From c34af612715ff773eb7c039211cc81ab45f8ea15 Mon Sep 17 00:00:00 2001 From: John OHara Date: Sat, 21 Dec 2024 12:59:54 +0000 Subject: [PATCH] Strongly typed Datastore configuration - Fixes #2088 - Updates from review - Update datastore config checks touse isBlank() - Update docs --- .../docs/Integrations/elasticsearch/index.md | 5 +- .../docs/Integrations/elasticsearch/modal.png | Bin 28936 -> 27733 bytes docs/site/content/en/openapi/openapi.yaml | 124 +++++-- .../data/datastore/BaseDatastoreConfig.java | 40 ++- .../CollectorApiDatastoreConfig.java | 22 +- .../horreum/api/data/datastore/Datastore.java | 8 +- .../api/data/datastore/DatastoreType.java | 67 +++- .../ElasticsearchDatastoreConfig.java | 62 ++-- .../datastore/PostgresDatastoreConfig.java | 11 + .../api/data/datastore/auth/APIKeyAuth.java | 18 + .../api/data/datastore/auth/NoAuth.java | 15 + .../data/datastore/auth/UsernamePassAuth.java | 21 ++ .../horreum/api/services/ConfigService.java | 10 +- .../horreum/datastore/BackendResolver.java | 24 -- .../datastore/CollectorApiDatastore.java | 24 +- .../horreum/datastore/DatastoreResolver.java | 46 +++ .../datastore/ElasticsearchDatastore.java | 113 ++++--- .../tools/horreum/svc/ConfigServiceImpl.java | 55 ++- .../tools/horreum/svc/RunServiceImpl.java | 57 ++-- .../src/main/resources/db/changeLog.xml | 70 ++++ .../tools/horreum/svc/DatasourceTest.java | 4 +- horreum-web/src/domain/admin/Datastores.tsx | 312 ++++++++++-------- .../admin/datastore/ModifyDatastoreModal.tsx | 308 ++++++++++------- .../src/domain/runs/ValidationErrorTable.tsx | 2 +- 24 files changed, 909 insertions(+), 509 deletions(-) create mode 100644 horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/APIKeyAuth.java create mode 100644 horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/NoAuth.java create mode 100644 horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/UsernamePassAuth.java delete mode 100644 horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/BackendResolver.java create mode 100644 horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/DatastoreResolver.java diff --git a/docs/site/content/en/docs/Integrations/elasticsearch/index.md b/docs/site/content/en/docs/Integrations/elasticsearch/index.md index 2da93388c..72878cbd2 100644 --- a/docs/site/content/en/docs/Integrations/elasticsearch/index.md +++ b/docs/site/content/en/docs/Integrations/elasticsearch/index.md @@ -26,8 +26,9 @@ New Datastore 1. Select `Elasticsearch` from the `Datastore Type` dropdown 2. Provide a `Name` for the Datastore 3. Enter the `URL` for the Elasticsearch instance - 4. Enter the `API Key` for the Elasticsearch instance, generated in step 1 - 5. Click `Save` + 4. Select `api-key` from `Authentication Type` from dropdown + 5. Enter the `API Key` for the Elasticsearch instance, generated in step 1 + 6. Click `Save` ## Test Configuration diff --git a/docs/site/content/en/docs/Integrations/elasticsearch/modal.png b/docs/site/content/en/docs/Integrations/elasticsearch/modal.png index 6396cd4be5f1f6162884967c1fb8fd5b3176b66d..29be7e020015ba36fe3809ef8849ba87f08e36b1 100644 GIT binary patch literal 27733 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 CU1mK1 literal 28936 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 { - }), - ELASTICSEARCH("ELASTICSEARCH", new TypeReference() { - }), - COLLECTORAPI("COLLECTORAPI", new TypeReference() { - }); + POSTGRES(PostgresDatastoreConfig.class), + ELASTICSEARCH(ElasticsearchDatastoreConfig.class), + COLLECTORAPI(CollectorApiDatastoreConfig.class); private static final DatastoreType[] VALUES = values(); - + private final String label; private final String name; - private final TypeReference typeReference; + private final String[] supportedAuths; + private final Boolean buildIn; + private final Class klass; - private DatastoreType(String name, TypeReference typeReference) { - this.typeReference = typeReference; - this.name = name; + DatastoreType(Class klass) { + this.klass = klass; + this.label = extractField(klass, "label"); + this.name = extractField(klass, "name"); + this.supportedAuths = extractField(klass, "auths"); + this.buildIn = extractField(klass, "builtIn"); + } + + private static T extractField(Class klass, String name) { + try { + Field supportedAuthField = klass.getField(name); + return (T) supportedAuthField.get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + Log.errorf("Could not extract field %s from class %s", name, klass.getName(), e); + return null; + } catch (NullPointerException e) { + Log.errorf("Could not extract field %s from class %s", name, klass.getName(), e); + return null; + } } - public TypeReference getTypeReference() { - return (TypeReference) typeReference; + public Class getTypeReference() { + return (Class) klass; } @JsonCreator @@ -37,4 +55,25 @@ public static DatastoreType fromString(String str) { return DatastoreType.valueOf(str); } } + + public TypeConfig getConfig() { + return new TypeConfig(this, name, label, buildIn, supportedAuths); + } + + public static class TypeConfig { + public String enumName; + public String name; + public String label; + + public String[] supportedAuths; + public Boolean builtIn; + + public TypeConfig(DatastoreType type, String name, String label, Boolean builtIn, String[] supportedAuths) { + this.enumName = type.name(); + this.name = name; + this.label = label; + this.builtIn = builtIn; + this.supportedAuths = supportedAuths; + } + } } diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/ElasticsearchDatastoreConfig.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/ElasticsearchDatastoreConfig.java index eaed6afce..7fc4714b9 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/ElasticsearchDatastoreConfig.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/ElasticsearchDatastoreConfig.java @@ -1,62 +1,44 @@ package io.hyperfoil.tools.horreum.api.data.datastore; -import static java.util.Objects.requireNonNullElse; - import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.APIKeyAuth; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.NoAuth; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.UsernamePassAuth; @Schema(type = SchemaType.OBJECT, required = true, description = "Type of backend datastore") public class ElasticsearchDatastoreConfig extends BaseDatastoreConfig { + public static final String[] auths = { NoAuth._TYPE, APIKeyAuth._TYPE, UsernamePassAuth._TYPE }; + public static final String name = "Elasticsearch"; + public static final String label = "Elasticsearch"; + public static final Boolean builtIn = false; + public ElasticsearchDatastoreConfig() { - super(false); - } - @Schema(type = SchemaType.STRING, description = "Elasticsearch API KEY") - public String apiKey; + } @Schema(type = SchemaType.STRING, required = true, description = "Elasticsearch url") public String url; - @Schema(type = SchemaType.STRING, description = "Elasticsearch username") - public String username; - - @Schema(type = SchemaType.STRING, description = "Elasticsearch password") - @JsonIgnore - public String password; - - @JsonProperty("password") - public void setSecrets(String password) { - this.password = password; - } - - @JsonProperty("password") - public String getMaskedSecrets() { - if (this.password != null) { - return "********"; - } else { - return null; - } - } - @Override public String validateConfig() { - String _apiKey = requireNonNullElse(apiKey, ""); - String _username = requireNonNullElse(username, ""); - String _password = requireNonNullElse(password, ""); - - if ("".equals(_apiKey) && ("".equals(_username) || "".equals(_password))) { - return "Either apiKey or username and password must be set"; + //TODO:: replace with pattern matching after upgrading to java 17 + if (authentication instanceof APIKeyAuth) { + APIKeyAuth apiKeyAuth = (APIKeyAuth) authentication; + if (apiKeyAuth.apiKey == null || apiKeyAuth.apiKey.isBlank()) { + return "apiKey must be set"; + } + } else if (authentication instanceof UsernamePassAuth) { + UsernamePassAuth usernamePassAuth = (UsernamePassAuth) authentication; + + if (usernamePassAuth.username == null || usernamePassAuth.username.isBlank() + || usernamePassAuth.password == null || usernamePassAuth.password.isBlank()) { + return "username and password must be set"; + } } - - if (!"".equals(_apiKey) && !("".equals(_username) || "".equals(_password))) { - return "Only apiKey or username and password can be set"; - } - return null; } diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/PostgresDatastoreConfig.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/PostgresDatastoreConfig.java index 2082f764d..9bd393159 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/PostgresDatastoreConfig.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/PostgresDatastoreConfig.java @@ -3,9 +3,20 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.NoAuth; + @Schema(type = SchemaType.OBJECT, required = true, description = "Built in backend datastore") public class PostgresDatastoreConfig extends BaseDatastoreConfig { + public static final String[] auths = { NoAuth._TYPE }; + public static final String name = "Postgres"; + public static final String label = "Postgres"; + public static final Boolean builtIn = true; + + public PostgresDatastoreConfig() { + super(true); + } + @Override public String validateConfig() { return null; diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/APIKeyAuth.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/APIKeyAuth.java new file mode 100644 index 000000000..3ee8639f8 --- /dev/null +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/APIKeyAuth.java @@ -0,0 +1,18 @@ +package io.hyperfoil.tools.horreum.api.data.datastore.auth; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class APIKeyAuth { + public static final String _TYPE = "api-key"; + + @Schema(type = SchemaType.STRING, description = "type") + public String type; + + @Schema(type = SchemaType.STRING, description = "Api key") + public String apiKey; + + public APIKeyAuth() { + this.type = _TYPE; + } +} diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/NoAuth.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/NoAuth.java new file mode 100644 index 000000000..705b2fd78 --- /dev/null +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/NoAuth.java @@ -0,0 +1,15 @@ +package io.hyperfoil.tools.horreum.api.data.datastore.auth; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class NoAuth { + public static final String _TYPE = "none"; + + @Schema(type = SchemaType.STRING, description = "type") + public String type; + + public NoAuth() { + this.type = _TYPE; + } +} diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/UsernamePassAuth.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/UsernamePassAuth.java new file mode 100644 index 000000000..676188ca3 --- /dev/null +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/data/datastore/auth/UsernamePassAuth.java @@ -0,0 +1,21 @@ +package io.hyperfoil.tools.horreum.api.data.datastore.auth; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public class UsernamePassAuth { + public static final String _TYPE = "username"; + + @Schema(type = SchemaType.STRING, description = "type") + public String type; + + @Schema(type = SchemaType.STRING, description = "Username") + public String username; + + @Schema(type = SchemaType.STRING, description = "Password") + public String password; + + public UsernamePassAuth() { + this.type = _TYPE; + } +} diff --git a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/services/ConfigService.java b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/services/ConfigService.java index 6949ccf60..bcd02746b 100644 --- a/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/services/ConfigService.java +++ b/horreum-api/src/main/java/io/hyperfoil/tools/horreum/api/services/ConfigService.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.hyperfoil.tools.horreum.api.data.datastore.Datastore; +import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; import io.quarkus.runtime.Startup; @Startup @@ -49,6 +50,11 @@ public interface ConfigService { }) List datastores(@PathParam("team") String team); + @GET + @Path("datastore/types") + @Operation(description = "Obtain list of available datastore types") + List datastoreTypes(); + @POST @Path("datastore") @Operation(description = "Create a new Datastore") @@ -66,12 +72,12 @@ public interface ConfigService { @GET @Path("datastore/{id}/test") @Operation(description = "Test a Datastore connection") - DatastoreTestResponse testDatastore(@PathParam("id") String datastoreId); + DatastoreTestResponse testDatastore(@PathParam("id") Integer datastoreId); @DELETE @Path("datastore/{id}") @Operation(description = "Test a Datastore") - void deleteDatastore(@PathParam("id") String datastoreId); + void deleteDatastore(@PathParam("id") Integer datastoreId); class VersionInfo { @Schema(description = "Version of Horreum", example = "0.9.4") diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/BackendResolver.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/BackendResolver.java deleted file mode 100644 index fc9e4fe8c..000000000 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/BackendResolver.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.hyperfoil.tools.horreum.datastore; - -import java.util.List; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; -import io.quarkus.arc.All; - -@ApplicationScoped -public class BackendResolver { - @Inject - @All - List backendStores; - - public Datastore getBackend(DatastoreType type) { - return backendStores.stream() - .filter(store -> store.type().equals(type)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Unknown datastore type: " + type)); - } - -} diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/CollectorApiDatastore.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/CollectorApiDatastore.java index 2e802859a..b830ccc03 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/CollectorApiDatastore.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/CollectorApiDatastore.java @@ -25,6 +25,7 @@ import io.hyperfoil.tools.horreum.api.data.datastore.CollectorApiDatastoreConfig; import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.APIKeyAuth; import io.hyperfoil.tools.horreum.entity.backend.DatastoreConfigDAO; import io.hyperfoil.tools.horreum.svc.ServiceException; @@ -66,8 +67,10 @@ public DatastoreResponse handleRun(JsonNode payload, + "&newerThan=" + newerThan + "&olderThan=" + olderThan); HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri); - builder.header("Content-Type", "application/json") - .header("token", jsonDatastoreConfig.apiKey); + builder.header("Content-Type", "application/json"); + if (jsonDatastoreConfig.authentication instanceof APIKeyAuth) { + builder.header("token", ((APIKeyAuth) jsonDatastoreConfig.authentication).apiKey); + } HttpRequest request = builder.build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != Response.Status.OK.getStatusCode()) { @@ -93,10 +96,15 @@ private static void verifyPayload(ObjectMapper mapper, CollectorApiDatastoreConf // Verify that the tag is in the distinct list of tags URI tagsUri = URI.create(jsonDatastoreConfig.url + "/tags/distinct"); HttpRequest.Builder tagsBuilder = HttpRequest.newBuilder().uri(tagsUri); - HttpRequest tagsRequest = tagsBuilder - .header("Content-Type", "application/json") - .header("token", jsonDatastoreConfig.apiKey).build(); - HttpResponse response = client.send(tagsRequest, HttpResponse.BodyHandlers.ofString()); + + tagsBuilder + .header("Content-Type", "application/json"); + + if (jsonDatastoreConfig.authentication instanceof APIKeyAuth) { + tagsBuilder + .header("token", ((APIKeyAuth) jsonDatastoreConfig.authentication).apiKey); + } + HttpResponse response = client.send(tagsBuilder.build(), HttpResponse.BodyHandlers.ofString()); String[] distinctTags; try { distinctTags = mapper.readValue(response.body(), String[].class); @@ -141,7 +149,9 @@ private static CollectorApiDatastoreConfig getCollectorApiDatastoreConfig(Datast log.error("Could not find collector API datastore: " + configuration.name); throw ServiceException.serverError("Could not find CollectorAPI datastore: " + configuration.name); } - assert jsonDatastoreConfig.apiKey != null : "API key must be set"; + if (jsonDatastoreConfig.authentication instanceof APIKeyAuth) { + assert ((APIKeyAuth) jsonDatastoreConfig.authentication).apiKey != null : "API key must be set"; + } assert jsonDatastoreConfig.url != null : "URL must be set"; return jsonDatastoreConfig; } diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/DatastoreResolver.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/DatastoreResolver.java new file mode 100644 index 000000000..ae51373e4 --- /dev/null +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/DatastoreResolver.java @@ -0,0 +1,46 @@ +package io.hyperfoil.tools.horreum.datastore; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; +import io.hyperfoil.tools.horreum.svc.ServiceException; +import io.quarkus.arc.All; + +@ApplicationScoped +public class DatastoreResolver { + @Inject + @All + List datastores; + + public Datastore getDatastore(DatastoreType type) { + return datastores.stream() + .filter(store -> store.type().equals(type)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Unknown datastore type: " + type)); + } + + public void validatedDatastoreConfig(DatastoreType type, Object config) { + io.hyperfoil.tools.horreum.datastore.Datastore datastoreImpl; + try { + datastoreImpl = this.getDatastore(type); + } catch (IllegalStateException e) { + throw ServiceException.badRequest("Unknown datastore type: " + type + + ". Please try again, if the problem persists please contact the system administrator."); + } + + if (datastoreImpl == null) { + throw ServiceException.badRequest("Unknown datastore type: " + type); + } + + String error = datastoreImpl.validateConfig(config); + + if (error != null) { + throw ServiceException.badRequest(error); + } + + } + +} diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/ElasticsearchDatastore.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/ElasticsearchDatastore.java index c5f6f1304..7d0b468fe 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/ElasticsearchDatastore.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/datastore/ElasticsearchDatastore.java @@ -2,9 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -32,6 +30,8 @@ import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; import io.hyperfoil.tools.horreum.api.data.datastore.ElasticsearchDatastoreConfig; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.APIKeyAuth; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.UsernamePassAuth; import io.hyperfoil.tools.horreum.entity.backend.DatastoreConfigDAO; @ApplicationScoped @@ -42,8 +42,6 @@ public class ElasticsearchDatastore implements Datastore { @Inject ObjectMapper mapper; - Map hostCache = new ConcurrentHashMap<>(); - @Override public DatastoreResponse handleRun(JsonNode payload, JsonNode metaData, @@ -67,18 +65,27 @@ public DatastoreResponse handleRun(JsonNode payload, if (elasticsearchDatastoreConfig != null) { RestClientBuilder builder = RestClient.builder(HttpHost.create(elasticsearchDatastoreConfig.url)); - if (elasticsearchDatastoreConfig.apiKey != null) { + + if (elasticsearchDatastoreConfig.authentication instanceof APIKeyAuth) { + + APIKeyAuth apiKeyAuth = (((APIKeyAuth) elasticsearchDatastoreConfig.authentication)); + builder.setDefaultHeaders(new Header[] { - new BasicHeader("Authorization", "ApiKey " + elasticsearchDatastoreConfig.apiKey) + new BasicHeader("Authorization", "ApiKey " + apiKeyAuth.apiKey) }); - } else { + + } else if (elasticsearchDatastoreConfig.authentication instanceof UsernamePassAuth) { final CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + + UsernamePassAuth usernamePassAuth = (((UsernamePassAuth) elasticsearchDatastoreConfig.authentication)); + credentialsProvider.setCredentials(AuthScope.ANY, - new UsernamePasswordCredentials(elasticsearchDatastoreConfig.username, - elasticsearchDatastoreConfig.password)); + new UsernamePasswordCredentials(usernamePassAuth.username, + usernamePassAuth.password)); builder.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder .setDefaultCredentialsProvider(credentialsProvider)); + } restClient = builder.build(); @@ -146,62 +153,64 @@ public DatastoreResponse handleRun(JsonNode payload, throw new BadRequestException("Schema is required for search requests"); } - //TODO: error handling - final MultiIndexQuery multiIndexQuery = mapper.treeToValue(apiRequest.query, MultiIndexQuery.class); - - //1st retrieve the list of docs from 1st Index - request = new Request( - "GET", - "/" + apiRequest.index + "/_search"); + try { + final MultiIndexQuery multiIndexQuery = mapper.treeToValue(apiRequest.query, MultiIndexQuery.class); + //1st retrieve the list of docs from 1st Index + request = new Request( + "GET", + "/" + apiRequest.index + "/_search"); - request.setJsonEntity(mapper.writeValueAsString(multiIndexQuery.metaQuery)); - finalString = extracted(restClient, request); + request.setJsonEntity(mapper.writeValueAsString(multiIndexQuery.metaQuery)); + finalString = extracted(restClient, request); - elasticResults = (ArrayNode) mapper.readTree(finalString).get("hits").get("hits"); - extractedResults = mapper.createArrayNode(); + elasticResults = (ArrayNode) mapper.readTree(finalString).get("hits").get("hits"); + extractedResults = mapper.createArrayNode(); - //2nd retrieve the docs from 2nd Index and combine into a single result with metadata and doc contents - final RestClient finalRestClient = restClient; //copy of restClient for use in lambda + //2nd retrieve the docs from 2nd Index and combine into a single result with metadata and doc contents + final RestClient finalRestClient = restClient; //copy of restClient for use in lambda - elasticResults.forEach(jsonNode -> { + elasticResults.forEach(jsonNode -> { - ObjectNode result = ((ObjectNode) jsonNode.get("_source")).put("$schema", schemaUri); - String docString = """ - { - "error": "Could not retrieve doc from secondary index" - "msg": "ERR_MSG" - } - """; + ObjectNode result = ((ObjectNode) jsonNode.get("_source")).put("$schema", schemaUri); + String docString = """ + { + "error": "Could not retrieve doc from secondary index" + "msg": "ERR_MSG" + } + """; - var subRequest = new Request( - "GET", - "/" + multiIndexQuery.targetIndex + "/_doc/" - + jsonNode.get("_source").get(multiIndexQuery.docField).textValue()); + var subRequest = new Request( + "GET", + "/" + multiIndexQuery.targetIndex + "/_doc/" + + jsonNode.get("_source").get(multiIndexQuery.docField).textValue()); - try { - docString = extracted(finalRestClient, subRequest); + try { + docString = extracted(finalRestClient, subRequest); - } catch (IOException e) { + } catch (IOException e) { - docString.replaceAll("ERR_MSG", e.getMessage()); - String msg = String.format("Could not query doc request: index: %s; docID: %s (%s)", - multiIndexQuery.targetIndex, multiIndexQuery.docField, e.getMessage()); - log.error(msg); - } + docString.replaceAll("ERR_MSG", e.getMessage()); + String msg = String.format("Could not query doc request: index: %s; docID: %s (%s)", + multiIndexQuery.targetIndex, multiIndexQuery.docField, e.getMessage()); + log.error(msg); + } - try { - result.put("$doc", mapper.readTree(docString)); - } catch (JsonProcessingException e) { - docString.replaceAll("ERR_MSG", e.getMessage()); - String msg = String.format("Could not parse doc result: %s, %s", docString, e.getMessage()); - log.error(msg); - } + try { + result.put("$doc", mapper.readTree(docString)); + } catch (JsonProcessingException e) { + docString.replaceAll("ERR_MSG", e.getMessage()); + String msg = String.format("Could not parse doc result: %s, %s", docString, e.getMessage()); + log.error(msg); + } - extractedResults.add(result); + extractedResults.add(result); - }); + }); - return new DatastoreResponse(extractedResults, payload); + return new DatastoreResponse(extractedResults, payload); + } catch (JsonProcessingException e) { + throw new RuntimeException("Could not process json query: " + e.getMessage()); + } default: throw new BadRequestException("Invalid request type: " + apiRequest.type); diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/ConfigServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/ConfigServiceImpl.java index edbfbd5d1..2863f2f08 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/ConfigServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/ConfigServiceImpl.java @@ -1,9 +1,6 @@ package io.hyperfoil.tools.horreum.svc; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import jakarta.annotation.security.PermitAll; @@ -20,8 +17,9 @@ import io.hyperfoil.tools.horreum.api.Version; import io.hyperfoil.tools.horreum.api.data.Access; import io.hyperfoil.tools.horreum.api.data.datastore.Datastore; +import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; import io.hyperfoil.tools.horreum.api.services.ConfigService; -import io.hyperfoil.tools.horreum.datastore.BackendResolver; +import io.hyperfoil.tools.horreum.datastore.DatastoreResolver; import io.hyperfoil.tools.horreum.entity.backend.DatastoreConfigDAO; import io.hyperfoil.tools.horreum.mapper.DatasourceMapper; import io.hyperfoil.tools.horreum.server.WithRoles; @@ -32,6 +30,10 @@ public class ConfigServiceImpl implements ConfigService { private static final Logger log = Logger.getLogger(ConfigServiceImpl.class); + //cache available dataStore configurations + private static final List datastoreTypes = Arrays.stream(DatastoreType.values()) + .map(DatastoreType::getConfig).toList(); + @ConfigProperty(name = "horreum.privacy") Optional privacyStatement; @@ -42,7 +44,7 @@ public class ConfigServiceImpl implements ConfigService { EntityManager em; @Inject - BackendResolver backendResolver; + DatastoreResolver backendResolver; @Override public KeycloakConfig keycloak() { @@ -80,6 +82,11 @@ public List datastores(String team) { } } + @Override + public List datastoreTypes() { + return datastoreTypes; + } + @Override @RolesAllowed(Roles.TESTER) @WithRoles @@ -102,29 +109,13 @@ public Integer newDatastore(Datastore datastore) { } else if (!identity.getRoles().contains(dao.owner)) { log.debugf("Failed to create datastore %s: requested owner %s, available roles: %s", dao.name, dao.owner, identity.getRoles()); - throw ServiceException.badRequest("This user does not have permissions to upload datastore for owner=" + dao.owner); + throw ServiceException.badRequest("This user does not have permissions to create datastore for owner=" + dao.owner); } if (dao.access == null) { dao.access = Access.PRIVATE; } - io.hyperfoil.tools.horreum.datastore.Datastore datastoreImpl; - try { - datastoreImpl = backendResolver.getBackend(datastore.type); - } catch (IllegalStateException e) { - throw ServiceException.badRequest("Unknown datastore type: " + datastore.type - + ". Please try again, if the problem persists please contact the system administrator."); - } - - if (datastoreImpl == null) { - throw ServiceException.badRequest("Unknown datastore type: " + datastore.type); - } - - String error = datastoreImpl.validateConfig(datastore.config); - - if (error != null) { - throw ServiceException.badRequest(error); - } + backendResolver.validatedDatastoreConfig(datastore.type, datastore.config); log.debugf("Creating new Datastore with owner=%s and access=%s", dao.owner, dao.access); @@ -144,18 +135,20 @@ public Integer newDatastore(Datastore datastore) { @RolesAllowed(Roles.TESTER) @WithRoles @Transactional - public Integer updateDatastore(Datastore backend) { - DatastoreConfigDAO dao = DatastoreConfigDAO.findById(backend.id); + public Integer updateDatastore(Datastore datastore) { + DatastoreConfigDAO dao = DatastoreConfigDAO.findById(datastore.id); if (dao == null) - throw ServiceException.notFound("Datastore with id " + backend.id + " does not exist"); + throw ServiceException.notFound("Datastore with id " + datastore.id + " does not exist"); - DatastoreConfigDAO newDao = DatasourceMapper.to(backend); + DatastoreConfigDAO newDao = DatasourceMapper.to(datastore); dao.type = newDao.type; dao.name = newDao.name; dao.configuration = newDao.configuration; dao.access = newDao.access; + backendResolver.validatedDatastoreConfig(datastore.type, datastore.config); + dao.persist(); return dao.id; @@ -166,7 +159,7 @@ public Integer updateDatastore(Datastore backend) { @RolesAllowed(Roles.TESTER) @WithRoles @Transactional - public DatastoreTestResponse testDatastore(String datastoreId) { + public DatastoreTestResponse testDatastore(Integer datastoreId) { return null; } @@ -174,8 +167,8 @@ public DatastoreTestResponse testDatastore(String datastoreId) { @RolesAllowed(Roles.TESTER) @WithRoles @Transactional - public void deleteDatastore(String datastoreId) { - DatastoreConfigDAO.deleteById(Integer.parseInt(datastoreId)); + public void deleteDatastore(Integer datastoreId) { + DatastoreConfigDAO.deleteById(datastoreId); } private String getString(String propertyName) { diff --git a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/RunServiceImpl.java b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/RunServiceImpl.java index 4d65d03e6..b357eb154 100644 --- a/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/RunServiceImpl.java +++ b/horreum-backend/src/main/java/io/hyperfoil/tools/horreum/svc/RunServiceImpl.java @@ -67,8 +67,8 @@ import io.hyperfoil.tools.horreum.api.services.SchemaService; import io.hyperfoil.tools.horreum.api.services.TestService; import io.hyperfoil.tools.horreum.bus.AsyncEventChannels; -import io.hyperfoil.tools.horreum.datastore.BackendResolver; import io.hyperfoil.tools.horreum.datastore.Datastore; +import io.hyperfoil.tools.horreum.datastore.DatastoreResolver; import io.hyperfoil.tools.horreum.datastore.DatastoreResponse; import io.hyperfoil.tools.horreum.entity.PersistentLogDAO; import io.hyperfoil.tools.horreum.entity.alerting.DataPointDAO; @@ -148,7 +148,7 @@ WHEN jsonb_typeof(data) = 'array' THEN ?1 IN (SELECT jsonb_array_elements(data)- @Inject ServiceMediator mediator; @Inject - BackendResolver backendResolver; + DatastoreResolver backendResolver; @Inject Session session; @@ -440,17 +440,13 @@ public Response addRunFromData(String start, String stop, String test, String ow } /** - * Processes and persists a run or multiple runs based on the provided data and metadata. - * It performs the following steps: - * - Validates and parses the input data string into a JSON structure. - * - Resolves the appropriate datastore to handle the run processing. - * - Handles single or multiple runs based on the datastore's response type. - * - Persists runs and their associated datasets in the database. - * - Queues dataset recalculation tasks for further processing. + * Processes and persists a run or multiple runs based on the provided data and metadata. It performs the following steps: - + * Validates and parses the input data string into a JSON structure. - Resolves the appropriate datastore to handle the run + * processing. - Handles single or multiple runs based on the datastore's response type. - Persists runs and their + * associated datasets in the database. - Queues dataset recalculation tasks for further processing. * - * If the response, in the case of datastore, contains more than 10 runs, - * the processing of the entire run is offloaded to an asynchronous queue. - * For fewer runs, processing occurs synchronously. + * If the response, in the case of datastore, contains more than 10 runs, the processing of the entire run is offloaded to + * an asynchronous queue. For fewer runs, processing occurs synchronously. * * @param start the start time for the run * @param stop the stop time for the run @@ -490,7 +486,8 @@ Response addRunFromData(String start, String stop, String test, TestDAO testEntity = testService.ensureTestExists(testNameOrId); - Datastore datastore = backendResolver.getBackend(testEntity.backendConfig.type); + Datastore datastore = backendResolver.getDatastore(testEntity.backendConfig.type); + DatastoreResponse response = datastore.handleRun(data, metadata, testEntity.backendConfig, Optional.ofNullable(schemaUri)); @@ -619,12 +616,10 @@ private Object findIfNotSet(String value, JsonNode data) { } /** - * Adds a new authenticated run to the database with appropriate ownership and access settings. - * This method performs the following tasks: - * - Ensures the run's ID is reset and metadata is correctly handled. - * - Determines the owner of the run, defaulting to a specific uploader role if no owner is provided. - * - Validates ownership permissions against the user's roles. - * - Persists or updates the run in the database and handles related datasets. + * Adds a new authenticated run to the database with appropriate ownership and access settings. This method performs the + * following tasks: - Ensures the run's ID is reset and metadata is correctly handled. - Determines the owner of the run, + * defaulting to a specific uploader role if no owner is provided. - Validates ownership permissions against the user's + * roles. - Persists or updates the run in the database and handles related datasets. * * @param run the RunDAO object containing the run details * @param test the TestDAO object containing the test details @@ -1134,15 +1129,15 @@ public void recalculateAll(String fromStr, String toStr) { } /** - * Transforms the data for a given run by applying applicable schemas and transformers. - * It ensures any existing datasets for the run are removed before creating new ones, - * handles timeouts for ongoing transformations, and creates datasets with the transformed data. - * If the flag {isRecalculation} is set to true the label values recalculation is performed - * right away synchronously otherwise it is completely skipped and let to the caller trigger it + * Transforms the data for a given run by applying applicable schemas and transformers. It ensures any existing datasets for + * the run are removed before creating new ones, handles timeouts for ongoing transformations, and creates datasets with the + * transformed data. If the flag {isRecalculation} is set to true the label values recalculation is performed right away + * synchronously otherwise it is completely skipped and let to the caller trigger it * * @param runId the ID of the run to transform * @param isRecalculation flag indicating if this is a recalculation - * @return the list of datasets ids that have been created, or empty list if the run is invalid or not found or already ongoing + * @return the list of datasets ids that have been created, or empty list if the run is invalid or not found or already + * ongoing */ @WithRoles(extras = Roles.HORREUM_SYSTEM) @Transactional @@ -1374,9 +1369,9 @@ List transform(int runId, boolean isRecalculation) { } /** - * Persists a dataset, optionally triggers recalculation events, and validates the dataset. - * The recalculation is getting triggered sync only if the {isRecalculation} is set to true - * otherwise it is completely skipped + * Persists a dataset, optionally triggers recalculation events, and validates the dataset. The recalculation is getting + * triggered sync only if the {isRecalculation} is set to true otherwise it is completely skipped + * * @param ds the DatasetDAO object to be persisted * @param isRecalculation whether the dataset is a result of recalculation * @return the ID of the persisted dataset @@ -1490,9 +1485,9 @@ static class RunFromUri { } /** - * Represents the result of persisting a run, including the run ID and associated dataset IDs. - * This class is used to encapsulate the ID of the newly persisted run and the IDs of the datasets - * connected to the run, providing a structured way to return this data. + * Represents the result of persisting a run, including the run ID and associated dataset IDs. This class is used to + * encapsulate the ID of the newly persisted run and the IDs of the datasets connected to the run, providing a structured + * way to return this data. */ public static class RunPersistence { private final Integer runId; diff --git a/horreum-backend/src/main/resources/db/changeLog.xml b/horreum-backend/src/main/resources/db/changeLog.xml index ae409e624..ba3d7324e 100644 --- a/horreum-backend/src/main/resources/db/changeLog.xml +++ b/horreum-backend/src/main/resources/db/changeLog.xml @@ -4688,4 +4688,74 @@ $$ LANGUAGE plpgsql; + + + ANY + + + UPDATE backendconfig + SET configuration = '{"builtIn": true, "authentication": {"type": "none"}}' + WHERE id = 1 + + + + UPDATE backendconfig + SET configuration = updated.newConfig + FROM (select id, jsonb_insert(newConfig - 'apiKey' - 'username' - 'password', '{authentication,type}', '"none"') as newConfig + from (select + id, + jsonb_insert(configuration, '{authentication}', '{}') as newConfig + from backendconfig + where jsonb_path_exists(configuration, '$.apiKey') + AND configuration ->> 'apiKey' = '' + AND not jsonb_path_exists(configuration, '$.username')) + ) + as updated + WHERE backendconfig.id = updated.id; + + + + + UPDATE backendconfig + SET configuration = updated.newConfig + FROM (select id, jsonb_insert(newConfig, '{authentication,type}', '"api-key"') as newConfig + from (Select rootConfig.id, + jsonb_insert(rootConfig.newConfig - 'apiKey' - 'username' - 'password', '{authentication,apiKey}', + to_jsonb(rootConfig.apiKey)) as newConfig + FROM (select id, + name, + configuration ->> 'apiKey' as apiKey, + configuration, + jsonb_insert(configuration, '{authentication}', '{}') as newConfig + from backendconfig + where jsonb_path_exists(configuration, '$.apiKey') + AND not configuration ->> 'apiKey' = '' + and not jsonb_path_exists(configuration, '$.authentication')) + as rootConfig) + as updated) + as updated + WHERE backendconfig.id = updated.id + + + + + UPDATE backendconfig + SET configuration = updated.newConfig + FROM + (select id, jsonb_insert(newConfig, '{authentication,type}', '"username"') as newConfig + FROM + (select id, jsonb_insert(updatedConfig.newConfig - 'password', '{authentication,password}', to_jsonb(updatedConfig.password)) as newConfig + from + (Select rootConfig.id, username, password, jsonb_insert(rootConfig.newConfig - 'apiKey' - 'username', '{authentication,username}', to_jsonb(rootConfig.username)) as newConfig + FROM + (select id, name, configuration ->> 'username' as username, configuration ->> 'password' as password, configuration, jsonb_insert(configuration, '{authentication}', '{}') as newConfig + from backendconfig + where jsonb_path_exists(configuration, '$.username') + ) as rootConfig + ) as updatedConfig) + ) as updated + WHERE backendconfig.id = updated.id; + + + diff --git a/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/DatasourceTest.java b/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/DatasourceTest.java index c733620aa..4ee774411 100644 --- a/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/DatasourceTest.java +++ b/horreum-backend/src/test/java/io/hyperfoil/tools/horreum/svc/DatasourceTest.java @@ -28,6 +28,7 @@ import io.hyperfoil.tools.horreum.api.data.datastore.Datastore; import io.hyperfoil.tools.horreum.api.data.datastore.DatastoreType; import io.hyperfoil.tools.horreum.api.data.datastore.ElasticsearchDatastoreConfig; +import io.hyperfoil.tools.horreum.api.data.datastore.auth.NoAuth; import io.hyperfoil.tools.horreum.bus.AsyncEventChannels; import io.hyperfoil.tools.horreum.entity.backend.DatastoreConfigDAO; import io.hyperfoil.tools.horreum.entity.data.DatasetDAO; @@ -233,13 +234,12 @@ private TestConfig createNewTestAndDatastores(TestInfo info) { Datastore newDatastore = new Datastore(); newDatastore.name = info.getDisplayName(); newDatastore.type = DatastoreType.ELASTICSEARCH; - newDatastore.builtIn = false; newDatastore.access = Access.PRIVATE; newDatastore.owner = TESTER_ROLES[0]; ElasticsearchDatastoreConfig elasticConfig = new ElasticsearchDatastoreConfig(); elasticConfig.url = hosts.get().get(0); - elasticConfig.apiKey = apiKey.orElse("123"); + elasticConfig.authentication = new NoAuth(); newDatastore.config = mapper.valueToTree(elasticConfig); diff --git a/horreum-web/src/domain/admin/Datastores.tsx b/horreum-web/src/domain/admin/Datastores.tsx index d7c028c73..749b57105 100644 --- a/horreum-web/src/domain/admin/Datastores.tsx +++ b/horreum-web/src/domain/admin/Datastores.tsx @@ -3,8 +3,7 @@ import {useContext, useEffect, useState} from "react" import {Button, Form, FormGroup} from "@patternfly/react-core" import ConfirmDeleteModal from "../../components/ConfirmDeleteModal" -import TeamSelect, {Team, SHOW_ALL} from "../../components/TeamSelect" - +import TeamSelect, {Team, SHOW_ALL, createTeam} from "../../components/TeamSelect" import { Table, @@ -26,22 +25,30 @@ import { configApi, Datastore, DatastoreTypeEnum, - ElasticsearchDatastoreConfig + ElasticsearchDatastoreConfig, TypeConfig } from "../../api"; import {AppContext} from "../../context/appContext"; import {AppContextType} from "../../context/@types/appContextTypes"; -import {noop} from "../../utils"; +import {useSelector} from "react-redux"; +import {defaultTeamSelector} from "../../auth"; interface dataStoreTableProps { datastores: Datastore[] + datastoreTypes: TypeConfig[] team: Team - persistDatastore: (backend: Datastore) => Promise - deleteDatastore: (id: string) => Promise + modifyDatastore: (backend: Datastore) => void + verifyDatastore: (backend: Datastore) => void + deleteDatastore: (backend: Datastore) => void } -const DatastoresTable = ( props: dataStoreTableProps) => { +const newBackendConfig: ElasticsearchDatastoreConfig | CollectorApiDatastoreConfig = { + url: "", + builtIn: false, + authentication: {'type': 'none'} +} +const DatastoresTable = (props: dataStoreTableProps) => { const columnNames = { type: 'Type', @@ -51,32 +58,82 @@ const DatastoresTable = ( props: dataStoreTableProps) => { }; - const defaultActions = (datastore: Datastore): IAction[] => [ + const defaultActions = (selectedDatastore: Datastore): IAction[] => [ { - title: `Edit`, onClick: () => editModalToggle(datastore.id) + title: `Edit`, onClick: () => props.modifyDatastore(selectedDatastore) }, { - title: `Test`, onClick: () => verifyModalToggle(datastore.id) + title: `Test`, onClick: () => props.verifyDatastore(selectedDatastore) }, { isSeparator: true }, { - title: `Delete`, onClick: () => deleteModalToggle(datastore.id) + title: `Delete`, onClick: () => props.deleteDatastore(selectedDatastore) }, ]; - const newBackendConfig: ElasticsearchDatastoreConfig | CollectorApiDatastoreConfig = { - url: "", - apiKey: "", - builtIn: false + + return ( + + + + + + + + + + + {props.datastores.map(teamDatastore => { + const rowActions: IAction[] | null = defaultActions(teamDatastore); + return ( + + + + + ) + })} + +
{columnNames.type}{columnNames.name}{columnNames.action}
{props.datastoreTypes.find((type) => type.enumName === teamDatastore.type)?.label}{teamDatastore.name} + +
+ + ); +} + +const errorFormatter = (error: any) => { + // Check if error has a message property + if (error.message) { + return error.message; + } + // If error is a string, return it as is + if (typeof error === 'string') { + return error; + } + // If error is an object, stringify it + if (typeof error === 'object') { + return JSON.stringify(error); } + // If none of the above, return a generic error message + return 'An error occurred'; +} + + +export default function Datastores() { + const {alerting} = useContext(AppContext) as AppContextType; + const defaultTeam = useSelector(defaultTeamSelector) || SHOW_ALL.key; + const [datastores, setDatastores] = useState([]) + const [datastoreTypes, setDatastoreTypes] = useState([]) + const [curTeam, setCurTeam] = useState(createTeam(defaultTeam)); const newDataStore: Datastore = { id: -1, name: "", - owner: props.team.key, - builtIn: false, + owner: curTeam.key, access: Access.Private, config: newBackendConfig, type: DatastoreTypeEnum.Postgres @@ -85,26 +142,19 @@ const DatastoresTable = ( props: dataStoreTableProps) => { const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [verifyModalOpen, setVerifyModalOpen] = useState(false); - const [datastore, setDatastore] = useState(newDataStore) - - const findDatastore = (id: number) => { - return props.datastores.filter( datastore => datastore.id === id).pop() || newDataStore - } - - const updateDatastore = ( datastore: Datastore) : void => { - setDatastore(datastore) - } + const [datastore, setDatastore] = useState(newDataStore) - const deleteModalToggle = (id: number) => { - setDatastore(findDatastore(id)) - setDeleteModalOpen(!deleteModalOpen); + const deleteModalToggle = () => { + if (datastore.config && !datastore.config.builtIn) { + setDeleteModalOpen(!deleteModalOpen); + } else { + alerting.dispatchError(null, "DELETE", "Can not delete built in datastore") + } }; - const editModalToggle = (id: number) => { - setDatastore(findDatastore(id)) + const editModalToggle = () => { setEditModalOpen(!editModalOpen); }; - const verifyModalToggle = (id: number) => { - setDatastore(findDatastore(id)) + const verifyModalToggle = () => { setVerifyModalOpen(!verifyModalOpen); }; @@ -113,106 +163,18 @@ const DatastoresTable = ( props: dataStoreTableProps) => { setEditModalOpen(!editModalOpen); }; - - return ( - - - - - - - - - - - - - - {props.datastores?.map(repo => { - const rowActions: IAction[] | null = defaultActions(repo); - return ( - - - - - ) - })} - -
{columnNames.type}{columnNames.name}{columnNames.action}
{repo.type}{repo.name} - -
-
- - - - - deleteModalToggle(0)} - onDelete={() => { - props.deleteDatastore(datastore?.id?.toString() || "") - deleteModalToggle(0) - return Promise.resolve() - } - } - /> - - - editModalToggle(0)} - persistDatastore={props.persistDatastore} - dataStore={datastore} - updateDatastore={updateDatastore} - onDelete={() => { - editModalToggle(0) - return Promise.resolve() - } - } - /> - - - verifyModalToggle(0)} - onDelete={() => { - verifyModalToggle(0) - return Promise.resolve() - } - } - /> - -
- ); -} - -export default function Datastores() { - const { alerting } = useContext(AppContext) as AppContextType; - - const [datastores, setDatastores] = useState([]) - const [curTeam, setCurTeam] = useState(SHOW_ALL) - - const fetchDataStores = () : Promise => { + const fetchDataStores = (): Promise => { return apiCall(configApi.datastores(curTeam.key), alerting, "FETCH_DATASTORES", "Cannot fetch datastores") .then(setDatastores) - // userApi.administrators().then( - // list => setAdmins(list.map(userElement)), - // error => dispatchError(dispatch, error, "FETCH ADMINS", "Cannot fetch administrators") - // ) + } + const fetchDataStoreTypes = (): Promise => { + return apiCall(configApi.datastoreTypes(), alerting, "FETCH_DATASTORE_TYPES", "Cannot fetch Datastore Types") + .then(setDatastoreTypes) } - const deleteDatastore = (id: string) : Promise => { - return apiCall(configApi.deleteDatastore(id), alerting, "DELETE_BACKEND", "Cannot delete datastore") + const deleteDatastore = (datastore: Datastore): Promise => { + return apiCall(configApi.deleteDatastore(datastore.id), alerting, "DELETE_BACKEND", "Cannot delete datastore") .then(fetchDataStores) } @@ -220,17 +182,42 @@ export default function Datastores() { fetchDataStores() }, [curTeam]) - const persistNewBackend = (datastore: Datastore) : Promise => { + useEffect(() => { + fetchDataStoreTypes() + }, []) + + + const updateDatastore = (datastore: Datastore): void => { + setDatastore(datastore) + } + + const handleDeleteDatastore = (datastore: Datastore) => { + setDatastore(datastore) + deleteModalToggle() + } + const handleModifyDatastore = (datastore: Datastore): void => { + setDatastore(datastore) + editModalToggle() + } + const handleVerifyDatastore = (datastore: Datastore): void => { + setDatastore(datastore) + verifyModalToggle() + } + const persistDatastore = (): Promise => { let apicall: Promise - if ( datastore.id == -1){ + if (datastore.id == -1) { apicall = apiCall(configApi.newDatastore(datastore), alerting, "NEW_DATASTORE", "Could create new datastore") } else { apicall = apiCall(configApi.updateDatastore(datastore), alerting, "UPDATE_DATASTORE", "Could create new datastore") } - return apicall.then(fetchDataStores) + return apicall + .then(fetchDataStores) + .then(editModalToggle) + .then(() => alerting.dispatchInfo("SAVE", "Saved!", "Datastore was successfully updated!", 3000)) + .catch(reason => alerting.dispatchError(reason, "Saved!", "Failed to save changes to Datastore", errorFormatter)) } return ( @@ -238,19 +225,68 @@ export default function Datastores() { { setCurTeam(selection) }} /> - + + + + + + + + + { + deleteDatastore(datastore) + deleteModalToggle() + return Promise.resolve() + } + } + /> + + + + + + { + verifyModalToggle() + return Promise.resolve() + } + } + /> + + ) diff --git a/horreum-web/src/domain/admin/datastore/ModifyDatastoreModal.tsx b/horreum-web/src/domain/admin/datastore/ModifyDatastoreModal.tsx index c21104af4..40d7c6ef2 100644 --- a/horreum-web/src/domain/admin/datastore/ModifyDatastoreModal.tsx +++ b/horreum-web/src/domain/admin/datastore/ModifyDatastoreModal.tsx @@ -1,4 +1,4 @@ -import React, {useContext, useState} from "react" +import React, {useEffect, useState} from "react" import { Button, Form, @@ -9,84 +9,179 @@ import { Modal, TextInput } from "@patternfly/react-core" import { + APIKeyAuth, Datastore, - DatastoreTypeEnum, ElasticsearchDatastoreConfig, + DatastoreTypeEnum, ElasticsearchDatastoreConfig, TypeConfig, UsernamePassAuth, } from "../../../api"; -import {AppContext} from "../../../context/appContext"; -import {AppContextType} from "../../../context/@types/appContextTypes"; type ConfirmDeleteModalProps = { isOpen: boolean dataStore: Datastore + dataStoreTypes: Array onClose(): void - onDelete(): Promise + onDelete(): void updateDatastore(datastore: Datastore): void - persistDatastore: (datastore: Datastore) => Promise + persistDatastore: () => Promise description: string extra?: string } -interface datastoreOption { - value: DatastoreTypeEnum, - label: string, - disabled: boolean, - urlDisabled: boolean, - usernameDisable: boolean, - tokenDisbaled: boolean +type UpdateDatastoreProps = { + dataStore: Datastore + updateDatastore(datastore: Datastore): void } -export default function ModifyDatastoreModal({isOpen, onClose, persistDatastore, dataStore, updateDatastore}: ConfirmDeleteModalProps) { +function NoAuth() { + return (<>) +} - const { alerting } = useContext(AppContext) as AppContextType; - const [enabledURL, setEnableUrl] = useState(false); - const [enabledToken, setEnableToken] = useState(false); +function UsernameAuth({dataStore, updateDatastore}: UpdateDatastoreProps) { + return ( + <> + + { + updateDatastore({ + ...dataStore, + config: { + ...dataStore.config, + authentication: {...dataStore.config.authentication, type: "username", username: value} + } + }) + }} + type="text" + id="horizontal-form-username" + aria-describedby="horizontal-form-token-helper" + name="horizontal-form-token" + isRequired={true} + /> + + + Please provide a Username to authenticate against datastore + + + + + { + updateDatastore({ + ...dataStore, + config: { + ...dataStore.config, + authentication: {...dataStore.config.authentication, type: "username", password: value} + } + }) + }} + type="text" + id="horizontal-form-password" + aria-describedby="horizontal-form-token-helper" + name="horizontal-form-token" + /> + + + Please provide a Password to authenticate against datastore + + + + + ) +} +function ApiKeyAuth({dataStore, updateDatastore}: UpdateDatastoreProps) { + return ( + <> + + { + updateDatastore({ + ...dataStore, + config: { + ...dataStore.config, + authentication: {...dataStore.config.authentication, type: "api-key", apiKey: value} + } + }) + }} + type="text" + id="horizontal-form-api-key" + aria-describedby="horizontal-form-token-helper" + name="horizontal-form-token" + isRequired={true} + /> + + + Please provide an API token to authenticate against datastore + + + + + ) +} - const handleOptionChange = (_event: React.FormEvent, value: string) => { - const option: datastoreOption | undefined = options.filter( optionvalue => optionvalue.value === value).pop() - if ( option ){ - setEnableUrl(option.urlDisabled) - setEnableToken(option.tokenDisbaled) +export default function ModifyDatastoreModal({ + isOpen, + onClose, + persistDatastore, + dataStore, + dataStoreTypes, + updateDatastore + }: ConfirmDeleteModalProps) { + + + const [authOptions, setAuthOptions] = useState>([]); - updateDatastore({...dataStore, type: option.value}) + useEffect(() => { //find initial auth options for the selected datastore + setAuthOptions(dataStoreTypes.find(t => t.enumName === dataStore.type)?.supportedAuths || []) + }, [dataStore]); + + const handleOptionChange = (_event: React.FormEvent, value: string) => { + const selectedOption = dataStoreTypes.find(t => t.enumName === value) + if (selectedOption) { + //some ts wizardry to get the enum value from the option name string + const datastoreTyped = selectedOption.name as keyof typeof DatastoreTypeEnum; + // let enumKey = Object.keys(DatastoreTypeEnum)[Object.values(DatastoreTypeEnum).indexOf(option.name)]; + updateDatastore({ + ...dataStore, + type: DatastoreTypeEnum[datastoreTyped], + config: {...dataStore.config, authentication: {type: 'none'}} + }) } }; - const errorFormatter = (error: any) => { - // Check if error has a message property - if (error.message) { - return error.message; + const handleAuthOptionChange = (_event: React.FormEvent, value: string) => { + switch (value) { //pita, but TS compiler complains need to switch on String value to get the correct union type + case "none": + updateDatastore({...dataStore, config: {...dataStore.config, authentication: {type: 'none'}}}) + break; + case "username": + updateDatastore({ + ...dataStore, + config: {...dataStore.config, authentication: {type: 'username', username: "", password: ""}} + }) + break; + case "api-key": + updateDatastore({ + ...dataStore, + config: {...dataStore.config, authentication: {type: 'api-key', apiKey: ""}} + }) + break; } - // If error is a string, return it as is - if (typeof error === 'string') { - return error; - } - // If error is an object, stringify it - if (typeof error === 'object') { - return JSON.stringify(error); - } - // If none of the above, return a generic error message - return 'An error occurred'; - } - - const saveBackend = () => { - persistDatastore(dataStore) - .then( () => { - onClose(); - alerting.dispatchInfo("SAVE", "Saved!", "Datastore was successfully updated!", 3000) - }) - .catch(reason => alerting.dispatchError(reason, "Saved!", "Failed to save changes to Datastore", errorFormatter)) - } - - const options : datastoreOption[] = [ - { value: DatastoreTypeEnum.Postgres, label: 'Please select...', disabled: true, urlDisabled: true, usernameDisable: true, tokenDisbaled: true }, - { value: DatastoreTypeEnum.Elasticsearch, label: 'Elasticsearch', disabled: false, urlDisabled: false, usernameDisable: false, tokenDisbaled: false }, - { value: DatastoreTypeEnum.Collectorapi, label: 'Collector API', disabled: false, urlDisabled: false, usernameDisable: true, tokenDisbaled: false }, - ]; + }; const actionButtons = [ - , - + , + ] return ( @@ -102,8 +197,11 @@ export default function ModifyDatastoreModal({isOpen, onClose, persistDatastore, name="horizontal-form-datastore-type" aria-label="Backend Type" > - {options.map((option, index) => ( - + + {dataStoreTypes.filter(type => !type.builtIn).map((option, index) => ( + ))} @@ -134,11 +232,10 @@ export default function ModifyDatastoreModal({isOpen, onClose, persistDatastore, { - const config :ElasticsearchDatastoreConfig = dataStore.config as ElasticsearchDatastoreConfig; + const config: ElasticsearchDatastoreConfig = dataStore.config as ElasticsearchDatastoreConfig; config.url = value updateDatastore({...dataStore, config: config}) - }} - isDisabled={enabledURL} + }} type="text" id="horizontal-form-url" aria-describedby="horizontal-form-name-helper" @@ -146,77 +243,40 @@ export default function ModifyDatastoreModal({isOpen, onClose, persistDatastore, /> - Please provide the full host URL to for the datastore service + Please provide the full host URL to for the datastore + service - - { - const config :ElasticsearchDatastoreConfig = dataStore.config as ElasticsearchDatastoreConfig; - config.apiKey = value - updateDatastore({...dataStore, config: config}) - }}isDisabled={enabledToken} - type="text" - id="horizontal-form-api-key" - aria-describedby="horizontal-form-token-helper" - name="horizontal-form-token" - /> - - - Please provide an API token to authenticate against datastore - - + + + + {authOptions.map((authOption, index) => ( + + ))} + - - { - const config :ElasticsearchDatastoreConfig = dataStore.config as ElasticsearchDatastoreConfig; - config.username = value - updateDatastore({...dataStore, config: config}) - }}isDisabled={enabledToken} - type="text" - id="horizontal-form-username" - aria-describedby="horizontal-form-token-helper" - name="horizontal-form-token" + {dataStore.config.authentication.type === "none" && ( + )} + {dataStore.config.authentication.type === "username" && ( + - - - Please provide a Username to authenticate against datastore - - - - - { - const config :ElasticsearchDatastoreConfig = dataStore.config as ElasticsearchDatastoreConfig; - config.password = value - updateDatastore({...dataStore, config: config}) - }}isDisabled={enabledToken} - type="text" - id="horizontal-form-password" - aria-describedby="horizontal-form-token-helper" - name="horizontal-form-token" + )} + {dataStore.config.authentication.type === "api-key" && ( + - - - Please provide a Password to authenticate against datastore - - - + )} ) diff --git a/horreum-web/src/domain/runs/ValidationErrorTable.tsx b/horreum-web/src/domain/runs/ValidationErrorTable.tsx index 93cdea06e..a46fee4d6 100644 --- a/horreum-web/src/domain/runs/ValidationErrorTable.tsx +++ b/horreum-web/src/domain/runs/ValidationErrorTable.tsx @@ -26,7 +26,7 @@ export default function ValidationErrorTable(props: ValidationErrorTableProps) { {props.schemas.find(s => s.id === error.schemaId)?.name || "unknown schema " + error.schemaId} - : "None"} + : "none"} {error.error.type} {error.error.path}