From 8704fa52c746aab2fb847b20cdd23a6d95ea8142 Mon Sep 17 00:00:00 2001 From: Andrew Azores Date: Thu, 25 Jun 2026 14:47:34 -0400 Subject: [PATCH] feat(graphql): audit log lookup for environmentNodes query --- .../io/cryostat/graphql/EnvironmentNodes.java | 77 +++++++- .../java/io/cryostat/graphql/RootNode.java | 14 ++ .../EnvironmentNodesAuditDisabledTest.java | 119 +++++++++++ .../EnvironmentNodesAuditEnabledTest.java | 184 ++++++++++++++++++ .../EnvironmentNodesAuditTestBase.java | 167 ++++++++++++++++ 5 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/cryostat/graphql/EnvironmentNodesAuditDisabledTest.java create mode 100644 src/test/java/io/cryostat/graphql/EnvironmentNodesAuditEnabledTest.java create mode 100644 src/test/java/io/cryostat/graphql/EnvironmentNodesAuditTestBase.java diff --git a/src/main/java/io/cryostat/graphql/EnvironmentNodes.java b/src/main/java/io/cryostat/graphql/EnvironmentNodes.java index 27ca0aa70..03c3d871d 100644 --- a/src/main/java/io/cryostat/graphql/EnvironmentNodes.java +++ b/src/main/java/io/cryostat/graphql/EnvironmentNodes.java @@ -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 environmentNodes(@Nullable DiscoveryNodeFilter filter) { + public List 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 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 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 historicalNodes = new ArrayList<>(); + for (Number nodeId : nodeIds) { + try { + @SuppressWarnings("unchecked") + List 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(); + } + } } diff --git a/src/main/java/io/cryostat/graphql/RootNode.java b/src/main/java/io/cryostat/graphql/RootNode.java index 4d8e007f9..774aef6d5 100644 --- a/src/main/java/io/cryostat/graphql/RootNode.java +++ b/src/main/java/io/cryostat/graphql/RootNode.java @@ -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()); + } } } diff --git a/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditDisabledTest.java b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditDisabledTest.java new file mode 100644 index 000000000..54f3de814 --- /dev/null +++ b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditDisabledTest.java @@ -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 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)); + } +} diff --git a/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditEnabledTest.java b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditEnabledTest.java new file mode 100644 index 000000000..950cc5ed0 --- /dev/null +++ b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditEnabledTest.java @@ -0,0 +1,184 @@ +/* + * 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.List; +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(EnvironmentNodesAuditEnabledTest.class) +@QuarkusTestResource(value = S3StorageResource.class, restrictToAnnotatedClass = true) +public class EnvironmentNodesAuditEnabledTest extends EnvironmentNodesAuditTestBase + implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.hibernate-orm.unsupported-properties.\"hibernate.envers.enabled\"", + "true"); + } + + @Test + public void testEnvironmentNodesWithoutAuditLogReturnsActiveOnly() throws Exception { + createTestEnvironmentNode("test-env-active", "Realm", true); + + Response response = queryEnvironmentNodes("test-env-active", 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-active".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 testEnvironmentNodesWithAuditLogTrueAndBlankFilterThrowsError() throws Exception { + JsonObject query = new JsonObject(); + query.put( + "query", + "query { environmentNodes(filter: {}, 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")); + } + + @Test + public void testEnvironmentNodesWithAuditLogRetrievesHistoricalNodes() throws Exception { + createTestEnvironmentNode("test-env-historical", "Realm", true); + + List auditRecords = queryDiscoveryNodeAuditRecords("test-env-historical"); + assertThat( + "Audit record should exist for created node", + auditRecords, + hasSize(greaterThanOrEqualTo(1))); + assertThat( + "Audit record should be ADD type", + ((Number) auditRecords.get(0)[2]).intValue(), + equalTo(REVTYPE_ADD)); + + // Query with useAuditLog=true should return the node from the audit log + Response response = queryEnvironmentNodes("test-env-historical", true); + assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300))); + + EnvironmentNodesResponse actual = + mapper.readValue(response.body().asString(), EnvironmentNodesResponse.class); + + boolean foundInAudit = + actual.getData().getEnvironmentNodes().stream() + .anyMatch(n -> "test-env-historical".equals(n.name)); + assertThat("Environment node SHOULD be retrievable from audit log", foundInAudit, is(true)); + } + + @Test + public void testEnvironmentNodesWithAuditLogReturnsLatestRevision() throws Exception { + long nodeId = createTestEnvironmentNode("test-env-label-update", "Realm", true); + + // Update labels (name/nodeType are updatable=false; labels can be updated) + updateEnvironmentNodeLabel(nodeId, "test-key", "test-value"); + + List auditRecords = queryDiscoveryNodeAuditRecords("test-env-label-update"); + assertThat( + "Should have at least 2 audit records (ADD + MOD)", + auditRecords, + hasSize(greaterThanOrEqualTo(2))); + assertThat( + "First record should be ADD", + ((Number) auditRecords.get(0)[2]).intValue(), + equalTo(REVTYPE_ADD)); + assertThat( + "Second record should be MOD", + ((Number) auditRecords.get(1)[2]).intValue(), + equalTo(REVTYPE_MOD)); + + // Query via audit log — the latest revision should be returned (one entry per node) + Response response = queryEnvironmentNodes("test-env-label-update", true); + assertThat(response.statusCode(), allOf(greaterThanOrEqualTo(200), lessThan(300))); + + EnvironmentNodesResponse actual = + mapper.readValue(response.body().asString(), EnvironmentNodesResponse.class); + + long count = + actual.getData().getEnvironmentNodes().stream() + .filter(n -> "test-env-label-update".equals(n.name)) + .count(); + assertThat("Should return exactly one entry for the node", count, equalTo(1L)); + } + + @Test + public void testBackwardCompatibility() throws Exception { + createTestEnvironmentNode("test-env-compat", "Realm", true); + + Response response = queryEnvironmentNodes("test-env-compat", 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-compat".equals(n.name)); + assertThat("Node should be found with default behavior", found, is(true)); + } +} diff --git a/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditTestBase.java b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditTestBase.java new file mode 100644 index 000000000..e442866de --- /dev/null +++ b/src/test/java/io/cryostat/graphql/EnvironmentNodesAuditTestBase.java @@ -0,0 +1,167 @@ +/* + * 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 java.util.HashMap; +import java.util.List; + +import io.cryostat.discovery.DiscoveryNode; + +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.vertx.core.json.JsonObject; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.AfterEach; + +public abstract class EnvironmentNodesAuditTestBase extends AbstractGraphQLTestBase { + + protected static final int REVTYPE_ADD = 0; + protected static final int REVTYPE_MOD = 1; + protected static final int REVTYPE_DEL = 2; + + @Inject protected EntityManager entityManager; + + @AfterEach + @Transactional + void cleanup() throws Exception { + entityManager + .createNativeQuery( + "DELETE FROM DiscoveryNode WHERE name LIKE 'test-env-%'" + + " AND id NOT IN (SELECT DISTINCT discoveryNode FROM Target" + + " WHERE discoveryNode IS NOT NULL)") + .executeUpdate(); + } + + /** + * Inserts a DiscoveryNode via native SQL to avoid JPA cascade/orphan interactions with the + * universe node's children collection. Sets parentNode to the Universe node's id so the node + * appears in the active discovery tree. + * + * @param writeAuditRecord when true, also inserts an Envers ADD revision into + * DiscoveryNode_AUD, mirroring what V4.2.0 migration does for built-in realm nodes. Pass + * true only in tests where Envers is enabled. + */ + @Transactional + protected long createTestEnvironmentNode( + String name, String nodeType, boolean writeAuditRecord) { + Number universeId = + (Number) + entityManager + .createNativeQuery( + "SELECT id FROM DiscoveryNode WHERE nodeType = 'Universe'") + .getSingleResult(); + Number nodeId = + (Number) + entityManager + .createNativeQuery("SELECT nextval('DiscoveryNode_SEQ')") + .getSingleResult(); + entityManager + .createNativeQuery( + "INSERT INTO DiscoveryNode (id, labels, name, nodeType, parentNode)" + + " VALUES (:id, '{}'::jsonb, :name, :nodeType, :parentNode)") + .setParameter("id", nodeId) + .setParameter("name", name) + .setParameter("nodeType", nodeType) + .setParameter("parentNode", universeId) + .executeUpdate(); + if (writeAuditRecord) { + Number rev = + (Number) + entityManager + .createNativeQuery("SELECT nextval('REVINFO_SEQ')") + .getSingleResult(); + entityManager + .createNativeQuery( + "INSERT INTO REVINFO (REV, REVTSTMP, username)" + + " VALUES (:rev, :ts, :user)") + .setParameter("rev", rev) + .setParameter("ts", System.currentTimeMillis()) + .setParameter("user", "test") + .executeUpdate(); + entityManager + .createNativeQuery( + "INSERT INTO DiscoveryNode_AUD" + + " (id, REV, REVTYPE, REVEND, REVEND_TSTMP, name, nodeType," + + " labels, parentNode)" + + " VALUES (:id, :rev, 0, NULL, NULL, :name, :nodeType, '{}'," + + " :parentNode)") + .setParameter("id", nodeId) + .setParameter("rev", rev) + .setParameter("name", name) + .setParameter("nodeType", nodeType) + .setParameter("parentNode", universeId) + .executeUpdate(); + } + entityManager.flush(); + entityManager.clear(); + return nodeId.longValue(); + } + + @Transactional + protected void updateEnvironmentNodeLabel(long nodeId, String labelKey, String labelValue) { + DiscoveryNode managed = entityManager.find(DiscoveryNode.class, nodeId); + if (managed != null) { + managed.labels = new HashMap<>(managed.labels); + managed.labels.put(labelKey, labelValue); + entityManager.merge(managed); + entityManager.flush(); + } + } + + protected Response queryEnvironmentNodes(String nameFilter, Boolean useAuditLog) { + JsonObject query = new JsonObject(); + String filterPart = + nameFilter != null ? String.format("filter: { name: \"%s\" }", nameFilter) : ""; + String auditLogPart = + useAuditLog != null ? String.format("useAuditLog: %b", useAuditLog) : ""; + String params = buildParamList(filterPart, auditLogPart); + query.put( + "query", String.format("query { environmentNodes(%s) { name nodeType } }", params)); + + return given().contentType(ContentType.JSON) + .body(query.encode()) + .when() + .post("/api/v4/graphql") + .then() + .extract() + .response(); + } + + private String buildParamList(String... parts) { + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (part != null && !part.isEmpty()) { + if (sb.length() > 0) sb.append(", "); + sb.append(part); + } + } + return sb.toString(); + } + + @SuppressWarnings("unchecked") + protected List queryDiscoveryNodeAuditRecords(String name) { + return entityManager + .createNativeQuery( + "SELECT id, REV, REVTYPE, name FROM DiscoveryNode_AUD WHERE name = :name" + + " ORDER BY REV") + .setParameter("name", name) + .getResultList(); + } +}