diff --git a/src/main/java/io/cryostat/ExceptionMappers.java b/src/main/java/io/cryostat/ExceptionMappers.java index 67df866f1..3d17a8899 100644 --- a/src/main/java/io/cryostat/ExceptionMappers.java +++ b/src/main/java/io/cryostat/ExceptionMappers.java @@ -20,6 +20,7 @@ import org.hibernate.exception.ConstraintViolationException; import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.server.ServerExceptionMapper; +import org.projectnessie.cel.tools.ScriptException; public class ExceptionMappers { @ServerExceptionMapper @@ -28,7 +29,7 @@ public RestResponse mapNoResultException(NoResultException ex) { } @ServerExceptionMapper - public RestResponse mapNoResultException(ConstraintViolationException ex) { + public RestResponse mapConstraintViolationException(ConstraintViolationException ex) { return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code()); } @@ -36,4 +37,9 @@ public RestResponse mapNoResultException(ConstraintViolationException ex) public RestResponse mapValidationException(jakarta.validation.ValidationException ex) { return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code()); } + + @ServerExceptionMapper + public RestResponse mapScriptException(ScriptException ex) { + return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code()); + } } diff --git a/src/main/java/io/cryostat/credentials/Credential.java b/src/main/java/io/cryostat/credentials/Credential.java index 7cdfd9bb7..00478f806 100644 --- a/src/main/java/io/cryostat/credentials/Credential.java +++ b/src/main/java/io/cryostat/credentials/Credential.java @@ -15,6 +15,7 @@ */ package io.cryostat.credentials; +import io.cryostat.expressions.MatchExpression; import io.cryostat.ws.MessagingServer; import io.cryostat.ws.Notification; @@ -22,20 +23,26 @@ import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; import jakarta.persistence.PostPersist; import jakarta.persistence.PostRemove; import jakarta.persistence.PostUpdate; import org.hibernate.annotations.ColumnTransformer; +import org.projectnessie.cel.tools.ScriptException; @Entity @EntityListeners(Credential.Listener.class) public class Credential extends PanacheEntity { - @Column(nullable = false, updatable = false) - public String matchExpression; + @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinColumn(name = "matchExpression") + public MatchExpression matchExpression; @ColumnTransformer( read = "pgp_sym_decrypt(username, current_setting('encrypt.key'))", @@ -53,28 +60,30 @@ public class Credential extends PanacheEntity { static class Listener { @Inject EventBus bus; - - // TODO prePersist validate the matchExpression syntax + @Inject MatchExpression.TargetMatcher targetMatcher; @PostPersist - public void postPersist(Credential credential) { + public void postPersist(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), - new Notification("CredentialsStored", Credentials.safeResult(credential))); + new Notification( + "CredentialsStored", Credentials.notificationResult(credential))); } @PostUpdate - public void postUpdate(Credential credential) { + public void postUpdate(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), - new Notification("CredentialsUpdated", Credentials.safeResult(credential))); + new Notification( + "CredentialsUpdated", Credentials.notificationResult(credential))); } @PostRemove - public void postRemove(Credential credential) { + public void postRemove(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), - new Notification("CredentialsDeleted", Credentials.safeResult(credential))); + new Notification( + "CredentialsDeleted", Credentials.notificationResult(credential))); } } } diff --git a/src/main/java/io/cryostat/credentials/Credentials.java b/src/main/java/io/cryostat/credentials/Credentials.java index eb64db29c..0234ca5a6 100644 --- a/src/main/java/io/cryostat/credentials/Credentials.java +++ b/src/main/java/io/cryostat/credentials/Credentials.java @@ -19,38 +19,60 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import io.cryostat.V2Response; +import io.cryostat.expressions.MatchExpression; +import io.cryostat.expressions.MatchExpression.TargetMatcher; +import io.smallrye.common.annotation.Blocking; import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestForm; import org.jboss.resteasy.reactive.RestPath; import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.RestResponse.ResponseBuilder; import org.jboss.resteasy.reactive.RestResponse.Status; +import org.projectnessie.cel.tools.ScriptException; @Path("/api/v2.2/credentials") public class Credentials { + @Inject TargetMatcher targetMatcher; + @Inject Logger logger; + @GET @RolesAllowed("read") public V2Response list() { List credentials = Credential.listAll(); return V2Response.json( - credentials.stream().map(Credentials::safeResult).toList(), Status.OK.toString()); + credentials.stream() + .map( + c -> { + try { + return Credentials.safeResult(c, targetMatcher); + } catch (ScriptException e) { + logger.warn(e); + return null; + } + }) + .filter(Objects::nonNull) + .toList(), + Status.OK.toString()); } @GET @RolesAllowed("read") @Path("/{id}") - public V2Response get(@RestPath long id) { + public V2Response get(@RestPath long id) throws ScriptException { Credential credential = Credential.find("id", id).singleResult(); - return V2Response.json(safeMatchedResult(credential), Status.OK.toString()); + return V2Response.json(safeMatchedResult(credential, targetMatcher), Status.OK.toString()); } @Transactional @@ -60,8 +82,10 @@ public RestResponse create( @RestForm String matchExpression, @RestForm String username, @RestForm String password) { + MatchExpression expr = new MatchExpression(matchExpression); + expr.persist(); Credential credential = new Credential(); - credential.matchExpression = matchExpression; + credential.matchExpression = expr; credential.username = username; credential.password = password; credential.persist(); @@ -78,19 +102,35 @@ public void delete(@RestPath long id) { credential.delete(); } - static Map safeResult(Credential credential) { + static Map notificationResult(Credential credential) throws ScriptException { Map result = new HashMap<>(); result.put("id", credential.id); result.put("matchExpression", credential.matchExpression); - // TODO + // TODO populating this on the credential post-persist hook leads to a database validation + // error because the expression ends up getting defined twice with the same ID, somehow. + // Populating this field with 0 means the UI is inaccurate when a new credential is first + // defined, but after a refresh the data correctly updates. result.put("numMatchingTargets", 0); return result; } - static Map safeMatchedResult(Credential credential) { + @Blocking + static Map safeResult(Credential credential, TargetMatcher matcher) + throws ScriptException { + Map result = new HashMap<>(); + result.put("id", credential.id); + result.put("matchExpression", credential.matchExpression); + result.put( + "numMatchingTargets", matcher.match(credential.matchExpression).targets().size()); + return result; + } + + @Blocking + static Map safeMatchedResult(Credential credential, TargetMatcher matcher) + throws ScriptException { Map result = new HashMap<>(); result.put("matchExpression", credential.matchExpression); - result.put("targets", List.of()); + result.put("targets", matcher.match(credential.matchExpression).targets()); return result; } } diff --git a/src/main/java/io/cryostat/expressions/MatchExpression.java b/src/main/java/io/cryostat/expressions/MatchExpression.java new file mode 100644 index 000000000..447305df2 --- /dev/null +++ b/src/main/java/io/cryostat/expressions/MatchExpression.java @@ -0,0 +1,170 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.expressions; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import io.cryostat.targets.Target; +import io.cryostat.ws.MessagingServer; +import io.cryostat.ws.Notification; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import io.vertx.mutiny.core.eventbus.EventBus; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.PostPersist; +import jakarta.persistence.PostRemove; +import jakarta.persistence.PostUpdate; +import jakarta.persistence.PrePersist; +import jakarta.validation.ValidationException; +import jakarta.validation.constraints.NotBlank; +import org.jboss.logging.Logger; +import org.projectnessie.cel.tools.ScriptException; + +@Entity +@EntityListeners(MatchExpression.Listener.class) +public class MatchExpression extends PanacheEntity { + public static final String EXPRESSION_ADDRESS = "io.cryostat.expressions.MatchExpression"; + + @Column(updatable = false, nullable = false) + @NotBlank + // TODO + // when serializing matchExpressions (ex. as a field of Rules), just use the script as the + // serialized form of the expression object. This is for 2.x compat only + @JsonValue + public String script; + + MatchExpression() { + this.script = null; + } + + @JsonCreator + public MatchExpression(String script) { + this.script = script; + } + + @ApplicationScoped + public static class TargetMatcher { + @Inject MatchExpressionEvaluator evaluator; + @Inject Logger logger; + + public MatchedExpression match(MatchExpression expr, Collection targets) + throws ScriptException { + Set matches = + new HashSet<>(Optional.ofNullable(targets).orElseGet(() -> Set.of())); + var it = matches.iterator(); + while (it.hasNext()) { + if (!evaluator.applies(expr, it.next())) { + it.remove(); + } + } + return new MatchedExpression(expr, matches); + } + + public MatchedExpression match(MatchExpression expr) throws ScriptException { + return match(expr, Target.listAll()); + } + } + + public static record MatchedExpression( + @Nullable Long id, String expression, Collection targets) { + public MatchedExpression { + Objects.requireNonNull(expression); + Objects.requireNonNull(targets); + } + + MatchedExpression(MatchExpression expr, Collection targets) { + this(expr.id, expr.script, targets); + } + } + + @ApplicationScoped + static class Listener { + @Inject EventBus bus; + @Inject MatchExpressionEvaluator evaluator; + @Inject Logger logger; + + @PrePersist + public void prePersist(MatchExpression expr) throws ValidationException { + try { + evaluator.createScript(expr.script); + } catch (Exception e) { + logger.error("Invalid match expression", e); + throw new ValidationException(e); + } + } + + @PostPersist + public void postPersist(MatchExpression expr) { + bus.publish( + EXPRESSION_ADDRESS, new ExpressionEvent(ExpressionEventCategory.CREATED, expr)); + notify(ExpressionEventCategory.CREATED, expr); + } + + @PostUpdate + public void postUpdate(MatchExpression expr) { + bus.publish( + EXPRESSION_ADDRESS, new ExpressionEvent(ExpressionEventCategory.UPDATED, expr)); + notify(ExpressionEventCategory.UPDATED, expr); + } + + @PostRemove + public void postRemove(MatchExpression expr) { + bus.publish( + EXPRESSION_ADDRESS, new ExpressionEvent(ExpressionEventCategory.DELETED, expr)); + notify(ExpressionEventCategory.DELETED, expr); + } + + private void notify(ExpressionEventCategory category, MatchExpression expr) { + bus.publish( + MessagingServer.class.getName(), + new Notification(category.getCategory(), expr)); + } + } + + public record ExpressionEvent(ExpressionEventCategory category, MatchExpression expression) { + public ExpressionEvent { + Objects.requireNonNull(category); + Objects.requireNonNull(expression); + } + } + + public enum ExpressionEventCategory { + CREATED("ExpressionCreated"), + UPDATED("ExpressionUpdated"), + DELETED("ExpressionDeleted"); + + private final String name; + + ExpressionEventCategory(String name) { + this.name = name; + } + + public String getCategory() { + return name; + } + } +} diff --git a/src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java b/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java similarity index 76% rename from src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java rename to src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java index 283c60d22..eeee087cc 100644 --- a/src/main/java/io/cryostat/rules/MatchExpressionEvaluator.java +++ b/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.cryostat.rules; +package io.cryostat.expressions; import java.util.List; import java.util.Map; @@ -23,7 +23,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import io.cryostat.rules.Rule.RuleEvent; +import io.cryostat.expressions.MatchExpression.ExpressionEvent; import io.cryostat.targets.Target; import io.quarkus.cache.Cache; @@ -36,7 +36,6 @@ import io.smallrye.common.annotation.Blocking; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; import jdk.jfr.Category; import jdk.jfr.Event; import jdk.jfr.Label; @@ -57,15 +56,14 @@ public class MatchExpressionEvaluator { @Inject Logger logger; @Inject CacheManager cacheManager; - @Transactional @Blocking - @ConsumeEvent(Rule.RULE_ADDRESS) - void onMessage(RuleEvent event) { + @ConsumeEvent(MatchExpression.EXPRESSION_ADDRESS) + void onMessage(ExpressionEvent event) { switch (event.category()) { case CREATED: break; case DELETED: - invalidate(event.rule().matchExpression); + invalidate(event.expression().script); break; case UPDATED: break; @@ -74,15 +72,17 @@ void onMessage(RuleEvent event) { } } - private Script createScript(String matchExpression) throws ScriptCreateException { + Script createScript(String matchExpression) throws ScriptCreateException { ScriptCreationEvent evt = new ScriptCreationEvent(); try { evt.begin(); return scriptHost .buildScript(matchExpression) .withDeclarations( - Decls.newVar("target", Decls.newObjectType(Target.class.getName()))) - .withTypes(Target.class) + Decls.newVar( + "target", + Decls.newObjectType(SimplifiedTarget.class.getName()))) + .withTypes(SimplifiedTarget.class) .build(); } finally { evt.end(); @@ -93,13 +93,13 @@ private Script createScript(String matchExpression) throws ScriptCreateException } @CacheResult(cacheName = CACHE_NAME) - boolean load(String matchExpression, Target serviceRef) throws ScriptException { + boolean load(String matchExpression, Target target) throws ScriptException { Script script = createScript(matchExpression); - return script.execute(Boolean.class, Map.of("target", serviceRef)); + return script.execute(Boolean.class, Map.of("target", SimplifiedTarget.from(target))); } @CacheInvalidate(cacheName = CACHE_NAME) - void invalidate(String matchExpression, Target serviceRef) {} + void invalidate(String matchExpression, Target target) {} void invalidate(String matchExpression) { Optional cache = cacheManager.getCache(CACHE_NAME); @@ -131,11 +131,11 @@ void invalidate(String matchExpression) { } } - public boolean applies(String matchExpression, Target target) throws ScriptException { + public boolean applies(MatchExpression matchExpression, Target target) throws ScriptException { MatchExpressionAppliesEvent evt = new MatchExpressionAppliesEvent(matchExpression); try { evt.begin(); - return load(matchExpression, target); + return load(matchExpression.script, target); } catch (CompletionException e) { if (e.getCause() instanceof ScriptException) { throw (ScriptException) e.getCause(); @@ -149,7 +149,7 @@ public boolean applies(String matchExpression, Target target) throws ScriptExcep } } - public List getMatchedTargets(String matchExpression) { + public List getMatchedTargets(MatchExpression matchExpression) { try (Stream targets = Target.streamAll()) { return targets.filter( target -> { @@ -177,8 +177,8 @@ public static class MatchExpressionAppliesEvent extends Event { String matchExpression; - MatchExpressionAppliesEvent(String matchExpression) { - this.matchExpression = matchExpression; + MatchExpressionAppliesEvent(MatchExpression matchExpression) { + this.matchExpression = matchExpression.script; } } @@ -190,4 +190,24 @@ public static class MatchExpressionAppliesEvent extends Event { // justification = "The event fields are recorded with JFR instead of accessed // directly") public static class ScriptCreationEvent extends Event {} + + /** + * Restricted view of a {@link io.cryostat.targets.Target} with only particular + * expression-relevant fields exposed, connection URI exposed as a String, etc. + */ + private static record SimplifiedTarget( + String connectUrl, + String alias, + String jvmId, + Map labels, + Target.Annotations annotations) { + static SimplifiedTarget from(Target target) { + return new SimplifiedTarget( + target.connectUrl.toString(), + target.alias, + target.jvmId, + target.labels, + target.annotations); + } + } } diff --git a/src/main/java/io/cryostat/expressions/MatchExpressions.java b/src/main/java/io/cryostat/expressions/MatchExpressions.java new file mode 100644 index 000000000..80ce6bb09 --- /dev/null +++ b/src/main/java/io/cryostat/expressions/MatchExpressions.java @@ -0,0 +1,91 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.expressions; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.cryostat.V2Response; +import io.cryostat.expressions.MatchExpression.MatchedExpression; +import io.cryostat.targets.Target; + +import io.smallrye.common.annotation.Blocking; +import io.smallrye.mutiny.Multi; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response.Status; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestPath; +import org.projectnessie.cel.tools.ScriptException; + +@Path("/api/beta/matchExpressions") +public class MatchExpressions { + + @Inject MatchExpression.TargetMatcher targetMatcher; + @Inject Logger logger; + + @POST + @RolesAllowed("read") + @Blocking + // FIXME in a later API version this request should not accept full target objects from the + // client but instead only a list of IDs, which will then be pulled from the target discovery + // database for testing + public V2Response test(RequestData requestData) throws ScriptException { + var matched = + targetMatcher.match( + new MatchExpression(requestData.matchExpression), requestData.targets); + return V2Response.json(matched, Status.OK.toString()); + } + + @GET + @RolesAllowed("read") + @Blocking + public Multi> list() { + List exprs = MatchExpression.listAll(); + // FIXME hack so that this endpoint renders the response as the entity object with id and + // script fields, rather than allowing Jackson serialization to handle it normally where it + // will be encoded as only the script as a raw string + return Multi.createFrom() + .items(exprs.stream().map(expr -> Map.of("id", expr.id, "script", expr.script))); + } + + @GET + @Path("/{id}") + @RolesAllowed("read") + @Blocking + public MatchedExpression get(@RestPath long id) throws ScriptException { + MatchExpression expr = MatchExpression.findById(id); + if (expr == null) { + throw new NotFoundException(); + } + return targetMatcher.match(expr); + } + + static record RequestData(String matchExpression, List targets) { + RequestData { + Objects.requireNonNull(matchExpression); + if (targets == null) { + targets = Collections.emptyList(); + } + } + } +} diff --git a/src/main/java/io/cryostat/rules/Rule.java b/src/main/java/io/cryostat/rules/Rule.java index 930389e4a..083afda96 100644 --- a/src/main/java/io/cryostat/rules/Rule.java +++ b/src/main/java/io/cryostat/rules/Rule.java @@ -15,6 +15,9 @@ */ package io.cryostat.rules; +import java.util.Objects; + +import io.cryostat.expressions.MatchExpression; import io.cryostat.ws.MessagingServer; import io.cryostat.ws.Notification; @@ -22,9 +25,13 @@ import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; import jakarta.persistence.PostPersist; import jakarta.persistence.PostRemove; import jakarta.persistence.PostUpdate; @@ -42,9 +49,9 @@ public class Rule extends PanacheEntity { public String description; - @Column(nullable = false) - @NotBlank(message = "matchExpression cannot be blank") - public String matchExpression; + @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinColumn(name = "matchExpression") + public MatchExpression matchExpression; @Column(nullable = false) @NotBlank(message = "eventSpecifier cannot be blank") @@ -114,7 +121,12 @@ private void notify(RuleEventCategory category, Rule rule) { } } - public record RuleEvent(RuleEventCategory category, Rule rule) {} + public record RuleEvent(RuleEventCategory category, Rule rule) { + public RuleEvent { + Objects.requireNonNull(category); + Objects.requireNonNull(rule); + } + } public enum RuleEventCategory { CREATED("RuleCreated"), diff --git a/src/main/java/io/cryostat/rules/RuleService.java b/src/main/java/io/cryostat/rules/RuleService.java index b9836305f..33bf8f7eb 100644 --- a/src/main/java/io/cryostat/rules/RuleService.java +++ b/src/main/java/io/cryostat/rules/RuleService.java @@ -20,6 +20,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; @@ -33,6 +34,7 @@ import io.cryostat.core.net.JFRConnection; import io.cryostat.core.templates.TemplateType; +import io.cryostat.expressions.MatchExpressionEvaluator; import io.cryostat.recordings.ActiveRecording; import io.cryostat.recordings.RecordingHelper; import io.cryostat.recordings.RecordingHelper.RecordingReplace; @@ -277,5 +279,10 @@ private void cancelTasksForRule(Rule rule) { } } - public record RuleRecording(Rule rule, ActiveRecording recording) {} + public record RuleRecording(Rule rule, ActiveRecording recording) { + public RuleRecording { + Objects.requireNonNull(rule); + Objects.requireNonNull(recording); + } + } } diff --git a/src/main/java/io/cryostat/rules/Rules.java b/src/main/java/io/cryostat/rules/Rules.java index 477d52552..70f7e0e39 100644 --- a/src/main/java/io/cryostat/rules/Rules.java +++ b/src/main/java/io/cryostat/rules/Rules.java @@ -16,6 +16,7 @@ package io.cryostat.rules; import io.cryostat.V2Response; +import io.cryostat.expressions.MatchExpression; import io.vertx.core.json.JsonObject; import io.vertx.mutiny.core.eventbus.EventBus; @@ -112,10 +113,12 @@ public RestResponse create( @RestForm int maxAgeSeconds, @RestForm int maxSizeBytes, @RestForm boolean enabled) { + MatchExpression expr = new MatchExpression(matchExpression); + expr.persist(); Rule rule = new Rule(); rule.name = name; rule.description = description; - rule.matchExpression = matchExpression; + rule.matchExpression = expr; rule.eventSpecifier = eventSpecifier; rule.archivalPeriodSeconds = archivalPeriodSeconds; rule.initialDelaySeconds = initialDelaySeconds; diff --git a/src/test/java/io/cryostat/expressions/MatchExpressionsTest.java b/src/test/java/io/cryostat/expressions/MatchExpressionsTest.java new file mode 100644 index 000000000..724aad986 --- /dev/null +++ b/src/test/java/io/cryostat/expressions/MatchExpressionsTest.java @@ -0,0 +1,72 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.expressions; + +import static io.cryostat.TestUtils.givenBasicAuth; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import jakarta.transaction.Transactional; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestHTTPEndpoint(MatchExpressions.class) +public class MatchExpressionsTest { + + static final Map ALL_MATCHING_EXPRESSION = Map.of("matchExpression", "true"); + + @AfterEach + @Transactional + public void afterEach() { + MatchExpression.deleteAll(); + } + + @Test + public void testUnauthorizedPost() { + given().body(ALL_MATCHING_EXPRESSION).when().post().then().assertThat().statusCode(401); + } + + @Test + public void testPostWithoutTargets() { + var expectation = new HashMap<>(); + expectation.put("id", null); + expectation.put("expression", "true"); + expectation.put("targets", List.of()); + givenBasicAuth() + .contentType(ContentType.JSON) + .body(ALL_MATCHING_EXPRESSION) + .when() + .post() + .then() + .assertThat() + .statusCode(200) + .and() + .contentType(ContentType.JSON) + .and() + .body("meta.type", Matchers.equalTo("application/json")) + .body("meta.status", Matchers.equalTo("OK")) + .body("data.result", Matchers.equalTo(expectation)); + } +} diff --git a/src/test/java/io/cryostat/rules/RulesTest.java b/src/test/java/io/cryostat/rules/RulesTest.java index 837adb06f..ec153dd72 100644 --- a/src/test/java/io/cryostat/rules/RulesTest.java +++ b/src/test/java/io/cryostat/rules/RulesTest.java @@ -38,6 +38,9 @@ @TestHTTPEndpoint(Rules.class) public class RulesTest { + private static final String EXPR_1 = "true"; + private static final String EXPR_2 = "false"; + @InjectSpy(convertScopes = true) EventBus bus; @@ -49,7 +52,7 @@ public class RulesTest { public void setup() { rule = new JsonObject(); rule.put("name", RULE_NAME); - rule.put("matchExpression", "my_match_expression"); + rule.put("matchExpression", EXPR_1); rule.put("eventSpecifier", "my_event_specifier"); rule.put("enabled", true); } @@ -96,7 +99,7 @@ public void testList() { "meta.status", is("OK"), "data.result", Matchers.hasSize(1), "data.result[0].name", is(RULE_NAME), - "data.result[0].matchExpression", is("my_match_expression"), + "data.result[0].matchExpression", is(EXPR_1), "data.result[0].eventSpecifier", is("my_event_specifier")); } @@ -178,7 +181,7 @@ public void testCreateThrowsWhenRuleNameExists() { // Try to create again var conflictRule = new JsonObject(); conflictRule.put("name", RULE_NAME); - conflictRule.put("matchExpression", "some_other_match_expression"); + conflictRule.put("matchExpression", EXPR_2); conflictRule.put("eventSpecifier", "some_other_event_specifier"); givenBasicAuth() @@ -198,7 +201,7 @@ public void testCreateThrowsWhenBodyNull() { public void testCreateThrowsWhenMandatoryFieldsUnspecified() { var badRule = new JsonObject(); badRule.put("name", RULE_NAME); - badRule.put("matchExpression", "some_other_match_expression"); + badRule.put("matchExpression", EXPR_2); // MISSING: badRule.put("eventSpecifier", "some_other_event_specifier"); givenBasicAuth() .body(badRule.toString()) diff --git a/src/test/java/itest/MatchExpressionsIT.java b/src/test/java/itest/MatchExpressionsIT.java new file mode 100644 index 000000000..5f936fd62 --- /dev/null +++ b/src/test/java/itest/MatchExpressionsIT.java @@ -0,0 +1,25 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package itest; + +import io.cryostat.expressions.MatchExpressionsTest; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import org.junit.jupiter.api.Disabled; + +@QuarkusIntegrationTest +@Disabled("TODO fix to account for targets being discovered") +public class MatchExpressionsIT extends MatchExpressionsTest {}