Skip to content

Commit

Permalink
chore(matchexpr): refactor MatchExpression as its own resource (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewazores authored Aug 23, 2023
1 parent 7382091 commit b598524
Show file tree
Hide file tree
Showing 12 changed files with 505 additions and 47 deletions.
8 changes: 7 additions & 1 deletion src/main/java/io/cryostat/ExceptionMappers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,12 +29,17 @@ public RestResponse<Void> mapNoResultException(NoResultException ex) {
}

@ServerExceptionMapper
public RestResponse<Void> mapNoResultException(ConstraintViolationException ex) {
public RestResponse<Void> mapConstraintViolationException(ConstraintViolationException ex) {
return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code());
}

@ServerExceptionMapper
public RestResponse<Void> mapValidationException(jakarta.validation.ValidationException ex) {
return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code());
}

@ServerExceptionMapper
public RestResponse<Void> mapScriptException(ScriptException ex) {
return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code());
}
}
29 changes: 19 additions & 10 deletions src/main/java/io/cryostat/credentials/Credential.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,34 @@
*/
package io.cryostat.credentials;

import io.cryostat.expressions.MatchExpression;
import io.cryostat.ws.MessagingServer;
import io.cryostat.ws.Notification;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
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'))",
Expand All @@ -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)));
}
}
}
56 changes: 48 additions & 8 deletions src/main/java/io/cryostat/credentials/Credentials.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Credential> 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
Expand All @@ -60,8 +82,10 @@ public RestResponse<Void> 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();
Expand All @@ -78,19 +102,35 @@ public void delete(@RestPath long id) {
credential.delete();
}

static Map<String, Object> safeResult(Credential credential) {
static Map<String, Object> notificationResult(Credential credential) throws ScriptException {
Map<String, Object> 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<String, Object> safeMatchedResult(Credential credential) {
@Blocking
static Map<String, Object> safeResult(Credential credential, TargetMatcher matcher)
throws ScriptException {
Map<String, Object> 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<String, Object> safeMatchedResult(Credential credential, TargetMatcher matcher)
throws ScriptException {
Map<String, Object> result = new HashMap<>();
result.put("matchExpression", credential.matchExpression);
result.put("targets", List.of());
result.put("targets", matcher.match(credential.matchExpression).targets());
return result;
}
}
170 changes: 170 additions & 0 deletions src/main/java/io/cryostat/expressions/MatchExpression.java
Original file line number Diff line number Diff line change
@@ -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<Target> targets)
throws ScriptException {
Set<Target> 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<Target> targets) {
public MatchedExpression {
Objects.requireNonNull(expression);
Objects.requireNonNull(targets);
}

MatchedExpression(MatchExpression expr, Collection<Target> 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;
}
}
}
Loading

0 comments on commit b598524

Please sign in to comment.