From e46209f053bde520493a25d42a24aec75b119247 Mon Sep 17 00:00:00 2001 From: Andrew Azores Date: Thu, 19 Aug 2021 16:41:48 +0000 Subject: [PATCH] feat(rules): implement "archive" rules (#648) * fix(rule): validate eventSpecifier * feat(rules): implement "archive" rules * fix(rules): don't enforce name uniqueness on archiver rules * test(rules): add unit tests for archiver rule handling * fix(rules): do not assume default rule periodic archive settings * fix(rules): disallow configurations on archiver rules * fix(rules): archive rules have optional names * fix(rules): remove unnecessary check * fix(rules): only enforce toDisk=true if maxAge/maxSize set * chore(rules): apply spotless formatting * fix(rules): avoid NPE during deserialization * chore(rules): apply spotless formatting --- HTTP_API.md | 15 +- .../rules/MatchExpressionValidator.java | 6 +- src/main/java/io/cryostat/rules/Rule.java | 92 ++++-- .../java/io/cryostat/rules/RuleProcessor.java | 80 ++++-- .../java/io/cryostat/rules/RuleRegistry.java | 28 +- .../io/cryostat/rules/RuleProcessorTest.java | 61 ++++ .../io/cryostat/rules/RuleRegistryTest.java | 54 ++++ src/test/java/io/cryostat/rules/RuleTest.java | 261 +++++++++++++++++- 8 files changed, 528 insertions(+), 69 deletions(-) diff --git a/HTTP_API.md b/HTTP_API.md index 18cdd6a9a0..c1b9890e4e 100644 --- a/HTTP_API.md +++ b/HTTP_API.md @@ -1398,8 +1398,9 @@ The handler-specific descriptions below describe how each handler populates the attributes `"name"`, `"matchExpression"`, and `"eventSpecifier"` must be provided. - `"name"`: the name of this rule definition. This must be unique. This name - will also be used to generate the name of the associated recordings. + `"name"`: the name of this rule definition. This must be unique, except in + the case of "archiver rules" (see `eventSpecifier` below). This name will + also be used to generate the name of the associated recordings. `"matchExpression"`: a string expression used to determine which target JVMs this rule will apply to. The expression has a variable named `target` in @@ -1412,9 +1413,15 @@ The handler-specific descriptions below describe how each handler populates the The simple expression `true` may also be used to create a rule which applies to any and all discovered targets. - `"eventSpecifier"`: a string of the form `template=Foo,type=TYPE`. This + `"eventSpecifier"`: a string of the form `template=Foo,type=TYPE`, which defines the event template that will be used for creating new recordings in - matching targets. + matching targets; or, the special string `"archive"`, which signifies that + this rule should cause all matching targets to have their current (at the + time of rule creation) JFR data copied to the Cryostat archives as a + one-time operation. When using `"archive"`, it is invalid to provide + `archivalPeriodSeconds`, `preservedArchives`, `maxSizeBytes`, or + `maxAgeSeconds`. Such "archiver rules" are only processed once and are not + persisted, so the `name` and `description` become optional. The following attributes are optional: diff --git a/src/main/java/io/cryostat/rules/MatchExpressionValidator.java b/src/main/java/io/cryostat/rules/MatchExpressionValidator.java index 5d1a13b9ca..aa786e9027 100644 --- a/src/main/java/io/cryostat/rules/MatchExpressionValidator.java +++ b/src/main/java/io/cryostat/rules/MatchExpressionValidator.java @@ -49,7 +49,11 @@ public class MatchExpressionValidator { String validate(Rule rule) throws MatchExpressionValidationException { try { - CompilationUnitTree cut = parser.parse(rule.getName(), rule.getMatchExpression(), null); + String name = rule.getName(); + if (name == null) { + name = ""; + } + CompilationUnitTree cut = parser.parse(name, rule.getMatchExpression(), null); if (cut == null) { throw new IllegalMatchExpressionException(); } diff --git a/src/main/java/io/cryostat/rules/Rule.java b/src/main/java/io/cryostat/rules/Rule.java index 554eaea972..574fc49feb 100644 --- a/src/main/java/io/cryostat/rules/Rule.java +++ b/src/main/java/io/cryostat/rules/Rule.java @@ -39,6 +39,9 @@ import java.util.function.Function; +import io.cryostat.recordings.RecordingTargetHelper; + +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.vertx.core.MultiMap; import org.apache.commons.lang3.StringUtils; @@ -50,6 +53,8 @@ public class Rule { private static final MatchExpressionValidator MATCH_EXPRESSION_VALIDATOR = new MatchExpressionValidator(); + static final String ARCHIVE_EVENT = "archive"; + private final String name; private final String description; private final String matchExpression; @@ -60,10 +65,14 @@ public class Rule { private final int maxSizeBytes; Rule(Builder builder) throws MatchExpressionValidationException { - this.name = sanitizeRuleName(requireNonBlank(builder.name, Attribute.NAME)); + this.eventSpecifier = builder.eventSpecifier; + if (isArchiver()) { + this.name = builder.name; + } else { + this.name = sanitizeRuleName(requireNonBlank(builder.name, Attribute.NAME)); + } this.description = builder.description == null ? "" : builder.description; this.matchExpression = builder.matchExpression; - this.eventSpecifier = builder.eventSpecifier; this.archivalPeriodSeconds = builder.archivalPeriodSeconds; this.preservedArchives = builder.preservedArchives; this.maxAgeSeconds = @@ -93,6 +102,10 @@ public String getEventSpecifier() { return this.eventSpecifier; } + public boolean isArchiver() { + return ARCHIVE_EVENT.equals(getEventSpecifier()); + } + public int getArchivalPeriodSeconds() { return this.archivalPeriodSeconds; } @@ -114,10 +127,6 @@ public static String sanitizeRuleName(String name) { return name.replaceAll("\\s", "_"); } - static String validateMatchExpression(Rule rule) throws MatchExpressionValidationException { - return MATCH_EXPRESSION_VALIDATOR.validate(rule); - } - private static String requireNonBlank(String s, Attribute attr) { if (StringUtils.isBlank(s)) { throw new IllegalArgumentException( @@ -126,6 +135,28 @@ private static String requireNonBlank(String s, Attribute attr) { return s; } + public void validate() throws IllegalArgumentException, MatchExpressionValidationException { + requireNonBlank(this.matchExpression, Attribute.MATCH_EXPRESSION); + validateEventSpecifier(requireNonBlank(this.eventSpecifier, Attribute.EVENT_SPECIFIER)); + validateMatchExpression(this); + + if (isArchiver()) { + requireNonPositive(this.archivalPeriodSeconds, Attribute.ARCHIVAL_PERIOD_SECONDS); + requireNonPositive(this.preservedArchives, Attribute.PRESERVED_ARCHIVES); + requireNonPositive(this.maxSizeBytes, Attribute.MAX_SIZE_BYTES); + requireNonPositive(this.maxAgeSeconds, Attribute.MAX_AGE_SECONDS); + } else { + requireNonBlank(this.name, Attribute.NAME); + requireNonNegative(this.archivalPeriodSeconds, Attribute.ARCHIVAL_PERIOD_SECONDS); + requireNonNegative(this.preservedArchives, Attribute.PRESERVED_ARCHIVES); + } + } + + private static String validateMatchExpression(Rule rule) + throws MatchExpressionValidationException { + return MATCH_EXPRESSION_VALIDATOR.validate(rule); + } + private static int requireNonNegative(int i, Attribute attr) { if (i < 0) { throw new IllegalArgumentException( @@ -134,13 +165,22 @@ private static int requireNonNegative(int i, Attribute attr) { return i; } - public void validate() throws IllegalArgumentException, MatchExpressionValidationException { - requireNonBlank(this.name, Attribute.NAME); - requireNonBlank(this.matchExpression, Attribute.MATCH_EXPRESSION); - requireNonBlank(this.eventSpecifier, Attribute.EVENT_SPECIFIER); - requireNonNegative(this.archivalPeriodSeconds, Attribute.ARCHIVAL_PERIOD_SECONDS); - requireNonNegative(this.preservedArchives, Attribute.PRESERVED_ARCHIVES); - validateMatchExpression(this); + private static int requireNonPositive(int i, Attribute attr) { + if (i > 0) { + throw new IllegalArgumentException( + String.format("\"%s\" cannot be positive, was \"%d\"", attr, i)); + } + return i; + } + + private static String validateEventSpecifier(String eventSpecifier) + throws IllegalArgumentException { + if (eventSpecifier.equals(ARCHIVE_EVENT)) { + return eventSpecifier; + } + // throws if cannot be parsed + RecordingTargetHelper.parseEventSpecifierToTemplate(eventSpecifier); + return eventSpecifier; } @Override @@ -154,12 +194,12 @@ public int hashCode() { } public static class Builder { - private String name; - private String description; - private String matchExpression; - private String eventSpecifier; - private int archivalPeriodSeconds = 30; - private int preservedArchives = 1; + private String name = ""; + private String description = ""; + private String matchExpression = ""; + private String eventSpecifier = ""; + private int archivalPeriodSeconds = 0; + private int preservedArchives = 0; private int maxAgeSeconds = -1; private int maxSizeBytes = -1; @@ -231,13 +271,11 @@ public static Builder from(MultiMap formAttributes) { public static Builder from(JsonObject jsonObj) throws IllegalArgumentException { Rule.Builder builder = new Rule.Builder() - .name(jsonObj.get(Rule.Attribute.NAME.getSerialKey()).getAsString()) + .name(getAsNullableString(jsonObj, Rule.Attribute.NAME)) .matchExpression( jsonObj.get(Rule.Attribute.MATCH_EXPRESSION.getSerialKey()) .getAsString()) - .description( - jsonObj.get(Rule.Attribute.DESCRIPTION.getSerialKey()) - .getAsString()) + .description(getAsNullableString(jsonObj, Rule.Attribute.DESCRIPTION)) .eventSpecifier( jsonObj.get(Rule.Attribute.EVENT_SPECIFIER.getSerialKey()) .getAsString()); @@ -249,6 +287,14 @@ public static Builder from(JsonObject jsonObj) throws IllegalArgumentException { return builder; } + private static String getAsNullableString(JsonObject jsonObj, Rule.Attribute attr) { + JsonElement el = jsonObj.get(attr.getSerialKey()); + if (el == null) { + return null; + } + return el.getAsString(); + } + private Builder setOptionalInt(Rule.Attribute key, MultiMap formAttributes) throws IllegalArgumentException { diff --git a/src/main/java/io/cryostat/rules/RuleProcessor.java b/src/main/java/io/cryostat/rules/RuleProcessor.java index b433250a0c..25e3e1ee5a 100644 --- a/src/main/java/io/cryostat/rules/RuleProcessor.java +++ b/src/main/java/io/cryostat/rules/RuleProcessor.java @@ -47,6 +47,7 @@ import java.util.function.Consumer; import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; +import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; import io.cryostat.configuration.CredentialsManager; import io.cryostat.core.log.Logger; @@ -159,29 +160,38 @@ private void activate(Rule rule, ServiceRef serviceRef) { Credentials credentials = credentialsManager.getCredentials(serviceRef.getServiceUri().toString()); - try { - startRuleRecording(new ConnectionDescriptor(serviceRef, credentials), rule); - } catch (Exception e) { - logger.error(e); - } - logger.trace("Rule activation successful"); - if (rule.getPreservedArchives() <= 0 || rule.getArchivalPeriodSeconds() <= 0) { - return; + + if (rule.isArchiver()) { + try { + archiveRuleRecording(new ConnectionDescriptor(serviceRef, credentials), rule); + } catch (Exception e) { + logger.error(e); + } + } else { + try { + startRuleRecording(new ConnectionDescriptor(serviceRef, credentials), rule); + } catch (Exception e) { + logger.error(e); + } + + if (rule.getPreservedArchives() <= 0 || rule.getArchivalPeriodSeconds() <= 0) { + return; + } + tasks.put( + Pair.of(serviceRef, rule), + scheduler.scheduleAtFixedRate( + periodicArchiverFactory.create( + serviceRef, + credentialsManager, + rule, + recordingArchiveHelper, + this::archivalFailureHandler, + base32), + rule.getArchivalPeriodSeconds(), + rule.getArchivalPeriodSeconds(), + TimeUnit.SECONDS)); } - tasks.put( - Pair.of(serviceRef, rule), - scheduler.scheduleAtFixedRate( - periodicArchiverFactory.create( - serviceRef, - credentialsManager, - rule, - recordingArchiveHelper, - this::archivalFailureHandler, - base32), - rule.getArchivalPeriodSeconds(), - rule.getArchivalPeriodSeconds(), - TimeUnit.SECONDS)); } private void deactivate(Rule rule, ServiceRef serviceRef) { @@ -217,6 +227,25 @@ private Void archivalFailureHandler(Pair id) { return null; } + private void archiveRuleRecording(ConnectionDescriptor connectionDescriptor, Rule rule) + throws Exception { + targetConnectionManager.executeConnectedTask( + connectionDescriptor, + connection -> { + IRecordingDescriptor descriptor = + connection.getService().getSnapshotRecording(); + try { + recordingArchiveHelper + .saveRecording(connectionDescriptor, descriptor.getName()) + .get(); + } finally { + connection.getService().close(descriptor); + } + + return null; + }); + } + private void startRuleRecording(ConnectionDescriptor connectionDescriptor, Rule rule) throws Exception { @@ -226,16 +255,15 @@ private void startRuleRecording(ConnectionDescriptor connectionDescriptor, Rule RecordingOptionsBuilder builder = recordingOptionsBuilderFactory .create(connection.getService()) - .name(rule.getRecordingName()) - .toDisk(true); + .name(rule.getRecordingName()); if (rule.getMaxAgeSeconds() > 0) { - builder = builder.maxAge(rule.getMaxAgeSeconds()); + builder = builder.maxAge(rule.getMaxAgeSeconds()).toDisk(true); } if (rule.getMaxSizeBytes() > 0) { - builder = builder.maxSize(rule.getMaxSizeBytes()); + builder = builder.maxSize(rule.getMaxSizeBytes()).toDisk(true); } Pair template = - recordingTargetHelper.parseEventSpecifierToTemplate( + RecordingTargetHelper.parseEventSpecifierToTemplate( rule.getEventSpecifier()); recordingTargetHelper.startRecording( true, diff --git a/src/main/java/io/cryostat/rules/RuleRegistry.java b/src/main/java/io/cryostat/rules/RuleRegistry.java index 8001668d3c..a188bb7282 100644 --- a/src/main/java/io/cryostat/rules/RuleRegistry.java +++ b/src/main/java/io/cryostat/rules/RuleRegistry.java @@ -94,20 +94,22 @@ public void loadRules() throws IOException { } public Rule addRule(Rule rule) throws IOException { - if (hasRuleByName(rule.getName())) { - throw new RuleException( - String.format( - "Rule with name \"%s\" already exists; refusing to overwrite", - rule.getName())); + if (!rule.isArchiver()) { + if (hasRuleByName(rule.getName())) { + throw new RuleException( + String.format( + "Rule with name \"%s\" already exists; refusing to overwrite", + rule.getName())); + } + Path destination = rulesDir.resolve(rule.getName() + ".json"); + this.fs.writeString( + destination, + gson.toJson(rule), + StandardOpenOption.WRITE, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + loadRules(); } - Path destination = rulesDir.resolve(rule.getName() + ".json"); - this.fs.writeString( - destination, - gson.toJson(rule), - StandardOpenOption.WRITE, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING); - loadRules(); emit(RuleEvent.ADDED, rule); return rule; } diff --git a/src/test/java/io/cryostat/rules/RuleProcessorTest.java b/src/test/java/io/cryostat/rules/RuleProcessorTest.java index d508680342..88405f120f 100644 --- a/src/test/java/io/cryostat/rules/RuleProcessorTest.java +++ b/src/test/java/io/cryostat/rules/RuleProcessorTest.java @@ -39,6 +39,7 @@ import java.net.URI; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -47,6 +48,7 @@ import org.openjdk.jmc.common.unit.IConstrainedMap; import org.openjdk.jmc.flightrecorder.configuration.recording.RecordingOptionsBuilder; import org.openjdk.jmc.rjmx.services.jfr.IFlightRecorderService; +import org.openjdk.jmc.rjmx.services.jfr.IRecordingDescriptor; import io.cryostat.configuration.CredentialsManager; import io.cryostat.core.log.Logger; @@ -72,6 +74,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @@ -234,6 +237,64 @@ void testSuccessfulRuleActivationWithCredentials() throws Exception { Mockito.verify(scheduler).scheduleAtFixedRate(periodicArchiver, 67, 67, TimeUnit.SECONDS); } + @Test + void testSuccessfulArchiverRuleActivationWithCredentials() throws Exception { + Mockito.when(targetConnectionManager.executeConnectedTask(Mockito.any(), Mockito.any())) + .thenAnswer( + arg0 -> + ((TargetConnectionManager.ConnectedTask) + arg0.getArgument(1)) + .execute(connection)); + Mockito.when(connection.getService()).thenReturn(service); + + IRecordingDescriptor snapshot = Mockito.mock(IRecordingDescriptor.class); + Mockito.when(snapshot.getName()).thenReturn("Snapshot-1"); + Mockito.when(service.getSnapshotRecording()).thenReturn(snapshot); + + String jmxUrl = "service:jmx:rmi://localhost:9091/jndi/rmi://fooHost:9091/jmxrmi"; + ServiceRef serviceRef = new ServiceRef(new URI(jmxUrl), "com.example.App"); + + Credentials credentials = new Credentials("foouser", "barpassword"); + Mockito.when(credentialsManager.getCredentials(jmxUrl)).thenReturn(credentials); + + TargetDiscoveryEvent tde = new TargetDiscoveryEvent(EventKind.FOUND, serviceRef); + + Rule rule = + new Rule.Builder() + .name("Test Rule") + .description("Automated unit test rule") + .matchExpression("target.alias == 'com.example.App'") + .eventSpecifier("archive") + .build(); + + Mockito.when(registry.getRules(serviceRef)).thenReturn(Set.of(rule)); + + Mockito.when(recordingArchiveHelper.saveRecording(Mockito.any(), Mockito.any())) + .thenReturn(CompletableFuture.completedFuture("unusedPath")); + + processor.accept(tde); + + ArgumentCaptor connectionDescriptorCaptor = + ArgumentCaptor.forClass(ConnectionDescriptor.class); + ArgumentCaptor recordingSaveNameCaptor = ArgumentCaptor.forClass(String.class); + + InOrder inOrder = Mockito.inOrder(service, recordingArchiveHelper); + inOrder.verify(service).getSnapshotRecording(); + inOrder.verify(recordingArchiveHelper) + .saveRecording( + connectionDescriptorCaptor.capture(), recordingSaveNameCaptor.capture()); + inOrder.verify(service).close(snapshot); + + ConnectionDescriptor connectionDescriptor = connectionDescriptorCaptor.getValue(); + MatcherAssert.assertThat( + connectionDescriptor.getTargetId(), + Matchers.equalTo(serviceRef.getServiceUri().toString())); + MatcherAssert.assertThat( + connectionDescriptor.getCredentials().get(), Matchers.equalTo(credentials)); + MatcherAssert.assertThat( + recordingSaveNameCaptor.getValue(), Matchers.equalTo(snapshot.getName())); + } + @Test void testTaskCancellationOnFailure() throws Exception { String jmxUrl = "service:jmx:rmi://localhost:9091/jndi/rmi://fooHost:9091/jmxrmi"; diff --git a/src/test/java/io/cryostat/rules/RuleRegistryTest.java b/src/test/java/io/cryostat/rules/RuleRegistryTest.java index bed7eea111..f41f98bd07 100644 --- a/src/test/java/io/cryostat/rules/RuleRegistryTest.java +++ b/src/test/java/io/cryostat/rules/RuleRegistryTest.java @@ -45,11 +45,15 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import io.cryostat.MainModule; import io.cryostat.core.log.Logger; import io.cryostat.core.sys.FileSystem; import io.cryostat.platform.ServiceRef; +import io.cryostat.rules.RuleRegistry.RuleEvent; +import io.cryostat.util.events.Event; import com.google.gson.Gson; import org.hamcrest.MatcherAssert; @@ -128,6 +132,9 @@ void testAddRule() throws Exception { Mockito.when(fs.listDirectoryChildren(rulesDir)).thenReturn(List.of("test_rule.json")); Mockito.when(fs.readFile(rulePath)).thenReturn(fileReader); + CompletableFuture> eventListener = new CompletableFuture<>(); + registry.addListener(eventListener::complete); + registry.addRule(testRule); InOrder inOrder = Mockito.inOrder(gson, fs); @@ -141,6 +148,10 @@ void testAddRule() throws Exception { StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); inOrder.verify(fs).listDirectoryChildren(rulesDir); + + Event event = eventListener.get(1, TimeUnit.SECONDS); + MatcherAssert.assertThat(event.getEventType(), Matchers.equalTo(RuleEvent.ADDED)); + MatcherAssert.assertThat(event.getPayload(), Matchers.sameInstance(testRule)); } @Test @@ -171,6 +182,32 @@ void testAddRuleThrowsExceptionOnDuplicateName() throws Exception { Assertions.assertThrows(IOException.class, () -> registry.addRule(testRule)); } + @Test + void testAddRuleAllowsDuplicateNameOnArchivers() throws Exception { + Path rulePath = Mockito.mock(Path.class); + Mockito.when(rulesDir.resolve(Mockito.anyString())).thenReturn(rulePath); + Mockito.when(fs.listDirectoryChildren(rulesDir)).thenReturn(List.of("test_rule.json")); + Mockito.when(fs.readFile(rulePath)).thenReturn(fileReader); + + registry.loadRules(); + + Rule archiver = + new Rule.Builder() + .name(testRule.getName()) + .matchExpression(testRule.getMatchExpression()) + .description(testRule.getDescription()) + .eventSpecifier("archive") + .build(); + + CompletableFuture> eventListener = new CompletableFuture<>(); + registry.addListener(eventListener::complete); + + Assertions.assertDoesNotThrow(() -> registry.addRule(archiver)); + Event event = eventListener.get(1, TimeUnit.SECONDS); + MatcherAssert.assertThat(event.getEventType(), Matchers.equalTo(RuleEvent.ADDED)); + MatcherAssert.assertThat(event.getPayload(), Matchers.sameInstance(archiver)); + } + @Test void testGetRulebyName() throws Exception { Path rulePath = Mockito.mock(Path.class); @@ -211,6 +248,23 @@ void testGetRulesByServiceRef() throws Exception { Matchers.equalTo(Set.of(testRule))); } + @Test + void testGetRulesByServiceRefIgnoresArchivers() throws Exception { + Rule archiverRule = + new Rule.Builder() + .name(testRule.getName()) + .matchExpression(testRule.getMatchExpression()) + .description(testRule.getDescription()) + .eventSpecifier("archive") + .build(); + + registry.addRule(archiverRule); + + MatcherAssert.assertThat( + registry.getRules(new ServiceRef(null, "com.example.App")), + Matchers.equalTo(Set.of())); + } + @Test void testGetRulesReturnsCopy() throws Exception { Path rulePath = Mockito.mock(Path.class); diff --git a/src/test/java/io/cryostat/rules/RuleTest.java b/src/test/java/io/cryostat/rules/RuleTest.java index 6128ef3878..422fa11c07 100644 --- a/src/test/java/io/cryostat/rules/RuleTest.java +++ b/src/test/java/io/cryostat/rules/RuleTest.java @@ -37,10 +37,13 @@ */ package io.cryostat.rules; +import com.google.gson.JsonObject; +import io.vertx.core.MultiMap; import org.hamcrest.MatcherAssert; import org.hamcrest.Matchers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -123,7 +126,8 @@ void shouldThrowOnInvalidMatchExpression(String s) { @ParameterizedTest @NullAndEmptySource - void shouldThrowOnBlankEventSpecifier(String s) { + @ValueSource(strings = {"invalid", "events=incorrect", "tenplate=typo"}) + void shouldThrowOnInvalidEventSpecifier(String s) { IllegalArgumentException ex = Assertions.assertThrows( IllegalArgumentException.class, @@ -135,7 +139,10 @@ void shouldThrowOnBlankEventSpecifier(String s) { }); MatcherAssert.assertThat( ex.getMessage(), - Matchers.containsString("\"eventSpecifier\" cannot be blank, was \"" + s + "\"")); + Matchers.either( + Matchers.equalTo( + "\"eventSpecifier\" cannot be blank, was \"" + s + "\"")) + .or(Matchers.equalTo(s))); } @Test @@ -202,4 +209,254 @@ void shouldSanitizeRecordingNameAndMarkAsAutomatic() throws Exception { .build(); MatcherAssert.assertThat(rule.getRecordingName(), Matchers.equalTo("auto_Some_Rule")); } + + @Test + void shouldAcceptEventSpecifierArchiveSpecialCase() throws Exception { + Rule rule = builder.matchExpression(MATCH_EXPRESSION).eventSpecifier("archive").build(); + MatcherAssert.assertThat(rule.getEventSpecifier(), Matchers.equalTo("archive")); + } + + @Test + void shouldAcceptEventSpecifierArchiveSpecialCaseWithName() throws Exception { + Rule rule = + builder.name("Some Rule") + .matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .build(); + MatcherAssert.assertThat(rule.getEventSpecifier(), Matchers.equalTo("archive")); + } + + @Test + void shouldAcceptEventSpecifierArchiveSpecialCaseWithDescription() throws Exception { + Rule rule = + builder.description("Unused description") + .matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .build(); + MatcherAssert.assertThat(rule.getEventSpecifier(), Matchers.equalTo("archive")); + } + + @Test + void shouldThrowOnArchiverWithArchivalPeriod() throws Exception { + IllegalArgumentException ex = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + builder.matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .archivalPeriodSeconds(5) + .build(); + }); + MatcherAssert.assertThat( + ex.getMessage(), + Matchers.containsString("\"archivalPeriodSeconds\" cannot be positive, was \"5\"")); + } + + @Test + void shouldThrowOnArchiverWithPreservedArchives() throws Exception { + IllegalArgumentException ex = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + builder.matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .preservedArchives(5) + .build(); + }); + MatcherAssert.assertThat( + ex.getMessage(), + Matchers.containsString("\"preservedArchives\" cannot be positive, was \"5\"")); + } + + @Test + void shouldThrowOnArchiverWithMaxSizeBytes() throws Exception { + IllegalArgumentException ex = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + builder.matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .maxSizeBytes(5) + .build(); + }); + MatcherAssert.assertThat( + ex.getMessage(), + Matchers.containsString("\"maxSizeBytes\" cannot be positive, was \"5\"")); + } + + @Test + void shouldThrowOnArchiverWithMaxAgeSeconds() throws Exception { + IllegalArgumentException ex = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + builder.matchExpression(MATCH_EXPRESSION) + .eventSpecifier("archive") + .maxAgeSeconds(5) + .build(); + }); + MatcherAssert.assertThat( + ex.getMessage(), + Matchers.containsString("\"maxAgeSeconds\" cannot be positive, was \"5\"")); + } + + @Nested + class Deserialization { + + @Nested + class Json { + + @Test + void testCompleteRule() + throws IllegalArgumentException, MatchExpressionValidationException { + String name = "Some Rule"; + String description = "This is a description"; + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "template=Foo"; + int maxAgeSeconds = 60; + int maxSizeBytes = 32 * 1024; + int archivalPeriodSeconds = 300; + int preservedArchives = 5; + + JsonObject json = new JsonObject(); + json.addProperty("name", name); + json.addProperty("description", description); + json.addProperty("matchExpression", matchExpression); + json.addProperty("eventSpecifier", eventSpecifier); + json.addProperty("maxAgeSeconds", maxAgeSeconds); + json.addProperty("maxSizeBytes", maxSizeBytes); + json.addProperty("archivalPeriodSeconds", archivalPeriodSeconds); + json.addProperty("preservedArchives", preservedArchives); + Rule rule = Rule.Builder.from(json).build(); + + MatcherAssert.assertThat(rule.getName(), Matchers.equalTo("Some_Rule")); + MatcherAssert.assertThat(rule.getDescription(), Matchers.equalTo(description)); + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + MatcherAssert.assertThat(rule.getMaxAgeSeconds(), Matchers.equalTo(maxAgeSeconds)); + MatcherAssert.assertThat(rule.getMaxSizeBytes(), Matchers.equalTo(maxSizeBytes)); + MatcherAssert.assertThat( + rule.getArchivalPeriodSeconds(), Matchers.equalTo(archivalPeriodSeconds)); + MatcherAssert.assertThat( + rule.getPreservedArchives(), Matchers.equalTo(preservedArchives)); + } + + @Test + void testRuleWithoutOptionalFields() + throws IllegalArgumentException, MatchExpressionValidationException { + String name = "Some Rule"; + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "template=Foo"; + + JsonObject json = new JsonObject(); + json.addProperty("name", name); + json.addProperty("matchExpression", matchExpression); + json.addProperty("eventSpecifier", eventSpecifier); + Rule rule = Rule.Builder.from(json).build(); + + MatcherAssert.assertThat(rule.getName(), Matchers.equalTo("Some_Rule")); + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + } + + @Test + void testArchiverWithoutName() + throws IllegalArgumentException, MatchExpressionValidationException { + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "archive"; + + JsonObject json = new JsonObject(); + json.addProperty("matchExpression", matchExpression); + json.addProperty("eventSpecifier", eventSpecifier); + Rule rule = Rule.Builder.from(json).build(); + + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + } + } + + @Nested + class Form { + + @Test + void testCompleteRule() + throws IllegalArgumentException, MatchExpressionValidationException { + String name = "Some Rule"; + String description = "This is a description"; + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "template=Foo"; + int maxAgeSeconds = 60; + int maxSizeBytes = 32 * 1024; + int archivalPeriodSeconds = 300; + int preservedArchives = 5; + + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.set("name", name); + form.set("description", description); + form.set("matchExpression", matchExpression); + form.set("eventSpecifier", eventSpecifier); + form.set("maxAgeSeconds", String.valueOf(maxAgeSeconds)); + form.set("maxSizeBytes", String.valueOf(maxSizeBytes)); + form.set("archivalPeriodSeconds", String.valueOf(archivalPeriodSeconds)); + form.set("preservedArchives", String.valueOf(preservedArchives)); + Rule rule = Rule.Builder.from(form).build(); + + MatcherAssert.assertThat(rule.getName(), Matchers.equalTo("Some_Rule")); + MatcherAssert.assertThat(rule.getDescription(), Matchers.equalTo(description)); + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + MatcherAssert.assertThat(rule.getMaxAgeSeconds(), Matchers.equalTo(maxAgeSeconds)); + MatcherAssert.assertThat(rule.getMaxSizeBytes(), Matchers.equalTo(maxSizeBytes)); + MatcherAssert.assertThat( + rule.getArchivalPeriodSeconds(), Matchers.equalTo(archivalPeriodSeconds)); + MatcherAssert.assertThat( + rule.getPreservedArchives(), Matchers.equalTo(preservedArchives)); + } + + @Test + void testRuleWithoutOptionalFields() + throws IllegalArgumentException, MatchExpressionValidationException { + String name = "Some Rule"; + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "template=Foo"; + + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.set("name", name); + form.set("matchExpression", matchExpression); + form.set("eventSpecifier", eventSpecifier); + Rule rule = Rule.Builder.from(form).build(); + + MatcherAssert.assertThat(rule.getName(), Matchers.equalTo("Some_Rule")); + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + } + + @Test + void testArchiverWithoutName() + throws IllegalArgumentException, MatchExpressionValidationException { + String matchExpression = "target.alias=='TheAlias'"; + String eventSpecifier = "archive"; + + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.set("matchExpression", matchExpression); + form.set("eventSpecifier", eventSpecifier); + Rule rule = Rule.Builder.from(form).build(); + + MatcherAssert.assertThat( + rule.getMatchExpression(), Matchers.equalTo(matchExpression)); + MatcherAssert.assertThat( + rule.getEventSpecifier(), Matchers.equalTo(eventSpecifier)); + } + } + } }