diff --git a/pom.xml b/pom.xml index 5eaa5acad..e99c9d6b3 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,7 @@ 2 3.2.3 ${surefire.rerunFailingTestsCount} + 2.10.1 @@ -229,6 +230,12 @@ ${com.github.spotbugs.version} provided + + com.google.code.gson + gson + ${com.google.code.gson.version} + test + io.rest-assured rest-assured diff --git a/smoketest/compose/sample-apps.yml b/smoketest/compose/sample-apps.yml index 90fc24e18..4f93679f6 100644 --- a/smoketest/compose/sample-apps.yml +++ b/smoketest/compose/sample-apps.yml @@ -1,6 +1,6 @@ version: "3" services: - sample-app-1: + sample-app: depends_on: cryostat: condition: service_healthy @@ -12,9 +12,9 @@ services: CRYOSTAT_AGENT_APP_NAME: "vertx-fib-demo-1" CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL: "true" CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME: "false" - CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-1" + CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app" CRYOSTAT_AGENT_WEBSERVER_PORT: "8910" - CRYOSTAT_AGENT_CALLBACK: "http://sample-app-1:8910/" + CRYOSTAT_AGENT_CALLBACK: "http://sample-app:8910/" CRYOSTAT_AGENT_BASEURI: "http://cryostat:8181/" CRYOSTAT_AGENT_TRUST_ALL: "true" CRYOSTAT_AGENT_AUTHORIZATION: "Basic dXNlcjpwYXNz" @@ -22,7 +22,7 @@ services: - "8081:8081" labels: io.cryostat.discovery: "true" - io.cryostat.jmxHost: "sample-app-1" + io.cryostat.jmxHost: "sample-app" io.cryostat.jmxPort: "9093" restart: always healthcheck: @@ -31,63 +31,6 @@ services: retries: 3 start_period: 10s timeout: 5s - sample-app-2: - depends_on: - cryostat: - condition: service_healthy - image: quay.io/andrewazores/vertx-fib-demo:0.13.0 - hostname: vertx-fib-demo-2 - environment: - HTTP_PORT: 8082 - JMX_PORT: 9094 - USE_AUTH: "true" - CRYOSTAT_AGENT_APP_NAME: "vertx-fib-demo-2" - CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL: "true" - CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME: "false" - CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-2" - CRYOSTAT_AGENT_WEBSERVER_PORT: "8911" - CRYOSTAT_AGENT_CALLBACK: "http://sample-app-2:8911/" - CRYOSTAT_AGENT_BASEURI: "http://cryostat:8181/" - CRYOSTAT_AGENT_TRUST_ALL: "true" - CRYOSTAT_AGENT_AUTHORIZATION: "Basic dXNlcjpwYXNz" - ports: - - "8082:8082" - restart: always - healthcheck: - test: curl --fail http://localhost:8081 || exit 1 - interval: 10s - retries: 3 - start_period: 10s - timeout: 5s - sample-app-3: - depends_on: - cryostat: - condition: service_healthy - image: quay.io/andrewazores/vertx-fib-demo:0.13.0 - hostname: vertx-fib-demo-3 - environment: - HTTP_PORT: 8083 - JMX_PORT: 9095 - USE_AUTH: "true" - USE_SSL: "true" - CRYOSTAT_AGENT_APP_NAME: "vertx-fib-demo-3" - CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL: "true" - CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME: "false" - CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-3" - CRYOSTAT_AGENT_WEBSERVER_PORT: "8910" - CRYOSTAT_AGENT_CALLBACK: "http://sample-app-3:8912/" - CRYOSTAT_AGENT_BASEURI: "http://cryostat:8181/" - CRYOSTAT_AGENT_TRUST_ALL: "true" - CRYOSTAT_AGENT_AUTHORIZATION: "Basic dXNlcjpwYXNz" - ports: - - "8083:8083" - restart: always - healthcheck: - test: curl --fail http://localhost:8081 || exit 1 - interval: 10s - retries: 3 - start_period: 10s - timeout: 5s quarkus-test-agent: image: quay.io/andrewazores/quarkus-test:latest # do not add a depends_on:cryostat here, so that we can test that the agent is tolerant of that state @@ -97,7 +40,7 @@ services: expose: - "9977" environment: - JAVA_OPTS_APPEND: "-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -javaagent:/deployments/app/cryostat-agent.jar" + JAVA_OPTS: "-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -javaagent:/deployments/app/cryostat-agent.jar" QUARKUS_HTTP_PORT: 10010 ORG_ACME_CRYOSTATSERVICE_ENABLED: "false" CRYOSTAT_AGENT_APP_NAME: quarkus-test-agent diff --git a/smoketest/k8s/sample-app-2-deployment.yaml b/smoketest/k8s/sample-app-2-deployment.yaml deleted file mode 100644 index efc63c48a..000000000 --- a/smoketest/k8s/sample-app-2-deployment.yaml +++ /dev/null @@ -1,64 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - creationTimestamp: null - labels: - io.kompose.service: sample-app-2 - name: sample-app-2 -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: sample-app-2 - strategy: {} - template: - metadata: - creationTimestamp: null - labels: - io.kompose.network/compose-default: "true" - io.kompose.service: sample-app-2 - spec: - containers: - - env: - - name: CRYOSTAT_AGENT_APP_NAME - value: vertx-fib-demo-2 - - name: CRYOSTAT_AGENT_AUTHORIZATION - value: Basic dXNlcjpwYXNz - - name: CRYOSTAT_AGENT_BASEURI - value: http://cryostat:8181/ - - name: CRYOSTAT_AGENT_CALLBACK - value: http://sample-app-2:8911/ - - name: CRYOSTAT_AGENT_TRUST_ALL - value: "true" - - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL - value: "true" - - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME - value: "false" - - name: CRYOSTAT_AGENT_WEBSERVER_HOST - value: sample-app-2 - - name: CRYOSTAT_AGENT_WEBSERVER_PORT - value: "8911" - - name: HTTP_PORT - value: "8082" - - name: JMX_PORT - value: "9094" - - name: USE_AUTH - value: "true" - image: quay.io/andrewazores/vertx-fib-demo:0.13.0 - livenessProbe: - exec: - command: - - curl --fail http://localhost:8081 || exit 1 - failureThreshold: 3 - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - name: sample-app-2 - ports: - - containerPort: 8082 - hostPort: 8082 - protocol: TCP - resources: {} - hostname: vertx-fib-demo-2 - restartPolicy: Always -status: {} diff --git a/smoketest/k8s/sample-app-2-service.yaml b/smoketest/k8s/sample-app-2-service.yaml deleted file mode 100644 index c9f5e71b9..000000000 --- a/smoketest/k8s/sample-app-2-service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - creationTimestamp: null - labels: - io.kompose.service: sample-app-2 - name: sample-app-2 -spec: - ports: - - name: "8082" - port: 8082 - targetPort: 8082 - selector: - io.kompose.service: sample-app-2 -status: - loadBalancer: {} diff --git a/smoketest/k8s/sample-app-3-deployment.yaml b/smoketest/k8s/sample-app-3-deployment.yaml deleted file mode 100644 index c7bceaddd..000000000 --- a/smoketest/k8s/sample-app-3-deployment.yaml +++ /dev/null @@ -1,66 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - creationTimestamp: null - labels: - io.kompose.service: sample-app-3 - name: sample-app-3 -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: sample-app-3 - strategy: {} - template: - metadata: - creationTimestamp: null - labels: - io.kompose.network/compose-default: "true" - io.kompose.service: sample-app-3 - spec: - containers: - - env: - - name: CRYOSTAT_AGENT_APP_NAME - value: vertx-fib-demo-3 - - name: CRYOSTAT_AGENT_AUTHORIZATION - value: Basic dXNlcjpwYXNz - - name: CRYOSTAT_AGENT_BASEURI - value: http://cryostat:8181/ - - name: CRYOSTAT_AGENT_CALLBACK - value: http://sample-app-3:8912/ - - name: CRYOSTAT_AGENT_TRUST_ALL - value: "true" - - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL - value: "true" - - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME - value: "false" - - name: CRYOSTAT_AGENT_WEBSERVER_HOST - value: sample-app-3 - - name: CRYOSTAT_AGENT_WEBSERVER_PORT - value: "8910" - - name: HTTP_PORT - value: "8083" - - name: JMX_PORT - value: "9095" - - name: USE_AUTH - value: "true" - - name: USE_SSL - value: "true" - image: quay.io/andrewazores/vertx-fib-demo:0.13.0 - livenessProbe: - exec: - command: - - curl --fail http://localhost:8081 || exit 1 - failureThreshold: 3 - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - name: sample-app-3 - ports: - - containerPort: 8083 - hostPort: 8083 - protocol: TCP - resources: {} - hostname: vertx-fib-demo-3 - restartPolicy: Always -status: {} diff --git a/smoketest/k8s/sample-app-3-service.yaml b/smoketest/k8s/sample-app-3-service.yaml deleted file mode 100644 index a27eeca2e..000000000 --- a/smoketest/k8s/sample-app-3-service.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - creationTimestamp: null - labels: - io.kompose.service: sample-app-3 - name: sample-app-3 -spec: - ports: - - name: "8083" - port: 8083 - targetPort: 8083 - selector: - io.kompose.service: sample-app-3 -status: - loadBalancer: {} diff --git a/smoketest/k8s/sample-app-1-deployment.yaml b/smoketest/k8s/sample-app-deployment.yaml similarity index 84% rename from smoketest/k8s/sample-app-1-deployment.yaml rename to smoketest/k8s/sample-app-deployment.yaml index 489a46665..53c2baf3c 100644 --- a/smoketest/k8s/sample-app-1-deployment.yaml +++ b/smoketest/k8s/sample-app-deployment.yaml @@ -3,28 +3,28 @@ kind: Deployment metadata: annotations: io.cryostat.discovery: "true" - io.cryostat.jmxHost: sample-app-1 + io.cryostat.jmxHost: sample-app io.cryostat.jmxPort: "9093" creationTimestamp: null labels: - io.kompose.service: sample-app-1 - name: sample-app-1 + io.kompose.service: sample-app + name: sample-app spec: replicas: 1 selector: matchLabels: - io.kompose.service: sample-app-1 + io.kompose.service: sample-app strategy: {} template: metadata: annotations: io.cryostat.discovery: "true" - io.cryostat.jmxHost: sample-app-1 + io.cryostat.jmxHost: sample-app io.cryostat.jmxPort: "9093" creationTimestamp: null labels: io.kompose.network/compose-default: "true" - io.kompose.service: sample-app-1 + io.kompose.service: sample-app spec: containers: - env: @@ -35,7 +35,7 @@ spec: - name: CRYOSTAT_AGENT_BASEURI value: http://cryostat:8181/ - name: CRYOSTAT_AGENT_CALLBACK - value: http://sample-app-1:8910/ + value: http://sample-app:8910/ - name: CRYOSTAT_AGENT_TRUST_ALL value: "true" - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL @@ -43,7 +43,7 @@ spec: - name: CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME value: "false" - name: CRYOSTAT_AGENT_WEBSERVER_HOST - value: sample-app-1 + value: sample-app - name: CRYOSTAT_AGENT_WEBSERVER_PORT value: "8910" - name: HTTP_PORT @@ -59,7 +59,7 @@ spec: initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 - name: sample-app-1 + name: sample-app ports: - containerPort: 8081 hostPort: 8081 diff --git a/smoketest/k8s/sample-app-1-service.yaml b/smoketest/k8s/sample-app-service.yaml similarity index 66% rename from smoketest/k8s/sample-app-1-service.yaml rename to smoketest/k8s/sample-app-service.yaml index f9c56883d..323741f45 100644 --- a/smoketest/k8s/sample-app-1-service.yaml +++ b/smoketest/k8s/sample-app-service.yaml @@ -3,18 +3,18 @@ kind: Service metadata: annotations: io.cryostat.discovery: "true" - io.cryostat.jmxHost: sample-app-1 + io.cryostat.jmxHost: sample-app io.cryostat.jmxPort: "9093" creationTimestamp: null labels: - io.kompose.service: sample-app-1 - name: sample-app-1 + io.kompose.service: sample-app + name: sample-app spec: ports: - name: "8081" port: 8081 targetPort: 8081 selector: - io.kompose.service: sample-app-1 + io.kompose.service: sample-app status: loadBalancer: {} diff --git a/src/main/java/io/cryostat/ExceptionMappers.java b/src/main/java/io/cryostat/ExceptionMappers.java index f97c58b2b..f03dc89fd 100644 --- a/src/main/java/io/cryostat/ExceptionMappers.java +++ b/src/main/java/io/cryostat/ExceptionMappers.java @@ -15,10 +15,6 @@ */ package io.cryostat; -import org.openjdk.jmc.rjmx.ConnectionException; - -import io.cryostat.targets.TargetConnectionManager; - import io.netty.handler.codec.http.HttpResponseStatus; import jakarta.persistence.NoResultException; import org.hibernate.exception.ConstraintViolationException; @@ -57,18 +53,4 @@ public RestResponse mapNoSuchKeyException(NoSuchKeyException ex) { public RestResponse mapIllegalArgumentException(IllegalArgumentException exception) { return RestResponse.status(HttpResponseStatus.BAD_REQUEST.code()); } - - @ServerExceptionMapper - public RestResponse mapJmxConnectionException(ConnectionException exception) { - return RestResponse.status(HttpResponseStatus.BAD_GATEWAY.code()); - } - - @ServerExceptionMapper - public RestResponse mapFlightRecorderException( - org.openjdk.jmc.rjmx.services.jfr.FlightRecorderException exception) { - if (TargetConnectionManager.isJmxAuthFailure(exception)) { - return RestResponse.status(HttpResponseStatus.FORBIDDEN.code()); - } - return RestResponse.status(HttpResponseStatus.BAD_GATEWAY.code()); - } } diff --git a/src/main/java/io/cryostat/V2Response.java b/src/main/java/io/cryostat/V2Response.java index 6f0ccfd4f..f5db1c711 100644 --- a/src/main/java/io/cryostat/V2Response.java +++ b/src/main/java/io/cryostat/V2Response.java @@ -18,18 +18,10 @@ import java.util.Objects; import jakarta.annotation.Nullable; -import jakarta.ws.rs.core.Response; public record V2Response(Meta meta, Data data) { - public static V2Response json(Response.Status status, Object payload) { - Data data; - if (status.getFamily().equals(Response.Status.Family.CLIENT_ERROR) - || status.getFamily().equals(Response.Status.Family.SERVER_ERROR)) { - data = new ErrorData(payload); - } else { - data = new PayloadData(payload); - } - return new V2Response(new Meta("application/json", status.getReasonPhrase()), data); + public static V2Response json(Object payload, String status) { + return new V2Response(new Meta("application/json", status), new Data(payload)); } // FIXME the type and status should both come from an enum and be non-null @@ -40,33 +32,5 @@ public record Meta(String type, String status) { } } - interface Data {} - - public static class PayloadData implements Data { - @Nullable Object result; - - public PayloadData(Object payload) { - this.result = payload; - } - - public Object getResult() { - return result; - } - } - - public static class ErrorData implements Data { - String reason; - - public ErrorData(Object payload) { - if (payload instanceof Exception) { - this.reason = ((Exception) Objects.requireNonNull(payload)).getMessage(); - } else { - this.reason = Objects.requireNonNull(payload).toString(); - } - } - - public String getReason() { - return reason; - } - } + public record Data(@Nullable Object result) {} } diff --git a/src/main/java/io/cryostat/credentials/Credential.java b/src/main/java/io/cryostat/credentials/Credential.java index af0d4f9a7..00478f806 100644 --- a/src/main/java/io/cryostat/credentials/Credential.java +++ b/src/main/java/io/cryostat/credentials/Credential.java @@ -40,10 +40,6 @@ @EntityListeners(Credential.Listener.class) public class Credential extends PanacheEntity { - public static final String CREDENTIALS_STORED = "CredentialsStored"; - public static final String CREDENTIALS_DELETED = "CredentialsDeleted"; - public static final String CREDENTIALS_UPDATED = "CredentialsUpdated"; - @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL) @JoinColumn(name = "matchExpression") public MatchExpression matchExpression; @@ -62,6 +58,7 @@ public class Credential extends PanacheEntity { @ApplicationScoped static class Listener { + @Inject EventBus bus; @Inject MatchExpression.TargetMatcher targetMatcher; @@ -70,8 +67,7 @@ public void postPersist(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), new Notification( - CREDENTIALS_STORED, Credentials.notificationResult(credential))); - bus.publish(CREDENTIALS_STORED, credential); + "CredentialsStored", Credentials.notificationResult(credential))); } @PostUpdate @@ -79,8 +75,7 @@ public void postUpdate(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), new Notification( - CREDENTIALS_UPDATED, Credentials.notificationResult(credential))); - bus.publish(CREDENTIALS_UPDATED, credential); + "CredentialsUpdated", Credentials.notificationResult(credential))); } @PostRemove @@ -88,8 +83,7 @@ public void postRemove(Credential credential) throws ScriptException { bus.publish( MessagingServer.class.getName(), new Notification( - CREDENTIALS_DELETED, Credentials.notificationResult(credential))); - bus.publish(CREDENTIALS_DELETED, credential); + "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 2feffcf45..0234ca5a6 100644 --- a/src/main/java/io/cryostat/credentials/Credentials.java +++ b/src/main/java/io/cryostat/credentials/Credentials.java @@ -33,12 +33,12 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Response; 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") @@ -52,7 +52,6 @@ public class Credentials { public V2Response list() { List credentials = Credential.listAll(); return V2Response.json( - Response.Status.OK, credentials.stream() .map( c -> { @@ -64,7 +63,8 @@ public V2Response list() { } }) .filter(Objects::nonNull) - .toList()); + .toList(), + Status.OK.toString()); } @GET @@ -72,7 +72,7 @@ public V2Response list() { @Path("/{id}") public V2Response get(@RestPath long id) throws ScriptException { Credential credential = Credential.find("id", id).singleResult(); - return V2Response.json(Response.Status.OK, safeMatchedResult(credential, targetMatcher)); + return V2Response.json(safeMatchedResult(credential, targetMatcher), Status.OK.toString()); } @Transactional diff --git a/src/main/java/io/cryostat/discovery/CustomDiscovery.java b/src/main/java/io/cryostat/discovery/CustomDiscovery.java index 12cb7bc4a..59f51b86f 100644 --- a/src/main/java/io/cryostat/discovery/CustomDiscovery.java +++ b/src/main/java/io/cryostat/discovery/CustomDiscovery.java @@ -20,20 +20,15 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Map; -import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; -import io.cryostat.V2Response; -import io.cryostat.credentials.Credential; -import io.cryostat.expressions.MatchExpression; import io.cryostat.targets.JvmIdException; import io.cryostat.targets.Target; import io.cryostat.targets.Target.Annotations; import io.cryostat.targets.TargetConnectionManager; import io.quarkus.runtime.StartupEvent; -import io.smallrye.common.annotation.Blocking; import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; @@ -44,9 +39,7 @@ import jakarta.ws.rs.DELETE; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.hibernate.exception.ConstraintViolationException; import org.jboss.logging.Logger; @@ -84,73 +77,29 @@ void onStart(@Observes StartupEvent evt) { @Transactional(rollbackOn = {JvmIdException.class}) @POST @Path("/api/v2/targets") - @Consumes(MediaType.APPLICATION_JSON) + @Consumes("application/json") @RolesAllowed("write") - public Response create( - Target target, @RestQuery boolean dryrun, @RestQuery boolean storeCredentials) { - // TODO handle credentials embedded in JSON body - return doV2Create(target, Optional.empty(), dryrun, storeCredentials); - } - - @Transactional - @POST - @Path("/api/v2/targets") - @Consumes({MediaType.MULTIPART_FORM_DATA, MediaType.APPLICATION_FORM_URLENCODED}) - @RolesAllowed("write") - public Response create( - @RestForm URI connectUrl, - @RestForm String alias, - @RestForm String username, - @RestForm String password, - @RestQuery boolean dryrun, - @RestQuery boolean storeCredentials) { - var target = new Target(); - target.connectUrl = connectUrl; - target.alias = alias; - - Credential credential = null; - if (StringUtils.isNotBlank(username) && StringUtils.isNotBlank(password)) { - credential = new Credential(); - credential.matchExpression = - new MatchExpression( - String.format("target.connectUrl == \"%s\"", connectUrl.toString())); - credential.username = username; - credential.password = password; - } - - return doV2Create(target, Optional.ofNullable(credential), dryrun, storeCredentials); - } - - @Transactional - @Blocking - Response doV2Create( - Target target, - Optional credential, - boolean dryrun, - boolean storeCredentials) { + public Response create(Target target, @RestQuery boolean dryrun) { try { target.connectUrl = sanitizeConnectUrl(target.connectUrl.toString()); try { - target.jvmId = - connectionManager.executeDirect( - target, credential, conn -> conn.getJvmId()); + if (target.isAgent()) { + // TODO test connection + target.jvmId = target.connectUrl.toString(); + } else { + target.jvmId = + connectionManager.executeConnectedTask(target, conn -> conn.getJvmId()); + } } catch (Exception e) { logger.error("Target connection failed", e); - return Response.status(Response.Status.BAD_REQUEST.getStatusCode()) - .entity(V2Response.json(Response.Status.BAD_REQUEST, e)) - .build(); + return Response.status(400).build(); } if (dryrun) { - return Response.accepted() - .entity(V2Response.json(Response.Status.ACCEPTED, target)) - .build(); + return Response.ok().build(); } - target.persist(); - credential.ifPresent(c -> c.persist()); - target.activeRecordings = new ArrayList<>(); target.labels = Map.of(); target.annotations = new Annotations(); @@ -165,23 +114,31 @@ Response doV2Create( node.persist(); realm.persist(); - return Response.created(URI.create("/api/v3/targets/" + target.id)) - .entity(V2Response.json(Response.Status.CREATED, target)) - .build(); + return Response.created(URI.create("/api/v3/targets/" + target.id)).build(); } catch (Exception e) { if (ExceptionUtils.indexOfType(e, ConstraintViolationException.class) >= 0) { logger.warn("Invalid target definition", e); - return Response.status(Response.Status.BAD_REQUEST.getStatusCode()) - .entity(V2Response.json(Response.Status.BAD_REQUEST, e)) - .build(); + return Response.status(400).build(); } logger.error("Unknown error", e); - return Response.serverError() - .entity(V2Response.json(Response.Status.INTERNAL_SERVER_ERROR, e)) - .build(); + return Response.serverError().build(); } } + @Transactional + @POST + @Path("/api/v2/targets") + @Consumes("multipart/form-data") + @RolesAllowed("write") + public Response create( + @RestForm URI connectUrl, @RestForm String alias, @RestQuery boolean dryrun) { + var target = new Target(); + target.connectUrl = connectUrl; + target.alias = alias; + + return create(target, dryrun); + } + @Transactional @DELETE @Path("/api/v2/targets/{connectUrl}") @@ -201,9 +158,9 @@ public Response delete(@RestPath long id) throws URISyntaxException { Target target = Target.find("id", id).singleResult(); DiscoveryNode realm = DiscoveryNode.getRealm(REALM).orElseThrow(); realm.children.remove(target.discoveryNode); - realm.persist(); target.delete(); - return Response.noContent().build(); + realm.persist(); + return Response.ok().build(); } private URI sanitizeConnectUrl(String in) throws URISyntaxException, MalformedURLException { diff --git a/src/main/java/io/cryostat/discovery/Discovery.java b/src/main/java/io/cryostat/discovery/Discovery.java index 9de683c48..e6d6b1ddd 100644 --- a/src/main/java/io/cryostat/discovery/Discovery.java +++ b/src/main/java/io/cryostat/discovery/Discovery.java @@ -110,7 +110,7 @@ public RestResponse checkRegistration(@RestPath UUID id, @RestQuery String @Transactional @POST @Path("/api/v2.2/discovery") - @Consumes(MediaType.APPLICATION_JSON) + @Consumes("application/json") @RolesAllowed("write") public Map register(JsonObject body) throws URISyntaxException { String id = body.getString("id"); @@ -147,7 +147,7 @@ public Map register(JsonObject body) throws URISyntaxException { @Transactional @POST @Path("/api/v2.2/discovery/{id}") - @Consumes(MediaType.APPLICATION_JSON) + @Consumes("application/json") @PermitAll public Map> publish( @RestPath UUID id, @RestQuery String token, List body) { diff --git a/src/main/java/io/cryostat/events/Events.java b/src/main/java/io/cryostat/events/Events.java index 8ed0df82f..e524bf551 100644 --- a/src/main/java/io/cryostat/events/Events.java +++ b/src/main/java/io/cryostat/events/Events.java @@ -37,6 +37,7 @@ import org.jboss.resteasy.reactive.RestPath; import org.jboss.resteasy.reactive.RestQuery; import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; @Path("") public class Events { @@ -64,7 +65,7 @@ public Response listEventsV1(@RestPath URI connectUrl, @RestQuery String q) thro @RolesAllowed("read") public V2Response listEventsV2(@RestPath URI connectUrl, @RestQuery String q) throws Exception { return V2Response.json( - Response.Status.OK, searchEvents(Target.getTargetByConnectUrl(connectUrl), q)); + searchEvents(Target.getTargetByConnectUrl(connectUrl), q), Status.OK.toString()); } @GET diff --git a/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java b/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java index e31c21055..e25a5350c 100644 --- a/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java +++ b/src/main/java/io/cryostat/expressions/MatchExpressionEvaluator.java @@ -37,7 +37,6 @@ import io.quarkus.cache.CompositeCacheKey; import io.quarkus.vertx.ConsumeEvent; import io.smallrye.common.annotation.Blocking; -import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jdk.jfr.Category; @@ -199,12 +198,13 @@ public static class ScriptCreationEvent extends Event {} private static record SimplifiedTarget( String connectUrl, String alias, - @Nullable String jvmId, + String jvmId, Map labels, Target.Annotations annotations) { SimplifiedTarget { Objects.requireNonNull(connectUrl); Objects.requireNonNull(alias); + Objects.requireNonNull(jvmId); if (labels == null) { labels = Collections.emptyMap(); } diff --git a/src/main/java/io/cryostat/expressions/MatchExpressions.java b/src/main/java/io/cryostat/expressions/MatchExpressions.java index 256413761..80ce6bb09 100644 --- a/src/main/java/io/cryostat/expressions/MatchExpressions.java +++ b/src/main/java/io/cryostat/expressions/MatchExpressions.java @@ -32,7 +32,7 @@ import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestPath; import org.projectnessie.cel.tools.ScriptException; @@ -53,7 +53,7 @@ public V2Response test(RequestData requestData) throws ScriptException { var matched = targetMatcher.match( new MatchExpression(requestData.matchExpression), requestData.targets); - return V2Response.json(Response.Status.OK, matched); + return V2Response.json(matched, Status.OK.toString()); } @GET diff --git a/src/main/java/io/cryostat/recordings/Recordings.java b/src/main/java/io/cryostat/recordings/Recordings.java index d76335fb4..ae45f6e9a 100644 --- a/src/main/java/io/cryostat/recordings/Recordings.java +++ b/src/main/java/io/cryostat/recordings/Recordings.java @@ -536,12 +536,12 @@ public Response createSnapshotV2(@RestPath URI connectUrl) throws Exception { return Response.status(Response.Status.CREATED) .entity( V2Response.json( - Response.Status.CREATED, - recordingHelper.toExternalForm(recording))) + recordingHelper.toExternalForm(recording), + RestResponse.Status.CREATED.toString())) .build(); } catch (SnapshotCreationException sce) { return Response.status(Response.Status.ACCEPTED) - .entity(V2Response.json(Response.Status.ACCEPTED, null)) + .entity(V2Response.json(null, RestResponse.Status.ACCEPTED.toString())) .build(); } } diff --git a/src/main/java/io/cryostat/rules/Rules.java b/src/main/java/io/cryostat/rules/Rules.java index 754ce4cc7..70f7e0e39 100644 --- a/src/main/java/io/cryostat/rules/Rules.java +++ b/src/main/java/io/cryostat/rules/Rules.java @@ -39,6 +39,7 @@ import org.jboss.resteasy.reactive.RestQuery; import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.RestResponse.ResponseBuilder; +import org.jboss.resteasy.reactive.RestResponse.Status; @Path("/api/v2/rules") public class Rules { @@ -48,20 +49,20 @@ public class Rules { @GET @RolesAllowed("read") public RestResponse list() { - return RestResponse.ok(V2Response.json(Response.Status.OK, Rule.listAll())); + return RestResponse.ok(V2Response.json(Rule.listAll(), Status.OK.getReasonPhrase())); } @GET @RolesAllowed("read") @Path("/{name}") public RestResponse get(@RestPath String name) { - return RestResponse.ok(V2Response.json(Response.Status.OK, Rule.getByName(name))); + return RestResponse.ok(V2Response.json(Rule.getByName(name), Status.OK.getReasonPhrase())); } @Transactional @POST @RolesAllowed("write") - @Consumes(MediaType.APPLICATION_JSON) + @Consumes({MediaType.APPLICATION_JSON}) public RestResponse create(Rule rule) { // TODO validate the incoming rule if (rule == null) { @@ -74,7 +75,7 @@ public RestResponse create(Rule rule) { rule.persist(); return ResponseBuilder.create( Response.Status.CREATED, - V2Response.json(Response.Status.CREATED, rule.name)) + V2Response.json(rule.name, Status.CREATED.toString())) .build(); } @@ -82,7 +83,7 @@ public RestResponse create(Rule rule) { @PATCH @RolesAllowed("write") @Path("/{name}") - @Consumes(MediaType.APPLICATION_JSON) + @Consumes("application/json") public RestResponse update( @RestPath String name, @RestQuery boolean clean, JsonObject body) { Rule rule = Rule.getByName(name); @@ -94,7 +95,7 @@ public RestResponse update( rule.enabled = enabled; rule.persist(); - return ResponseBuilder.ok(V2Response.json(Response.Status.OK, rule)).build(); + return ResponseBuilder.ok(V2Response.json(rule, Status.OK.toString())).build(); } @Transactional @@ -141,7 +142,7 @@ public RestResponse delete(@RestPath String name, @RestQuery boolean bus.send(Rule.RULE_ADDRESS + "?clean", rule); } rule.delete(); - return RestResponse.ok(V2Response.json(Response.Status.OK, null)); + return RestResponse.ok(V2Response.json(null, Status.OK.toString())); } static class RuleExistsException extends ClientErrorException { diff --git a/src/main/java/io/cryostat/security/Auth.java b/src/main/java/io/cryostat/security/Auth.java index e7553937a..8791dbee0 100644 --- a/src/main/java/io/cryostat/security/Auth.java +++ b/src/main/java/io/cryostat/security/Auth.java @@ -27,7 +27,6 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; @@ -39,7 +38,7 @@ public class Auth { @POST @Path("/api/v2.1/logout") @PermitAll - @Produces(MediaType.APPLICATION_JSON) + @Produces("application/json") public Response logout(@Context RoutingContext context) { HttpAuthenticator authenticator = context.get(HttpAuthenticator.class.getName()); return authenticator @@ -63,7 +62,7 @@ public Response logout(@Context RoutingContext context) { @POST @Path("/api/v2.1/auth") @PermitAll - @Produces(MediaType.APPLICATION_JSON) + @Produces("application/json") public Response login(@Context RoutingContext context) { HttpAuthenticator authenticator = context.get(HttpAuthenticator.class.getName()); return authenticator diff --git a/src/main/java/io/cryostat/targets/Target.java b/src/main/java/io/cryostat/targets/Target.java index bd8cf7f1d..50dad8780 100644 --- a/src/main/java/io/cryostat/targets/Target.java +++ b/src/main/java/io/cryostat/targets/Target.java @@ -233,9 +233,11 @@ void prePersist(Target target) throws JvmIdException { connectionManager.executeConnectedTask(target, conn -> conn.getJvmId()); } catch (Exception e) { // TODO tolerate this in the condition that the connection failed because of JMX - // auth. In that instance then persist the entity with a null jvmId, but listen for - // new Credentials and test them against any targets with null jvmIds to see if we - // can populate them. + // auth. + // In that instance then persist the entity with a null jvmId, but listen for new + // Credentials + // and test them against any targets with null jvmIds to see if we can populate + // them. throw new JvmIdException(e); } } diff --git a/src/main/java/io/cryostat/targets/TargetConnectionManager.java b/src/main/java/io/cryostat/targets/TargetConnectionManager.java index 09e1b97e4..6185bc74c 100644 --- a/src/main/java/io/cryostat/targets/TargetConnectionManager.java +++ b/src/main/java/io/cryostat/targets/TargetConnectionManager.java @@ -15,9 +15,7 @@ */ package io.cryostat.targets; -import java.net.SocketTimeoutException; import java.net.URI; -import java.rmi.ConnectIOException; import java.time.Duration; import java.util.Collections; import java.util.Map; @@ -31,16 +29,9 @@ import java.util.concurrent.Semaphore; import javax.management.remote.JMXServiceURL; -import javax.naming.ServiceUnavailableException; -import javax.security.sasl.SaslException; - -import org.openjdk.jmc.rjmx.ConnectionException; -import org.openjdk.jmc.rjmx.services.jfr.FlightRecorderException; import io.cryostat.core.net.JFRConnection; import io.cryostat.core.net.JFRConnectionToolkit; -import io.cryostat.credentials.Credential; -import io.cryostat.expressions.MatchExpressionEvaluator; import io.cryostat.targets.Target.EventKind; import io.cryostat.targets.Target.TargetDiscovery; @@ -54,21 +45,17 @@ import io.smallrye.mutiny.Uni; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; import jdk.jfr.Category; import jdk.jfr.Event; import jdk.jfr.FlightRecorder; import jdk.jfr.Label; import jdk.jfr.Name; -import org.apache.commons.lang3.exception.ExceptionUtils; import org.jboss.logging.Logger; -import org.projectnessie.cel.tools.ScriptException; @ApplicationScoped public class TargetConnectionManager { private final JFRConnectionToolkit jfrConnectionToolkit; - private final MatchExpressionEvaluator matchExpressionEvaluator; private final AgentConnectionFactory agentConnectionFactory; private final Executor executor; private final Logger logger; @@ -80,14 +67,12 @@ public class TargetConnectionManager { @Inject TargetConnectionManager( JFRConnectionToolkit jfrConnectionToolkit, - MatchExpressionEvaluator matchExpressionEvaluator, AgentConnectionFactory agentConnectionFactory, Executor executor, Logger logger) { FlightRecorder.register(TargetConnectionOpened.class); FlightRecorder.register(TargetConnectionClosed.class); this.jfrConnectionToolkit = jfrConnectionToolkit; - this.matchExpressionEvaluator = matchExpressionEvaluator; this.agentConnectionFactory = agentConnectionFactory; this.executor = executor; @@ -131,42 +116,6 @@ void onMessage(TargetDiscovery event) { } } - @Blocking - @ConsumeEvent(Credential.CREDENTIALS_STORED) - void onCredentialsStored(Credential credential) { - handleCredentialChange(credential); - } - - @Blocking - @ConsumeEvent(Credential.CREDENTIALS_UPDATED) - void onCredentialsUpdated(Credential credential) { - handleCredentialChange(credential); - } - - @Blocking - @ConsumeEvent(Credential.CREDENTIALS_DELETED) - void onCredentialsDeleted(Credential credential) { - handleCredentialChange(credential); - } - - @Blocking - void handleCredentialChange(Credential credential) { - for (var entry : connections.asMap().entrySet()) { - URI key = entry.getKey(); - var target = Target.find("connectUrl", key).firstResultOptional(); - if (target.isEmpty()) { - continue; - } - try { - if (matchExpressionEvaluator.applies(credential.matchExpression, target.get())) { - connections.synchronous().invalidate(key); - } - } catch (ScriptException se) { - logger.warn(se); - } - } - } - public Uni executeConnectedTaskAsync(Target target, ConnectedTask task) { return Uni.createFrom() .completionStage( @@ -196,15 +145,6 @@ public T executeConnectedTask(Target target, ConnectedTask task) throws E } } - @Blocking - public T executeDirect( - Target target, Optional credentials, ConnectedTask task) - throws Exception { - try (var conn = connect(target.connectUrl, credentials)) { - return task.execute(conn); - } - } - /** * Mark a connection as still in use by the consumer. Connections expire from cache and are * automatically closed after {@link NetworkModule.TARGET_CACHE_TTL}. For long-running @@ -258,33 +198,7 @@ private void closeConnection(URI connectUrl, JFRConnection connection, RemovalCa } } - @Transactional - JFRConnection connect(URI connectUrl) throws Exception { - var credentials = - Target.find("connectUrl", connectUrl) - .firstResultOptional() - .map( - t -> - Credential.listAll().stream() - .filter( - c -> { - try { - return matchExpressionEvaluator - .applies( - c.matchExpression, - t); - } catch (ScriptException e) { - logger.error(e); - return false; - } - }) - .findFirst() - .orElse(null)); - return connect(connectUrl, credentials); - } - - @Blocking - JFRConnection connect(URI connectUrl, Optional credentials) throws Exception { + private JFRConnection connect(URI connectUrl) throws Exception { TargetConnectionOpened evt = new TargetConnectionOpened(connectUrl.toString()); evt.begin(); try { @@ -295,14 +209,13 @@ JFRConnection connect(URI connectUrl, Optional credentials) throws E if (Set.of("http", "https", "cryostat-agent").contains(connectUrl.getScheme())) { return agentConnectionFactory.createConnection(connectUrl); } - return jfrConnectionToolkit.connect( new JMXServiceURL(connectUrl.toString()), - credentials - .map(c -> new io.cryostat.core.net.Credentials(c.username, c.password)) - .orElse(null), + null /* TODO get from credentials storage */, Collections.singletonList( - () -> connections.synchronous().invalidate(connectUrl))); + () -> { + this.connections.synchronous().invalidate(connectUrl); + })); } catch (Exception e) { evt.setExceptionThrown(true); if (semaphore.isPresent()) { @@ -351,33 +264,6 @@ public interface ConnectedTask { T execute(JFRConnection connection) throws Exception; } - public static boolean isTargetConnectionFailure(Exception e) { - return ExceptionUtils.indexOfType(e, ConnectionException.class) >= 0 - || ExceptionUtils.indexOfType(e, FlightRecorderException.class) >= 0; - } - - public static boolean isJmxAuthFailure(Exception e) { - return ExceptionUtils.indexOfType(e, SecurityException.class) >= 0 - || ExceptionUtils.indexOfType(e, SaslException.class) >= 0; - } - - public static boolean isJmxSslFailure(Exception e) { - return ExceptionUtils.indexOfType(e, ConnectIOException.class) >= 0 - && !isServiceTypeFailure(e); - } - - /** Check if the exception happened because the port connected to a non-JMX service. */ - public static boolean isServiceTypeFailure(Exception e) { - return ExceptionUtils.indexOfType(e, ConnectIOException.class) >= 0 - && ExceptionUtils.indexOfType(e, SocketTimeoutException.class) >= 0; - } - - public static boolean isUnknownTargetFailure(Exception e) { - return ExceptionUtils.indexOfType(e, java.net.UnknownHostException.class) >= 0 - || ExceptionUtils.indexOfType(e, java.rmi.UnknownHostException.class) >= 0 - || ExceptionUtils.indexOfType(e, ServiceUnavailableException.class) >= 0; - } - @Name("io.cryostat.net.TargetConnectionManager.TargetConnectionOpened") @Label("Target Connection Opened") @Category("Cryostat") diff --git a/src/main/java/io/cryostat/util/HttpStatusCodeIdentifier.java b/src/main/java/io/cryostat/util/HttpStatusCodeIdentifier.java index 7637340fd..7efc50af2 100644 --- a/src/main/java/io/cryostat/util/HttpStatusCodeIdentifier.java +++ b/src/main/java/io/cryostat/util/HttpStatusCodeIdentifier.java @@ -15,29 +15,27 @@ */ package io.cryostat.util; -import jakarta.ws.rs.core.Response; - public final class HttpStatusCodeIdentifier { private HttpStatusCodeIdentifier() {} public static boolean isInformationCode(int code) { - return Response.Status.Family.familyOf(code).equals(Response.Status.Family.INFORMATIONAL); + return 100 <= code && code < 200; } public static boolean isSuccessCode(int code) { - return Response.Status.Family.familyOf(code).equals(Response.Status.Family.SUCCESSFUL); + return 200 <= code && code < 300; } public static boolean isRedirectCode(int code) { - return Response.Status.Family.familyOf(code).equals(Response.Status.Family.REDIRECTION); + return 300 <= code && code < 400; } public static boolean isClientErrorCode(int code) { - return Response.Status.Family.familyOf(code).equals(Response.Status.Family.CLIENT_ERROR); + return 400 <= code && code < 500; } public static boolean isServerErrorCode(int code) { - return Response.Status.Family.familyOf(code).equals(Response.Status.Family.SERVER_ERROR); + return 500 <= code && code < 600; } } diff --git a/src/main/java/io/cryostat/ws/MessagingServer.java b/src/main/java/io/cryostat/ws/MessagingServer.java index 8ad65bdfa..f9e37ba34 100644 --- a/src/main/java/io/cryostat/ws/MessagingServer.java +++ b/src/main/java/io/cryostat/ws/MessagingServer.java @@ -16,23 +16,15 @@ package io.cryostat.ws; import java.io.IOException; -import java.nio.channels.ClosedChannelException; import java.util.Map; import java.util.Set; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CopyOnWriteArraySet; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.ConcurrentHashMap; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import io.quarkus.runtime.ShutdownEvent; -import io.quarkus.runtime.StartupEvent; import io.quarkus.vertx.ConsumeEvent; import io.smallrye.common.annotation.Blocking; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.websocket.OnClose; import jakarta.websocket.OnError; @@ -40,8 +32,6 @@ import jakarta.websocket.OnOpen; import jakarta.websocket.Session; import jakarta.websocket.server.ServerEndpoint; -import org.apache.commons.lang3.exception.ExceptionUtils; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; @ApplicationScoped @@ -49,15 +39,9 @@ public class MessagingServer { @Inject Logger logger; - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - private final BlockingQueue msgQ; - private final Set sessions = new CopyOnWriteArraySet<>(); + private final Set sessions = ConcurrentHashMap.newKeySet(); private final ObjectMapper mapper = new ObjectMapper(); - MessagingServer(@ConfigProperty(name = "cryostat.messaging.queue.size") int capacity) { - this.msgQ = new ArrayBlockingQueue<>(capacity); - } - // TODO implement authentication check @OnOpen public void onOpen(Session session) { @@ -78,70 +62,42 @@ public void onError(Session session, Throwable throwable) { logger.errorv("Closing session {0}", session.getId()); session.close(); } catch (IOException ioe) { + ioe.printStackTrace(System.err); logger.error("Unable to close session", ioe); } } - void start(@Observes StartupEvent evt) { - logger.infov("Starting {0} executor", getClass().getName()); - executor.execute( - () -> { - while (!executor.isShutdown()) { - try { - var notification = msgQ.take(); - var map = - Map.of( - "meta", - Map.of("category", notification.category()), - "message", - notification.message()); - logger.infov("Broadcasting: {0}", map); - sessions.forEach( - s -> { - try { - s.getBasicRemote() - .sendText(mapper.writeValueAsString(map)); - } catch (JsonProcessingException e) { - logger.error("Unable to serialize message to JSON", e); - } catch (IOException e) { - // ignored simple ClosedChannelExceptions since this - // just means the connection has already been closed, - // either due to an error or the client closing it. This - // does not actually indicate a problem - if (!ExceptionUtils.getThrowableList(e).stream() - .anyMatch( - t -> - t - instanceof - ClosedChannelException)) { - logger.errorv( - "Unable to send message to {0}", s.getId()); - logger.error(e); - } - } - }); - } catch (InterruptedException ie) { - logger.warn(ie); - break; - } - } - }); - } - - void shutdown(@Observes ShutdownEvent evt) { - logger.infov("Shutting down {0} executor", getClass().getName()); - executor.shutdown(); - msgQ.clear(); - } - @OnMessage public void onMessage(Session session, String message) { - logger.infov("{0} message: \"{1}\"", session.getId(), message); + logger.infov("[{0}] message: {1}", session.getId(), message); } @ConsumeEvent @Blocking void broadcast(Notification notification) { - msgQ.add(notification); + var map = + Map.of( + "meta", + Map.of("category", notification.category()), + "message", + notification.message()); + logger.infov("Broadcasting: {0}", map); + sessions.forEach( + s -> { + try { + s.getAsyncRemote() + .sendObject( + mapper.writeValueAsString(map), + result -> { + if (result.getException() != null) { + logger.warn( + "Unable to send message: " + + result.getException()); + } + }); + } catch (JsonProcessingException e) { + logger.error("Unable to send message", e); + } + }); } } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 08e564fbe..f814fc441 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -19,7 +19,7 @@ cryostat.discovery.podman.enabled=true cryostat.discovery.docker.enabled=true quarkus.datasource.devservices.enabled=true -quarkus.datasource.devservices.image-name=quay.io/cryostat/cryostat-db +quarkus.datasource.devservices.image-name=quay.io/andrewazores/cryostat-db:bisect-2 # !!! prod databases must set this configuration parameter some other way via a secret !!! quarkus.datasource.devservices.container-env.PG_ENCRYPT_KEY=examplekey diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 3af3648d8..f15a4474e 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -5,10 +5,10 @@ cryostat.discovery.podman.enabled=true cryostat.discovery.docker.enabled=true cryostat.auth.disabled=true -quarkus.test.env.JAVA_OPTS_APPEND=-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9091 -Dcom.sun.management.jmxremote.rmi.port=9091 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.local.only=false +quarkus.test.env.JAVA_OPTS_APPEND=-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -Dcom.sun.management.jmxremote.autodiscovery=true -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9091 -Dcom.sun.management.jmxremote.rmi.port=9091 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.local.only=false quarkus.datasource.devservices.enabled=true -quarkus.datasource.devservices.image-name=quay.io/cryostat/cryostat-db +quarkus.datasource.devservices.image-name=quay.io/andrewazores/cryostat-db:bisect-2 # !!! prod databases must set this configuration parameter some other way via a secret !!! quarkus.datasource.devservices.container-env.PG_ENCRYPT_KEY=examplekey diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 839db9919..6a7fc93ee 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,8 +4,6 @@ cryostat.discovery.podman.enabled=false cryostat.discovery.docker.enabled=false quarkus.test.integration-test-profile=test -cryostat.messaging.queue.size=1024 - quarkus.http.auth.proactive=false quarkus.http.host=0.0.0.0 quarkus.http.port=8181 diff --git a/src/test/java/itest/CustomTargetsIT.java b/src/test/java/itest/CustomTargetsIT.java new file mode 100644 index 000000000..ac0096959 --- /dev/null +++ b/src/test/java/itest/CustomTargetsIT.java @@ -0,0 +1,406 @@ +/* + * 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 java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.cryostat.util.HttpMimeType; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.vertx.core.MultiMap; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import itest.bases.StandardSelfTest; +import itest.util.ITestCleanupFailedException; +import itest.util.http.JvmIdWebRequest; +import itest.util.http.StoredCredential; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@QuarkusIntegrationTest +@Disabled("TODO") +@TestMethodOrder(OrderAnnotation.class) +public class CustomTargetsIT extends StandardSelfTest { + + private final ExecutorService worker = Executors.newCachedThreadPool(); + static final Map NULL_RESULT = new HashMap<>(); + private String itestJvmId; + private static StoredCredential storedCredential; + + static { + NULL_RESULT.put("result", null); + } + + @BeforeEach + void setup() throws InterruptedException, ExecutionException, TimeoutException { + itestJvmId = + JvmIdWebRequest.jvmIdRequest( + "service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi"); + } + + @AfterAll + static void cleanup() throws Exception { + // Delete credentials to clean up + CompletableFuture deleteResponse = new CompletableFuture<>(); + webClient + .delete("/api/v2.2/credentials/" + storedCredential.id) + .send( + ar -> { + if (assertRequestStatus(ar, deleteResponse)) { + deleteResponse.complete(ar.result().bodyAsJsonObject()); + } + }); + + JsonObject expectedDeleteResponse = + new JsonObject( + Map.of( + "meta", + Map.of("type", HttpMimeType.JSON.mime(), "status", "OK"), + "data", + NULL_RESULT)); + try { + MatcherAssert.assertThat( + deleteResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), + Matchers.equalTo(expectedDeleteResponse)); + } catch (Exception e) { + logger.error( + new ITestCleanupFailedException( + String.format( + "Failed to clean up credential with ID %d", + storedCredential.id), + e)); + } + } + + @Test + @Order(1) + void shouldBeAbleToTestTargetConnection() throws InterruptedException, ExecutionException { + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("connectUrl", "localhost:0"); + form.add("alias", "self"); + + CompletableFuture response = new CompletableFuture<>(); + webClient + .post("/api/v2/targets?dryrun=true") + .sendForm( + form, + ar -> { + assertRequestStatus(ar, response); + // Assert 202 since localhost:0 jvm already exists + MatcherAssert.assertThat( + ar.result().statusCode(), Matchers.equalTo(202)); + response.complete(ar.result().bodyAsJsonObject()); + }); + JsonObject body = response.get().getJsonObject("data").getJsonObject("result"); + MatcherAssert.assertThat(body.getString("connectUrl"), Matchers.equalTo("localhost:0")); + MatcherAssert.assertThat(body.getString("alias"), Matchers.equalTo("self")); + } + + @Test + @Order(2) + void targetShouldNotAppearInListing() throws InterruptedException, ExecutionException { + CompletableFuture response = new CompletableFuture<>(); + webClient + .get("/api/v1/targets") + .send( + ar -> { + assertRequestStatus(ar, response); + response.complete(ar.result().bodyAsJsonArray()); + }); + JsonArray body = response.get(); + MatcherAssert.assertThat(body, Matchers.notNullValue()); + MatcherAssert.assertThat(body.size(), Matchers.equalTo(1)); + + JsonObject selfJdp = + new JsonObject( + Map.of( + "jvmId", + itestJvmId, + "alias", + "io.cryostat.Cryostat", + "connectUrl", + "service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi", + "labels", + Map.of(), + "annotations", + Map.of( + "cryostat", + Map.of( + "REALM", + "JDP", + "HOST", + "cryostat-itests", + "PORT", + "9091", + "JAVA_MAIN", + "io.cryostat.Cryostat"), + "platform", + Map.of()))); + MatcherAssert.assertThat(body, Matchers.contains(selfJdp)); + } + + @Test + @Order(3) + void shouldBeAbleToDefineTarget() + throws TimeoutException, ExecutionException, InterruptedException { + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("connectUrl", "localhost:0"); + form.add("alias", "self"); + form.add("username", "username"); + form.add("password", "password"); + + CountDownLatch latch = new CountDownLatch(3); + + Future resultFuture1 = + worker.submit( + () -> { + try { + return expectNotification("CredentialsStored", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Future resultFuture2 = + worker.submit( + () -> { + try { + return expectNotification( + "TargetJvmDiscovery", 15, TimeUnit.SECONDS) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + + Thread.sleep(5000); // Sleep to setup notification listening before query resolves + + CompletableFuture response = new CompletableFuture<>(); + webClient + .post("/api/v2/targets?storeCredentials=true") + .sendForm( + form, + ar -> { + assertRequestStatus(ar, response); + response.complete(ar.result().bodyAsJsonObject()); + latch.countDown(); + }); + latch.await(30, TimeUnit.SECONDS); + + JsonObject body = response.get().getJsonObject("data").getJsonObject("result"); + MatcherAssert.assertThat(body.getString("connectUrl"), Matchers.equalTo("localhost:0")); + MatcherAssert.assertThat(body.getString("alias"), Matchers.equalTo("self")); + + JsonObject result1 = resultFuture1.get(); + + JsonObject message = result1.getJsonObject("message"); + + storedCredential = + new StoredCredential( + message.getInteger("id"), + message.getString("matchExpression"), + message.getInteger("numMatchingTargets")); + + MatcherAssert.assertThat(storedCredential.id, Matchers.any(Integer.class)); + MatcherAssert.assertThat( + storedCredential.matchExpression, + Matchers.equalTo("target.connectUrl == \"localhost:0\"")); + MatcherAssert.assertThat( + storedCredential.numMatchingTargets, Matchers.equalTo(Integer.valueOf(1))); + + JsonObject result2 = resultFuture2.get(); + JsonObject event = result2.getJsonObject("message").getJsonObject("event"); + MatcherAssert.assertThat(event.getString("kind"), Matchers.equalTo("FOUND")); + MatcherAssert.assertThat( + event.getJsonObject("serviceRef").getString("connectUrl"), + Matchers.equalTo("localhost:0")); + MatcherAssert.assertThat( + event.getJsonObject("serviceRef").getString("alias"), Matchers.equalTo("self")); + } + + @Test + @Order(4) + void targetShouldAppearInListing() + throws ExecutionException, InterruptedException, TimeoutException { + CompletableFuture response = new CompletableFuture<>(); + webClient + .get("/api/v1/targets") + .send( + ar -> { + assertRequestStatus(ar, response); + response.complete(ar.result().bodyAsJsonArray()); + }); + JsonArray body = response.get(); + MatcherAssert.assertThat(body, Matchers.notNullValue()); + MatcherAssert.assertThat(body.size(), Matchers.equalTo(2)); + + JsonObject selfJdp = + new JsonObject( + Map.of( + "jvmId", + itestJvmId, + "alias", + "io.cryostat.Cryostat", + "connectUrl", + "service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi", + "labels", + Map.of(), + "annotations", + Map.of( + "cryostat", + Map.of( + "REALM", + "JDP", + "HOST", + "cryostat-itests", + "PORT", + "9091", + "JAVA_MAIN", + "io.cryostat.Cryostat"), + "platform", + Map.of()))); + JsonObject selfCustom = + new JsonObject( + Map.of( + "jvmId", + itestJvmId, + "alias", + "self", + "connectUrl", + "localhost:0", + "labels", + Map.of(), + "annotations", + Map.of( + "cryostat", + Map.of("REALM", "Custom Targets"), + "platform", + Map.of()))); + MatcherAssert.assertThat(body, Matchers.containsInAnyOrder(selfJdp, selfCustom)); + } + + @Test + @Order(5) + void shouldBeAbleToDeleteTarget() + throws TimeoutException, ExecutionException, InterruptedException { + CountDownLatch latch = new CountDownLatch(2); + + worker.submit( + () -> { + try { + expectNotification("TargetJvmDiscovery", 5, TimeUnit.SECONDS) + .thenAcceptAsync( + notification -> { + JsonObject event = + notification + .getJsonObject("message") + .getJsonObject("event"); + MatcherAssert.assertThat( + event.getString("kind"), + Matchers.equalTo("LOST")); + MatcherAssert.assertThat( + event.getJsonObject("serviceRef") + .getString("connectUrl"), + Matchers.equalTo("localhost:0")); + MatcherAssert.assertThat( + event.getJsonObject("serviceRef") + .getString("alias"), + Matchers.equalTo("self")); + latch.countDown(); + }) + .get(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + CompletableFuture response = new CompletableFuture<>(); + webClient + .delete("/api/v2/targets/localhost:0") + .send( + ar -> { + assertRequestStatus(ar, response); + response.complete(null); + latch.countDown(); + }); + + latch.await(5, TimeUnit.SECONDS); + } + + @Test + @Order(6) + void targetShouldNoLongerAppearInListing() throws ExecutionException, InterruptedException { + CompletableFuture response = new CompletableFuture<>(); + webClient + .get("/api/v1/targets") + .send( + ar -> { + assertRequestStatus(ar, response); + response.complete(ar.result().bodyAsJsonArray()); + }); + JsonArray body = response.get(); + MatcherAssert.assertThat(body, Matchers.notNullValue()); + MatcherAssert.assertThat(body.size(), Matchers.equalTo(1)); + + JsonObject selfJdp = + new JsonObject( + Map.of( + "jvmId", + itestJvmId, + "alias", + "io.cryostat.Cryostat", + "connectUrl", + "service:jmx:rmi:///jndi/rmi://cryostat-itests:9091/jmxrmi", + "labels", + Map.of(), + "annotations", + Map.of( + "cryostat", + Map.of( + "REALM", + "JDP", + "HOST", + "cryostat-itests", + "PORT", + "9091", + "JAVA_MAIN", + "io.cryostat.Cryostat"), + "platform", + Map.of()))); + MatcherAssert.assertThat(body, Matchers.contains(selfJdp)); + } +} diff --git a/src/test/java/itest/CustomTargetsTest.java b/src/test/java/itest/CustomTargetsTest.java deleted file mode 100644 index b7a08d123..000000000 --- a/src/test/java/itest/CustomTargetsTest.java +++ /dev/null @@ -1,327 +0,0 @@ -/* - * 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 java.net.UnknownHostException; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import io.cryostat.util.HttpMimeType; - -import io.quarkus.test.junit.QuarkusTest; -import io.vertx.core.MultiMap; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.client.HttpResponse; -import itest.bases.StandardSelfTest; -import itest.util.ITestCleanupFailedException; -import itest.util.http.JvmIdWebRequest; -import itest.util.http.StoredCredential; -import org.apache.http.client.utils.URLEncodedUtils; -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; - -@QuarkusTest -@TestMethodOrder(OrderAnnotation.class) -public class CustomTargetsTest extends StandardSelfTest { - - private final ExecutorService worker = Executors.newCachedThreadPool(); - private static String itestJvmId; - private static StoredCredential storedCredential; - - static String JMX_URL_ENCODED = URLEncodedUtils.formatSegments(SELF_JMX_URL).substring(1); - - @BeforeAll - static void removeTestHarnessTargetDefinition() - throws InterruptedException, - ExecutionException, - TimeoutException, - UnknownHostException { - itestJvmId = JvmIdWebRequest.jvmIdRequest(SELF_JMX_URL); - - deleteSelfCustomTarget(); - - JsonArray list = - webClient - .extensions() - .get("/api/v3/targets", true, REQUEST_TIMEOUT_SECONDS) - .bodyAsJsonArray(); - if (!list.isEmpty()) throw new IllegalStateException(); - } - - @AfterAll - static void restoreTestHarnessTargetDefinition() - throws InterruptedException, - ExecutionException, - TimeoutException, - UnknownHostException { - waitForDiscovery(0); - } - - @AfterAll - static void cleanup() throws Exception { - // Delete credentials to clean up - if (storedCredential == null) { - return; - } - CompletableFuture deleteResponse = new CompletableFuture<>(); - webClient - .delete("/api/v2.2/credentials/" + storedCredential.id) - .send( - ar -> { - if (assertRequestStatus(ar, deleteResponse)) { - deleteResponse.complete(ar.result().bodyAsJsonObject()); - } - }); - - Map nullResult = new HashMap<>(); - nullResult.put("result", null); - JsonObject expectedDeleteResponse = - new JsonObject( - Map.of( - "meta", - Map.of("type", HttpMimeType.JSON.mime(), "status", "OK"), - "data", - nullResult)); - try { - MatcherAssert.assertThat( - deleteResponse.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), - Matchers.equalTo(expectedDeleteResponse)); - } catch (Exception e) { - logger.error( - new ITestCleanupFailedException( - String.format( - "Failed to clean up credential with ID %d", - storedCredential.id), - e)); - } - } - - @Test - @Order(1) - void shouldBeAbleToTestTargetConnection() - throws InterruptedException, ExecutionException, TimeoutException { - HttpResponse response = - webClient - .extensions() - .post( - "/api/v2/targets?dryrun=true", - true, - Buffer.buffer( - JsonObject.of("connectUrl", SELF_JMX_URL, "alias", "self") - .encode()), - REQUEST_TIMEOUT_SECONDS); - MatcherAssert.assertThat(response.statusCode(), Matchers.equalTo(202)); - JsonObject body = response.bodyAsJsonObject().getJsonObject("data").getJsonObject("result"); - MatcherAssert.assertThat(body.getString("connectUrl"), Matchers.equalTo(SELF_JMX_URL)); - MatcherAssert.assertThat(body.getString("alias"), Matchers.equalTo("self")); - MatcherAssert.assertThat(body.getString("jvmId"), Matchers.equalTo(itestJvmId)); - - JsonArray list = - webClient - .extensions() - .get("/api/v3/targets", true, REQUEST_TIMEOUT_SECONDS) - .bodyAsJsonArray(); - MatcherAssert.assertThat(list, Matchers.notNullValue()); - MatcherAssert.assertThat(list.size(), Matchers.equalTo(0)); - } - - @Test - @Order(2) - void shouldBeAbleToDefineTarget() - throws TimeoutException, ExecutionException, InterruptedException { - MultiMap form = MultiMap.caseInsensitiveMultiMap(); - String alias = UUID.randomUUID().toString(); - form.add("connectUrl", SELF_JMX_URL); - form.add("alias", alias); - form.add("username", "username"); - form.add("password", "password"); - - CountDownLatch latch = new CountDownLatch(2); - - Future resultFuture1 = - worker.submit( - () -> { - try { - return expectNotification( - "CredentialsStored", - REQUEST_TIMEOUT_SECONDS, - TimeUnit.SECONDS) - .get(); - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - latch.countDown(); - } - }); - - Future resultFuture2 = - worker.submit( - () -> { - try { - return expectNotification( - "TargetJvmDiscovery", - REQUEST_TIMEOUT_SECONDS, - TimeUnit.SECONDS) - .get(); - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - latch.countDown(); - } - }); - - Thread.sleep(500); - - HttpResponse response = - webClient - .extensions() - .post( - "/api/v2/targets?storeCredentials=true", - true, - form, - REQUEST_TIMEOUT_SECONDS); - MatcherAssert.assertThat(response.statusCode(), Matchers.equalTo(201)); - - JsonObject body = response.bodyAsJsonObject().getJsonObject("data").getJsonObject("result"); - - latch.await(30, TimeUnit.SECONDS); - - MatcherAssert.assertThat(body.getString("connectUrl"), Matchers.equalTo(SELF_JMX_URL)); - MatcherAssert.assertThat(body.getString("alias"), Matchers.equalTo(alias)); - - JsonObject result1 = resultFuture1.get(); - - JsonObject message = result1.getJsonObject("message"); - - storedCredential = - new StoredCredential( - message.getInteger("id"), - message.getString("matchExpression"), - message.getInteger("numMatchingTargets")); - - MatcherAssert.assertThat(storedCredential.id, Matchers.any(Integer.class)); - MatcherAssert.assertThat( - storedCredential.matchExpression, - Matchers.equalTo(String.format("target.connectUrl == \"%s\"", SELF_JMX_URL))); - // FIXME this is currently always emitted as 0. Do we really need this to be included at - // all? - // MatcherAssert.assertThat( - // storedCredential.numMatchingTargets, Matchers.equalTo(Integer.valueOf(1))); - - JsonObject result2 = resultFuture2.get(); - JsonObject foundDiscoveryEvent = result2.getJsonObject("message").getJsonObject("event"); - MatcherAssert.assertThat(foundDiscoveryEvent.getString("kind"), Matchers.equalTo("FOUND")); - MatcherAssert.assertThat( - foundDiscoveryEvent.getJsonObject("serviceRef").getString("connectUrl"), - Matchers.equalTo(SELF_JMX_URL)); - MatcherAssert.assertThat( - foundDiscoveryEvent.getJsonObject("serviceRef").getString("alias"), - Matchers.equalTo(alias)); - - HttpResponse listResponse = - webClient.extensions().get("/api/v1/targets", true, REQUEST_TIMEOUT_SECONDS); - MatcherAssert.assertThat(listResponse.statusCode(), Matchers.equalTo(200)); - JsonArray list = listResponse.bodyAsJsonArray(); - MatcherAssert.assertThat(list, Matchers.notNullValue()); - MatcherAssert.assertThat(list.size(), Matchers.equalTo(1)); - JsonObject item = list.getJsonObject(0); - MatcherAssert.assertThat(item.getString("jvmId"), Matchers.equalTo(itestJvmId)); - MatcherAssert.assertThat(item.getString("alias"), Matchers.equalTo(alias)); - MatcherAssert.assertThat(item.getString("connectUrl"), Matchers.equalTo(SELF_JMX_URL)); - MatcherAssert.assertThat(item.getJsonObject("labels"), Matchers.equalTo(new JsonObject())); - MatcherAssert.assertThat( - item.getJsonObject("annotations"), - Matchers.equalTo( - new JsonObject( - Map.of( - "platform", - Map.of(), - "cryostat", - Map.of("REALM", "Custom Targets"))))); - } - - @Test - @Order(3) - void shouldBeAbleToDeleteTarget() - throws TimeoutException, ExecutionException, InterruptedException { - CountDownLatch latch = new CountDownLatch(1); - - worker.submit( - () -> { - try { - expectNotification( - "TargetJvmDiscovery", - REQUEST_TIMEOUT_SECONDS, - TimeUnit.SECONDS) - .thenAcceptAsync( - notification -> { - JsonObject event = - notification - .getJsonObject("message") - .getJsonObject("event"); - MatcherAssert.assertThat( - event.getString("kind"), - Matchers.equalTo("LOST")); - MatcherAssert.assertThat( - event.getJsonObject("serviceRef") - .getString("connectUrl"), - Matchers.equalTo("localhost:0")); - MatcherAssert.assertThat( - event.getJsonObject("serviceRef") - .getString("alias"), - Matchers.equalTo("self")); - latch.countDown(); - }) - .get(); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); - - webClient - .extensions() - .delete( - String.format("/api/v2/targets/%s", JMX_URL_ENCODED), - true, - REQUEST_TIMEOUT_SECONDS); - - latch.await(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); - - HttpResponse listResponse = - webClient.extensions().get("/api/v1/targets", true, REQUEST_TIMEOUT_SECONDS); - MatcherAssert.assertThat(listResponse.statusCode(), Matchers.equalTo(200)); - JsonArray list = listResponse.bodyAsJsonArray(); - MatcherAssert.assertThat(list, Matchers.notNullValue()); - MatcherAssert.assertThat(list.size(), Matchers.equalTo(0)); - } -} diff --git a/src/test/java/itest/SnapshotTest.java b/src/test/java/itest/SnapshotTest.java index ece42cdcc..1f9d46125 100644 --- a/src/test/java/itest/SnapshotTest.java +++ b/src/test/java/itest/SnapshotTest.java @@ -15,7 +15,6 @@ */ package itest; -import java.net.URI; import java.time.Instant; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -150,13 +149,7 @@ void testPostV1ShouldCreateSnapshot() throws Exception { form.add("recordingName", TEST_RECORDING_NAME); form.add("duration", "5"); form.add("events", "template=ALL"); - webClient - .extensions() - .post( - String.format("%s/recordings", v1RequestUrl()), - true, - form, - REQUEST_TIMEOUT_SECONDS); + webClient.extensions().post(String.format("%s/recordings", v1RequestUrl()), true, form, 5); // Create a snapshot recording of all events at that time webClient @@ -184,7 +177,7 @@ void testPostV1ShouldCreateSnapshot() throws Exception { .delete( String.format("%s/recordings/%s", v1RequestUrl(), TEST_RECORDING_NAME), true, - REQUEST_TIMEOUT_SECONDS); + 5); webClient .extensions() .delete( @@ -193,7 +186,7 @@ void testPostV1ShouldCreateSnapshot() throws Exception { v1RequestUrl(), snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)), true, - REQUEST_TIMEOUT_SECONDS); + 5); } @Test @@ -224,13 +217,7 @@ void testPostV2ShouldCreateSnapshot() throws Exception { form.add("recordingName", TEST_RECORDING_NAME); form.add("duration", "5"); form.add("events", "template=ALL"); - webClient - .extensions() - .post( - String.format("%s/recordings", v1RequestUrl()), - true, - form, - REQUEST_TIMEOUT_SECONDS); + webClient.extensions().post(String.format("%s/recordings", v1RequestUrl()), true, form, 5); // Create a snapshot recording of all events at that time CompletableFuture createResponse = new CompletableFuture<>(); @@ -278,13 +265,7 @@ void testPostV2ShouldCreateSnapshot() throws Exception { Matchers.equalTo("/api/v3/activedownload/" + result.getLong("id"))); MatcherAssert.assertThat( result.getString("reportUrl"), - Matchers.equalTo( - URI.create( - String.format( - "%s/reports/%d", - selfCustomTargetLocation, - result.getLong("remoteId"))) - .getPath())); + Matchers.equalTo("/api/v3/targets/1/reports/" + result.getLong("remoteId"))); MatcherAssert.assertThat(result.getLong("expiry"), Matchers.nullValue()); webClient @@ -292,7 +273,7 @@ void testPostV2ShouldCreateSnapshot() throws Exception { .delete( String.format("%s/recordings/%s", v1RequestUrl(), TEST_RECORDING_NAME), true, - REQUEST_TIMEOUT_SECONDS); + 5); webClient .extensions() .delete( @@ -301,7 +282,7 @@ void testPostV2ShouldCreateSnapshot() throws Exception { v1RequestUrl(), snapshotName.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)), true, - REQUEST_TIMEOUT_SECONDS); + 5); } @Test diff --git a/src/test/java/itest/UploadRecordingTest.java b/src/test/java/itest/UploadRecordingTest.java index 0ef11155b..437b87e9a 100644 --- a/src/test/java/itest/UploadRecordingTest.java +++ b/src/test/java/itest/UploadRecordingTest.java @@ -108,7 +108,7 @@ public void shouldLoadRecordingToDatasource() throws Exception { "/api/v1/targets/%s/recordings/%s/upload", getSelfReferenceConnectUrlEncoded(), RECORDING_NAME), true, - (Buffer) null, + null, 0); MatcherAssert.assertThat(resp.statusCode(), Matchers.equalTo(200)); diff --git a/src/test/java/itest/bases/StandardSelfTest.java b/src/test/java/itest/bases/StandardSelfTest.java index 693f085f1..ed6e6026d 100644 --- a/src/test/java/itest/bases/StandardSelfTest.java +++ b/src/test/java/itest/bases/StandardSelfTest.java @@ -55,8 +55,8 @@ public abstract class StandardSelfTest { - public static final String SELF_JMX_URL = "service:jmx:rmi:///jndi/rmi://localhost:0/jmxrmi"; - public static final String SELFTEST_ALIAS = "selftest"; + private static final String SELF_JMX_URL = "service:jmx:rmi:///jndi/rmi://localhost:0/jmxrmi"; + private static final String SELFTEST_ALIAS = "selftest"; private static final ExecutorService WORKER = Executors.newCachedThreadPool(); public static final Logger logger = Logger.getLogger(StandardSelfTest.class); public static final ObjectMapper mapper = new ObjectMapper(); @@ -81,36 +81,62 @@ public static void assertPostconditions() throws Exception { } public static void assertNoRecordings() throws Exception { - JsonArray listResp = - webClient - .extensions() - .get( - String.format( - "/api/v1/targets/%s/recordings", - getSelfReferenceConnectUrlEncoded()), - true, - REQUEST_TIMEOUT_SECONDS) - .bodyAsJsonArray(); + CompletableFuture listFuture = new CompletableFuture<>(); + webClient + .get( + String.format( + "/api/v1/targets/%s/recordings", + getSelfReferenceConnectUrlEncoded())) + .basicAuthentication("user", "pass") + .send( + ar -> { + if (assertRequestStatus(ar, listFuture)) { + listFuture.complete(ar.result().bodyAsJsonArray()); + } + }); + JsonArray listResp = listFuture.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); if (!listResp.isEmpty()) { throw new ITestCleanupFailedException( String.format("Unexpected recordings:\n%s", listResp.encodePrettily())); } } - @AfterAll - public static void deleteSelfCustomTarget() - throws InterruptedException, ExecutionException, TimeoutException { - if (!selfCustomTargetExists()) { + // @AfterAll + public static void deleteSelfCustomTarget() { + if (StringUtils.isBlank(selfCustomTargetLocation)) { return; } logger.infov("Deleting self custom target at {0}", selfCustomTargetLocation); String path = URI.create(selfCustomTargetLocation).getPath(); - HttpResponse resp = - webClient.extensions().delete(path, true, REQUEST_TIMEOUT_SECONDS); - logger.infov( - "DELETE {0} -> HTTP {1} {2}: [{3}]", - path, resp.statusCode(), resp.statusMessage(), resp.headers()); - selfCustomTargetLocation = null; + CompletableFuture future = new CompletableFuture<>(); + try { + WORKER.submit( + () -> { + webClient + .delete(path) + .basicAuthentication("user", "pass") + .timeout(5000) + .send( + ar -> { + if (ar.failed()) { + logger.error(ar.cause()); + future.completeExceptionally(ar.cause()); + return; + } + HttpResponse resp = ar.result(); + logger.infov( + "DELETE {0} -> HTTP {1} {2}: [{3}]", + path, + resp.statusCode(), + resp.statusMessage(), + resp.headers()); + future.complete(null); + }); + }); + selfCustomTargetLocation = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + logger.error(e); + } } public static void waitForDiscovery(int otherTargetsCount) { @@ -126,7 +152,7 @@ public static void waitForDiscovery(int otherTargetsCount) { .get("/api/v3/targets") .basicAuthentication("user", "pass") .as(BodyCodec.jsonArray()) - .timeout(TimeUnit.SECONDS.toMillis(REQUEST_TIMEOUT_SECONDS)) + .timeout(5000) .send( ar -> { if (ar.failed()) { @@ -168,21 +194,38 @@ private static boolean selfCustomTargetExists() { if (StringUtils.isBlank(selfCustomTargetLocation)) { return false; } + CompletableFuture future = new CompletableFuture<>(); try { - HttpResponse resp = - webClient - .extensions() - .get(selfCustomTargetLocation, true, REQUEST_TIMEOUT_SECONDS); - logger.infov( - "POST /api/v2/targets -> HTTP {0} {1}: [{2}]", - resp.statusCode(), resp.statusMessage(), resp.headers()); - boolean result = HttpStatusCodeIdentifier.isSuccessCode(resp.statusCode()); + WORKER.submit( + () -> { + webClient + .getAbs(selfCustomTargetLocation) + .basicAuthentication("user", "pass") + .timeout(5000) + .send( + ar -> { + if (ar.failed()) { + logger.error(ar.cause()); + future.completeExceptionally(ar.cause()); + return; + } + HttpResponse resp = ar.result(); + logger.infov( + "POST /api/v2/targets -> HTTP {0} {1}: [{2}]", + resp.statusCode(), + resp.statusMessage(), + resp.headers()); + future.complete( + HttpStatusCodeIdentifier.isSuccessCode( + resp.statusCode())); + }); + }); + boolean result = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); if (!result) { selfCustomTargetLocation = null; } return result; } catch (Exception e) { - selfCustomTargetLocation = null; logger.error(e); return false; } @@ -193,45 +236,88 @@ private static void tryDefineSelfCustomTarget() { return; } logger.info("Trying to define self-referential custom target..."); - JsonObject self = - new JsonObject(Map.of("connectUrl", SELF_JMX_URL, "alias", SELFTEST_ALIAS)); - HttpResponse resp; + CompletableFuture future = new CompletableFuture<>(); try { - resp = - webClient - .extensions() - .post( - "/api/v2/targets", - true, - Buffer.buffer(self.encode()), - REQUEST_TIMEOUT_SECONDS); - logger.infov( - "POST /api/v2/targets -> HTTP {0} {1}: [{2}]", - resp.statusCode(), resp.statusMessage(), resp.headers()); - if (!HttpStatusCodeIdentifier.isSuccessCode(resp.statusCode())) { - throw new IllegalStateException(Integer.toString(resp.statusCode())); - } - selfCustomTargetLocation = - URI.create(resp.headers().get(HttpHeaders.LOCATION)).getPath(); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IllegalStateException(e); + JsonObject self = + new JsonObject(Map.of("connectUrl", SELF_JMX_URL, "alias", SELFTEST_ALIAS)); + WORKER.submit( + () -> { + webClient + .post("/api/v2/targets") + .basicAuthentication("user", "pass") + .timeout(5000) + .sendJson( + self, + ar -> { + if (ar.failed()) { + logger.error(ar.cause()); + future.completeExceptionally(ar.cause()); + return; + } + HttpResponse resp = ar.result(); + logger.infov( + "POST /api/v2/targets -> HTTP {0} {1}: [{2}]", + resp.statusCode(), + resp.statusMessage(), + resp.headers()); + if (HttpStatusCodeIdentifier.isSuccessCode( + resp.statusCode())) { + future.complete( + resp.headers().get(HttpHeaders.LOCATION)); + } else { + future.completeExceptionally( + new IllegalStateException( + Integer.toString( + resp.statusCode()))); + } + }); + }); + selfCustomTargetLocation = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + logger.error(e); } } public static String getSelfReferenceConnectUrl() { + tryDefineSelfCustomTarget(); + CompletableFuture future = new CompletableFuture<>(); + WORKER.submit( + () -> { + String path = URI.create(selfCustomTargetLocation).getPath(); + webClient + .get(path) + .basicAuthentication("user", "pass") + .as(BodyCodec.jsonObject()) + .timeout(5000) + .send( + ar -> { + if (ar.failed()) { + logger.error(ar.cause()); + future.completeExceptionally(ar.cause()); + return; + } + HttpResponse resp = ar.result(); + JsonObject body = resp.body(); + logger.infov( + "GET {0} -> HTTP {1} {2}: [{3}] = {4}", + path, + resp.statusCode(), + resp.statusMessage(), + resp.headers(), + body); + if (!HttpStatusCodeIdentifier.isSuccessCode( + resp.statusCode())) { + future.completeExceptionally( + new IllegalStateException( + Integer.toString(resp.statusCode()))); + return; + } + future.complete(body); + }); + }); try { - tryDefineSelfCustomTarget(); - String path = URI.create(selfCustomTargetLocation).getPath(); - HttpResponse resp = - webClient.extensions().get(path, true, REQUEST_TIMEOUT_SECONDS); - JsonObject body = resp.bodyAsJsonObject(); - logger.infov( - "GET {0} -> HTTP {1} {2}: [{3}] = {4}", - path, resp.statusCode(), resp.statusMessage(), resp.headers(), body); - if (!HttpStatusCodeIdentifier.isSuccessCode(resp.statusCode())) { - throw new IllegalStateException(Integer.toString(resp.statusCode())); - } - return body.getString("connectUrl"); + JsonObject obj = future.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + return obj.getString("connectUrl"); } catch (Exception e) { throw new RuntimeException("Could not determine own connectUrl", e); } @@ -247,12 +333,8 @@ public static String getSelfReferenceConnectUrlEncoded() { public static CompletableFuture expectNotification( String category, long timeout, TimeUnit unit) throws TimeoutException, ExecutionException, InterruptedException { - logger.infov( - "Waiting for a \"{0}\" message within the next {1} {2} ...", - category, timeout, unit.name()); CompletableFuture future = new CompletableFuture<>(); - var a = new WebSocket[1]; Utils.HTTP_CLIENT.webSocket( getNotificationsUrl().get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), ar -> { @@ -260,8 +342,7 @@ public static CompletableFuture expectNotification( future.completeExceptionally(ar.cause()); return; } - a[0] = ar.result(); - var ws = a[0]; + WebSocket ws = ar.result(); ws.handler( m -> { @@ -269,8 +350,6 @@ public static CompletableFuture expectNotification( JsonObject meta = resp.getJsonObject("meta"); String c = meta.getString("category"); if (Objects.equals(c, category)) { - logger.infov( - "Received expected \"{0}\" message", category); ws.end(unused -> future.complete(resp)); ws.close(); } @@ -286,7 +365,7 @@ public static CompletableFuture expectNotification( .writeTextMessage(""); }); - return future.orTimeout(timeout, unit).whenComplete((o, t) -> a[0].close()); + return future.orTimeout(timeout, unit); } public static boolean assertRequestStatus( diff --git a/src/test/java/itest/util/Utils.java b/src/test/java/itest/util/Utils.java index c74256f22..61ef57bd4 100644 --- a/src/test/java/itest/util/Utils.java +++ b/src/test/java/itest/util/Utils.java @@ -83,13 +83,7 @@ public static FileSystem getFileSystem() { } public interface RedirectExtensions { - HttpResponse get(String url, boolean authentication, int timeout) - throws InterruptedException, ExecutionException, TimeoutException; - - HttpResponse post(String url, boolean authentication, Buffer payload, int timeout) - throws InterruptedException, ExecutionException, TimeoutException; - - HttpResponse post(String url, boolean authentication, MultiMap payload, int timeout) + HttpResponse post(String url, boolean authentication, MultiMap form, int timeout) throws InterruptedException, ExecutionException, TimeoutException; HttpResponse delete(String url, boolean authentication, int timeout) @@ -118,66 +112,8 @@ public RedirectExtensions extensions() { } private class RedirectExtensionsImpl implements RedirectExtensions { - public HttpResponse get(String url, boolean authentication, int timeout) - throws InterruptedException, ExecutionException, TimeoutException { - CompletableFuture> future = new CompletableFuture<>(); - RequestOptions options = new RequestOptions().setURI(url); - HttpRequest req = TestWebClient.this.request(HttpMethod.GET, options); - if (authentication) { - req.basicAuthentication("user", "pass"); - } - req.send( - ar -> { - if (ar.succeeded()) { - future.complete(ar.result()); - } else { - future.completeExceptionally(ar.cause()); - } - }); - if (future.get().statusCode() == 308) { - return get(future.get().getHeader("Location"), true, timeout); - } - return future.get(timeout, TimeUnit.SECONDS); - } - - public HttpResponse post( - String url, boolean authentication, Buffer payload, int timeout) - throws InterruptedException, ExecutionException, TimeoutException { - CompletableFuture> future = new CompletableFuture<>(); - RequestOptions options = new RequestOptions().setURI(url); - HttpRequest req = TestWebClient.this.request(HttpMethod.POST, options); - if (authentication) { - req.basicAuthentication("user", "pass"); - } - if (payload != null) { - req.sendBuffer( - payload, - ar -> { - if (ar.succeeded()) { - future.complete(ar.result()); - } else { - future.completeExceptionally(ar.cause()); - } - }); - } else { - req.send( - ar -> { - if (ar.succeeded()) { - future.complete(ar.result()); - } else { - future.completeExceptionally(ar.cause()); - } - }); - } - if (future.get().statusCode() == 308) { - return post( - future.get().getHeader("Location"), authentication, payload, timeout); - } - return future.get(timeout, TimeUnit.SECONDS); - } - public HttpResponse post( - String url, boolean authentication, MultiMap payload, int timeout) + String url, boolean authentication, MultiMap form, int timeout) throws InterruptedException, ExecutionException, TimeoutException { CompletableFuture> future = new CompletableFuture<>(); RequestOptions options = new RequestOptions().setURI(url); @@ -185,9 +121,9 @@ public HttpResponse post( if (authentication) { req.basicAuthentication("user", "pass"); } - if (payload != null) { + if (form != null) { req.sendForm( - payload, + form, ar -> { if (ar.succeeded()) { future.complete(ar.result()); @@ -206,8 +142,7 @@ public HttpResponse post( }); } if (future.get().statusCode() == 308) { - return post( - future.get().getHeader("Location"), authentication, payload, timeout); + return post(future.get().getHeader("Location"), authentication, form, timeout); } return future.get(timeout, TimeUnit.SECONDS); } diff --git a/src/test/java/itest/util/http/JvmIdWebRequest.java b/src/test/java/itest/util/http/JvmIdWebRequest.java index f9c8180b7..ad3eed3da 100644 --- a/src/test/java/itest/util/http/JvmIdWebRequest.java +++ b/src/test/java/itest/util/http/JvmIdWebRequest.java @@ -15,41 +15,93 @@ */ package itest.util.http; -import java.util.Objects; +import java.net.URI; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import com.google.gson.Gson; +import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.WebClient; import itest.bases.StandardSelfTest; import itest.util.Utils; -import itest.util.Utils.TestWebClient; +import org.apache.commons.lang3.tuple.Pair; public class JvmIdWebRequest { - public static final TestWebClient webClient = Utils.getWebClient(); + private static final Gson gson = new Gson(); - public static String jvmIdRequest(long id) + public static final int REQUEST_TIMEOUT_SECONDS = 10; + public static final WebClient webClient = Utils.getWebClient(); + + // shouldn't be percent-encoded i.e. + // String.format("service:jmx:rmi:///jndi/rmi://%s:9091/jmxrmi", Podman.POD_NAME) + public static String jvmIdRequest(String targetId) + throws InterruptedException, ExecutionException, TimeoutException { + return jvmIdRequest(targetId, null); + } + + public static String jvmIdRequest(String targetId, Pair credentials) + throws InterruptedException, ExecutionException, TimeoutException { + CompletableFuture resp = new CompletableFuture<>(); + + JsonObject query = new JsonObject(); + query.put( + "query", + String.format( + "query { targetNodes(filter: { name: \"%s\" }) { target { jvmId } } }", + targetId)); + HttpRequest buffer = webClient.post("/api/v2.2/graphql"); + if (credentials != null) { + buffer.putHeader( + "X-JMX-Authorization", + "Basic " + + Base64.getUrlEncoder() + .encodeToString( + (credentials.getLeft() + ":" + credentials.getRight()) + .getBytes())); + } + buffer.sendJson( + query, + ar -> { + if (StandardSelfTest.assertRequestStatus(ar, resp)) { + resp.complete( + gson.fromJson( + ar.result().bodyAsString(), + TargetNodesQueryResponse.class)); + } + }); + TargetNodesQueryResponse response = resp.get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + return response.data.targetNodes.get(0).target.jvmId; + } + + public static String jvmIdRequest(URI serviceUri) throws InterruptedException, ExecutionException, TimeoutException { - return webClient - .extensions() - .get( - String.format("/api/v3/targets/%d", id), - true, - StandardSelfTest.REQUEST_TIMEOUT_SECONDS) - .bodyAsJsonObject() - .getString("jvmId"); + return jvmIdRequest(serviceUri.toString(), null); } - public static String jvmIdRequest(String connectUrl) + public static String jvmIdRequest(URI serviceUri, Pair credentials) throws InterruptedException, ExecutionException, TimeoutException { - return webClient - .extensions() - .get("/api/v3/targets", true, StandardSelfTest.REQUEST_TIMEOUT_SECONDS) - .bodyAsJsonArray() - .stream() - .map(o -> (JsonObject) o) - .filter(o -> Objects.equals(connectUrl, o.getString("connectUrl"))) - .findFirst() - .map(o -> o.getString("jvmId")) - .orElseThrow(); + return jvmIdRequest(serviceUri.toString(), credentials); + } + + static class TargetNodesQueryResponse { + TargetNodes data; + } + + static class TargetNodes { + List targetNodes; + } + + static class TargetNode { + Target target; + } + + static class Target { + String jvmId; } }