Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(matchexpr): refactor MatchExpression as its own resource #39

Merged
merged 28 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8a1e64f
chore(matchexpr): refactor MatchExpression as its own resource
andrewazores Jul 26, 2023
8e6a8af
add comment
andrewazores Jul 26, 2023
b87ec65
fix test
andrewazores Jul 26, 2023
2967316
map exception to status 400
andrewazores Jul 26, 2023
06c075f
better validation exception handling
andrewazores Jul 26, 2023
3f70451
rebase cleanup
andrewazores Jul 27, 2023
e54bcc4
apply spotless
andrewazores Aug 2, 2023
e17b500
add expression test endpoint
andrewazores Aug 2, 2023
df1efad
refactor stored credentials to use matchExpression linked resource
andrewazores Aug 2, 2023
21b4e9d
expose simplified Target object to expression scripts
andrewazores Aug 2, 2023
2adb92c
add basic unit test
andrewazores Aug 2, 2023
841d736
relicense
andrewazores Aug 2, 2023
5c63865
refactor, add endpoints for listing expressions and querying individu…
andrewazores Aug 8, 2023
a8969b9
fill in credentials/expressions linked response fields
andrewazores Aug 8, 2023
2fd578b
fixup! fill in credentials/expressions linked response fields
andrewazores Aug 8, 2023
2b5289c
add TODO
andrewazores Aug 8, 2023
755080d
handle broader ScriptException
andrewazores Aug 8, 2023
4a58035
rename method more accurately
andrewazores Aug 8, 2023
1d3d786
emit events for expression resource lifecycle, evaluator listens for …
andrewazores Aug 8, 2023
b68996e
refactor
andrewazores Aug 9, 2023
8f0265a
test fixup
andrewazores Aug 9, 2023
a731f6f
fixup! test fixup
andrewazores Aug 9, 2023
395c573
run test as itest too
andrewazores Aug 9, 2023
437d5c2
disable test and add TODO
andrewazores Aug 9, 2023
ba56736
Revert "refactor"
andrewazores Aug 9, 2023
5d295fa
handle request including target list
andrewazores Aug 10, 2023
fe50599
null safety
andrewazores Aug 10, 2023
ac7ec23
add FIXME
andrewazores Aug 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
andrewazores marked this conversation as resolved.
Show resolved Hide resolved
.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
Loading