Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
77 changes: 76 additions & 1 deletion src/main/java/io/cryostat/graphql/EnvironmentNodes.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,100 @@
*/
package io.cryostat.graphql;

import java.util.ArrayList;
import java.util.List;

import io.cryostat.discovery.DiscoveryNode;
import io.cryostat.graphql.RootNode.DiscoveryNodeFilter;

import io.smallrye.graphql.api.Nullable;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import org.eclipse.microprofile.graphql.DefaultValue;
import org.eclipse.microprofile.graphql.Description;
import org.eclipse.microprofile.graphql.GraphQLApi;
import org.eclipse.microprofile.graphql.Query;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.hibernate.envers.query.AuditEntity;
import org.jboss.logging.Logger;

@GraphQLApi
public class EnvironmentNodes {

@Inject EntityManager em;
@Inject Logger logger;

@Query("environmentNodes")
@Description("Get all environment nodes in the discovery tree with optional filtering")
public List<DiscoveryNode> environmentNodes(@Nullable DiscoveryNodeFilter filter) {
public List<DiscoveryNode> environmentNodes(
@Nullable DiscoveryNodeFilter filter,
@Nullable
@DefaultValue("false")
@Description(
"Query historical environment nodes from audit log. This is more"
+ " expensive and should only be used when historical data is"
+ " needed. When true, a non-null filter with at least one"
+ " filter field set must be supplied.")
boolean useAuditLog) {
if (useAuditLog) {
if (filter == null || filter.isBlank()) {
throw new IllegalArgumentException(
"A non-null filter with at least one field set is required when"
+ " useAuditLog=true");
}
return queryAuditLogEnvironmentNodes().stream().filter(n -> filter.test(n)).toList();
}
return RootNode.recurseChildren(DiscoveryNode.getUniverse(), node -> node.target == null)
.stream()
.filter(n -> filter == null ? true : filter.test(n))
.toList();
}

private List<DiscoveryNode> queryAuditLogEnvironmentNodes() {
try {
AuditReader ar = AuditReaderFactory.get(em);

// DiscoveryNode_AUD has no target column (Target owns the FK).
// Environment nodes are DiscoveryNode entries that never appear as the
// discoveryNode FK on any Target in the audit log.
@SuppressWarnings("unchecked")
List<Number> nodeIds =
em.createNativeQuery(
"SELECT DISTINCT id FROM DiscoveryNode_AUD WHERE id NOT IN"
+ " (SELECT DISTINCT discoveryNode FROM Target_AUD"
+ " WHERE discoveryNode IS NOT NULL)")
.getResultList();

List<DiscoveryNode> historicalNodes = new ArrayList<>();
for (Number nodeId : nodeIds) {
try {
@SuppressWarnings("unchecked")
List<Object[]> revisions =
ar.createQuery()
.forRevisionsOfEntity(DiscoveryNode.class, false, true)
.add(AuditEntity.id().eq(nodeId.longValue()))
.add(
AuditEntity.revisionType()
.ne(org.hibernate.envers.RevisionType.DEL))
.addOrder(AuditEntity.revisionNumber().desc())
.setMaxResults(1)
.getResultList();
if (!revisions.isEmpty()) {
Object[] result = revisions.get(0);
DiscoveryNode node = (DiscoveryNode) result[0];
node.parent = null;
node.children = new ArrayList<>();
historicalNodes.add(node);
}
} catch (Exception e) {
logger.warn("Failed to get DiscoveryNode from audit for id: " + nodeId, e);
}
}
return historicalNodes;
} catch (Exception e) {
logger.debug("Error querying audit log for environment nodes", e);
return List.of();
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/io/cryostat/graphql/RootNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -135,5 +135,19 @@ public boolean test(DiscoveryNode t) {
.reduce(x -> true, Predicate::and)
.test(t);
}

public boolean isBlank() {
return id == null
&& (ids == null || ids.isEmpty())
&& targetId == null
&& (targetIds == null || targetIds.isEmpty())
&& jvmId == null
&& (jvmIds == null || jvmIds.isEmpty())
&& name == null
&& (names == null || names.isEmpty())
&& (nodeTypes == null || nodeTypes.isEmpty())
&& (labels == null || labels.isEmpty())
&& (annotations == null || annotations.isEmpty());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* 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.graphql;

import static io.restassured.RestAssured.given;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

import java.util.Map;

import io.cryostat.graphql.GraphQLTestModels.EnvironmentNodesResponse;
import io.cryostat.resources.S3StorageResource;

import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.http.ContentType;
import io.restassured.response.Response;
import io.vertx.core.json.JsonObject;
import org.junit.jupiter.api.Test;

@QuarkusTest
@TestProfile(EnvironmentNodesAuditDisabledTest.class)
@QuarkusTestResource(value = S3StorageResource.class, restrictToAnnotatedClass = true)
public class EnvironmentNodesAuditDisabledTest extends EnvironmentNodesAuditTestBase
implements QuarkusTestProfile {

@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.hibernate-orm.unsupported-properties.\"hibernate.envers.enabled\"",
"false");
}

@Test
public void testEnvironmentNodesWithoutAuditLogReturnsActiveNodes() throws Exception {
createTestEnvironmentNode("test-env-no-audit", "Realm", false);

Response response = queryEnvironmentNodes("test-env-no-audit", null);
assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300)));

EnvironmentNodesResponse actual =
mapper.readValue(response.body().asString(), EnvironmentNodesResponse.class);

boolean found =
actual.getData().getEnvironmentNodes().stream()
.anyMatch(n -> "test-env-no-audit".equals(n.name));
assertThat("Active environment node should be found", found, is(true));
}

@Test
public void testEnvironmentNodesWithAuditLogTrueAndNullFilterThrowsError() throws Exception {
JsonObject query = new JsonObject();
query.put("query", "query { environmentNodes(useAuditLog: true) { name nodeType } }");

Response response =
given().contentType(ContentType.JSON)
.body(query.encode())
.when()
.post("/api/v4/graphql")
.then()
.extract()
.response();

assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300)));
String body = response.body().asString();
assertThat("Response should contain an error", body, containsString("errors"));
assertThat("Error should mention filter requirement", body, containsString("filter"));
}

@Test
public void testEnvironmentNodesWithAuditLogTrueReturnsEmptyWhenAuditDisabled()
throws Exception {
createTestEnvironmentNode("test-env-audit-disabled", "Realm", false);

// With audit disabled, useAuditLog=true gracefully returns empty (no _AUD records)
Response response = queryEnvironmentNodes("test-env-audit-disabled", true);
assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300)));

EnvironmentNodesResponse actual =
mapper.readValue(response.body().asString(), EnvironmentNodesResponse.class);

boolean found =
actual.getData().getEnvironmentNodes().stream()
.anyMatch(n -> "test-env-audit-disabled".equals(n.name));
assertThat(
"Node should not be found via audit log when audit is disabled", found, is(false));
}

@Test
public void testEnvironmentNodesWithAuditLogFalseReturnsActiveNodes() throws Exception {
createTestEnvironmentNode("test-env-explicit-false", "Realm", false);

Response response = queryEnvironmentNodes("test-env-explicit-false", false);
assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300)));

EnvironmentNodesResponse actual =
mapper.readValue(response.body().asString(), EnvironmentNodesResponse.class);

boolean found =
actual.getData().getEnvironmentNodes().stream()
.anyMatch(n -> "test-env-explicit-false".equals(n.name));
assertThat("Active node should be found with useAuditLog=false", found, is(true));
}
}
Loading
Loading