Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,38 @@ public Config hazelcastConfig() {
return config;
}
```

### Sharing notification filters

The Hazelcast map `spring-boot-admin-notification-filters` (configurable via `spring.boot.admin.hazelcast.notification-filters`) is available as a bean named `notificationFiltersMap`. To share filters across the cluster, you pass it to the constructor of your own `FilteringNotifier`. This allows filter definitions to be replicated and kept in sync across all cluster nodes.

```java
@Bean
@Primary
public FilteringNotifier filteringNotifier(@Qualifier("mailNotifier") Notifier delegate,
InstanceRepository repository,
@Qualifier("notificationFiltersMap") ConcurrentMap<String, NotificationFilter> filters) {
return new FilteringNotifier(delegate, repository, filters);
}
```

:::warning
A `FilteringNotifier` that wraps another notifier can end up in a circular dependency with the `CompositeNotifier`.
The `CompositeNotifier` is created when more than one `Notifier` bean exists and none of them is `@Primary`. It
collects every `Notifier` bean, including your `FilteringNotifier`. If the `FilteringNotifier` receives its delegate
as a plain `Notifier` parameter, Spring resolves that parameter to the `CompositeNotifier`, and the application
fails to start with a `BeanCurrentlyInCreationException`.

To avoid this, declare the `FilteringNotifier` as `@Primary` and inject the notifier it wraps by name with
`@Qualifier` (or by its concrete type), as in the example above.
:::

```java
// Do not do this: the plain Notifier parameter resolves to the CompositeNotifier,
// which in turn needs this FilteringNotifier.
@Bean
public FilteringNotifier filteringNotifier(Notifier delegate, InstanceRepository repository,
@Qualifier("notificationFiltersMap") ConcurrentMap<String, NotificationFilter> filters) {
return new FilteringNotifier(delegate, repository, filters);
}
```
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2023 the original author or authors.
* Copyright 2014-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -20,7 +20,6 @@

import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.map.IMap;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
Expand All @@ -33,14 +32,10 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.eventstore.HazelcastEventStore;
import de.codecentric.boot.admin.server.eventstore.InstanceEventStore;
import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger;
import de.codecentric.boot.admin.server.notify.NotificationTrigger;
import de.codecentric.boot.admin.server.notify.Notifier;
import de.codecentric.boot.admin.server.notify.filter.FilteringNotifier;
import de.codecentric.boot.admin.server.notify.filter.NotificationFilter;

Expand All @@ -59,6 +54,10 @@ public class AdminServerHazelcastAutoConfiguration {

public static final String DEFAULT_NAME_NOTIFICATION_FILTERS_MAP = "spring-boot-admin-notification-filters";

public static final String SENT_NOTIFICATIONS_BEAN_NAME = "sentNotificationsMap";

public static final String NOTIFICATION_FILTERS_BEAN_NAME = "notificationFiltersMap";

@Value("${spring.boot.admin.hazelcast.event-store:" + DEFAULT_NAME_EVENT_STORE_MAP + "}")
private final String nameEventStoreMap = DEFAULT_NAME_EVENT_STORE_MAP;

Expand All @@ -72,39 +71,40 @@ public HazelcastEventStore eventStore(HazelcastInstance hazelcastInstance,
eventStoreProperties.isPruneInfoUpdatedEvents());
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(Notifier.class)
public static class NotifierTriggerConfiguration {

@Value("${spring.boot.admin.hazelcast.sent-notifications:" + DEFAULT_NAME_SENT_NOTIFICATIONS_MAP + "}")
private final String nameSentNotificationsMap = DEFAULT_NAME_SENT_NOTIFICATIONS_MAP;

@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnMissingBean(NotificationTrigger.class)
public NotificationTrigger notificationTrigger(HazelcastInstance hazelcastInstance, Notifier notifier,
Publisher<InstanceEvent> events) {
return new HazelcastNotificationTrigger(notifier, events,
hazelcastInstance.getMap(this.nameSentNotificationsMap));
}

/**
* Distributed map used to deduplicate notifications across the cluster. It is exposed
* as a bean because {@link AdminServerNotifierAutoConfiguration} must not reference
* Hazelcast classes, as Hazelcast is optional. It obtains the map as a plain
* {@code ConcurrentMap} through an {@code ObjectProvider}, which is resolved lazily
* when the notification trigger is created. The result therefore does not depend on
* the order in which the auto-configurations are evaluated.
* @param hazelcastInstance the Hazelcast instance
* @param nameSentNotificationsMap the name of the backing map
* @return the map holding the version of the last event a notification was sent for
*/
@Bean(name = SENT_NOTIFICATIONS_BEAN_NAME)
@Lazy
@ConditionalOnMissingBean(name = SENT_NOTIFICATIONS_BEAN_NAME)
public IMap<InstanceId, Long> sentNotificationsMap(HazelcastInstance hazelcastInstance,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like for the other maps already present, I don't think it's necessary to have them declared as beans explicitly.

@Value("${spring.boot.admin.hazelcast.sent-notifications:" + DEFAULT_NAME_SENT_NOTIFICATIONS_MAP
+ "}") String nameSentNotificationsMap) {
return hazelcastInstance.getMap(nameSentNotificationsMap);
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(Notifier.class)
@AutoConfigureBefore({ AdminServerNotifierAutoConfiguration.NotifierTriggerConfiguration.class })
public static class NotificationFilterConfiguration {

@Value("${spring.boot.admin.hazelcast.notification-filters:" + DEFAULT_NAME_NOTIFICATION_FILTERS_MAP + "}")
private final String nameNotificationFiltersMap = DEFAULT_NAME_NOTIFICATION_FILTERS_MAP;

@Bean
@ConditionalOnMissingBean(FilteringNotifier.class)
public FilteringNotifier filteringNotifier(HazelcastInstance hazelcastInstance, Notifier delegate,
InstanceRepository repository) {
IMap<String, NotificationFilter> filtersMap = hazelcastInstance.getMap(this.nameNotificationFiltersMap);
return new FilteringNotifier(delegate, repository, filtersMap);
}

/**
* Distributed map holding the notification filters, so that filters are shared across
* the cluster. Pass it to a {@link FilteringNotifier} declared in your application.
* @param hazelcastInstance the Hazelcast instance
* @param nameNotificationFiltersMap the name of the backing map
* @return the map holding the notification filters
*/
@Bean(name = NOTIFICATION_FILTERS_BEAN_NAME)
@Lazy
@ConditionalOnMissingBean(name = NOTIFICATION_FILTERS_BEAN_NAME)
public IMap<String, NotificationFilter> notificationFiltersMap(HazelcastInstance hazelcastInstance,
@Value("${spring.boot.admin.hazelcast.notification-filters:" + DEFAULT_NAME_NOTIFICATION_FILTERS_MAP
+ "}") String nameNotificationFiltersMap) {
return hazelcastInstance.getMap(nameNotificationFiltersMap);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.ConcurrentMap;

import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.Credentials;
Expand All @@ -26,6 +27,8 @@
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.core5.http.HttpHost;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
Expand All @@ -52,10 +55,12 @@

import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.domain.events.InstanceEvent;
import de.codecentric.boot.admin.server.domain.values.InstanceId;
import de.codecentric.boot.admin.server.notify.CompositeNotifier;
import de.codecentric.boot.admin.server.notify.DingTalkNotifier;
import de.codecentric.boot.admin.server.notify.DiscordNotifier;
import de.codecentric.boot.admin.server.notify.FeiShuNotifier;
import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger;
import de.codecentric.boot.admin.server.notify.HipchatNotifier;
import de.codecentric.boot.admin.server.notify.LetsChatNotifier;
import de.codecentric.boot.admin.server.notify.MailNotifier;
Expand Down Expand Up @@ -107,7 +112,12 @@ public static class NotifierTriggerConfiguration {

@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnMissingBean(NotificationTrigger.class)
public NotificationTrigger notificationTrigger(Notifier notifier, Publisher<InstanceEvent> events) {
public NotificationTrigger notificationTrigger(Notifier notifier, Publisher<InstanceEvent> events,
@Qualifier(AdminServerHazelcastAutoConfiguration.SENT_NOTIFICATIONS_BEAN_NAME) ObjectProvider<ConcurrentMap<InstanceId, Long>> sentNotifications) {
ConcurrentMap<InstanceId, Long> sentNotificationsMap = sentNotifications.getIfAvailable();
if (sentNotificationsMap != null) {
return new HazelcastNotificationTrigger(notifier, events, sentNotificationsMap);
}
return new NotificationTrigger(notifier, events);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class AdminServerAutoConfigurationTest {
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ReactiveHttpClientAutoConfiguration.class,
WebClientAutoConfiguration.class, HazelcastAutoConfiguration.class, WebMvcAutoConfiguration.class,
AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class))
AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class,
AdminServerNotifierAutoConfiguration.class))
.withUserConfiguration(AdminServerMarkerConfiguration.class);

@Test
Expand All @@ -68,9 +69,8 @@ void hazelcastConfig() {
this.contextRunner.withUserConfiguration(TestHazelcastConfig.class).run((context) -> {
assertThat(context).getBean(InstanceEventStore.class).isInstanceOf(HazelcastEventStore.class);
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
assertThat(context).hasSingleBean(FilteringNotifier.class);
FilteringNotifier filteringNotifier = context.getBean(FilteringNotifier.class);
assertThat(filteringNotifier).extracting("filters").isInstanceOf(IMap.class);
assertThat(context).doesNotHaveBean(FilteringNotifier.class);
assertThat(context).getBean("notificationFiltersMap").isInstanceOf(IMap.class);
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright 2014-2026 the original author or 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
*
* https://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 de.codecentric.boot.admin.server.config;

import java.util.concurrent.ConcurrentMap;

import com.hazelcast.config.Config;
import com.hazelcast.map.IMap;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.hazelcast.autoconfigure.HazelcastAutoConfiguration;
import org.springframework.boot.http.client.autoconfigure.reactive.ReactiveHttpClientAutoConfiguration;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;

import de.codecentric.boot.admin.server.domain.entities.InstanceRepository;
import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger;
import de.codecentric.boot.admin.server.notify.NotificationTrigger;
import de.codecentric.boot.admin.server.notify.Notifier;
import de.codecentric.boot.admin.server.notify.filter.FilteringNotifier;
import de.codecentric.boot.admin.server.notify.filter.NotificationFilter;

import static org.assertj.core.api.Assertions.assertThat;

class AdminServerHazelcastAutoConfigurationTest {

private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ReactiveHttpClientAutoConfiguration.class,
WebClientAutoConfiguration.class, HazelcastAutoConfiguration.class, WebMvcAutoConfiguration.class,
AdminServerHazelcastAutoConfiguration.class, AdminServerAutoConfiguration.class,
AdminServerNotifierAutoConfiguration.class))
.withUserConfiguration(AdminServerMarkerConfiguration.class, HazelcastOnlyConfig.class);

@Test
void should_use_hazelcast_trigger_for_property_configured_notifier() {
this.contextRunner.withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com")
.run((context) -> {
assertThat(context).hasSingleBean(NotificationTrigger.class);
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
});
}

@Test
void should_use_hazelcast_trigger_for_multiple_property_configured_notifiers() {
this.contextRunner
.withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com",
"spring.boot.admin.notify.discord.webhook-url:https://example.com")
.run((context) -> {
assertThat(context).hasSingleBean(NotificationTrigger.class);
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
});
}

@Test
void should_use_plain_trigger_when_hazelcast_is_disabled() {
this.contextRunner
.withPropertyValues("spring.boot.admin.hazelcast.enabled:false",
"spring.boot.admin.notify.slack.webhook-url:https://example.com")
.run((context) -> {
assertThat(context).hasSingleBean(NotificationTrigger.class);
assertThat(context).getBean(NotificationTrigger.class)
.isNotInstanceOf(HazelcastNotificationTrigger.class);
});
}

@Test
void should_start_and_use_hazelcast_trigger_with_single_user_defined_notifier() {
this.contextRunner.withUserConfiguration(UserNotifierConfig.class).run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(NotificationTrigger.class);
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
assertThat(context).doesNotHaveBean(FilteringNotifier.class);
});
}

@Test
void should_share_filters_map_with_user_defined_filtering_notifier() {
this.contextRunner.withUserConfiguration(UserFilteringNotifierConfig.class).run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
assertThat(context.getBean(FilteringNotifier.class)).extracting("filters").isInstanceOf(IMap.class);
});
}

@Test
void should_start_with_primary_filtering_notifier_wrapping_property_configured_notifier() {
this.contextRunner.withUserConfiguration(PrimaryFilteringNotifierConfig.class)
.withPropertyValues("spring.boot.admin.notify.slack.webhook-url:https://example.com")
.run((context) -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(FilteringNotifier.class);
assertThat(context).getBean(NotificationTrigger.class).isInstanceOf(HazelcastNotificationTrigger.class);
});
}

public static class HazelcastOnlyConfig {

@Bean
public Config config() {
return new Config();
}

}

public static class UserNotifierConfig {

@Bean
public Notifier notifier() {
return (event) -> Mono.empty();
}

}

public static class UserFilteringNotifierConfig {

@Bean
public Notifier delegateNotifier() {
return (event) -> Mono.empty();
}

@Bean
@Primary
public FilteringNotifier filteringNotifier(@Qualifier("delegateNotifier") Notifier delegate,
InstanceRepository repository,
@Qualifier("notificationFiltersMap") ConcurrentMap<String, NotificationFilter> filters) {
return new FilteringNotifier(delegate, repository, filters);
}

}

public static class PrimaryFilteringNotifierConfig {

@Bean
@Primary
public FilteringNotifier filteringNotifier(@Qualifier("slackNotifier") Notifier delegate,
InstanceRepository repository,
@Qualifier("notificationFiltersMap") ConcurrentMap<String, NotificationFilter> filters) {
return new FilteringNotifier(delegate, repository, filters);
}

}

}