From 4a8e60764f758aa679d3dbd9bdc686afe2e0db24 Mon Sep 17 00:00:00 2001 From: Vasil Ilchev Date: Wed, 30 Jul 2025 16:58:00 +0300 Subject: [PATCH] Remote Events migrated from Spring Bus to Spring Cloud Stream (#2563) * Remote Events migrated from Spring Bus to Spring Cloud Stream --------- Co-authored-by: vasilchev --- .../EventPublisherAutoConfiguration.java | 2 +- .../resource/DdiConfirmationBaseTest.java | 4 +- .../src/test/resources/ddi-test.properties | 4 +- hawkbit-ddi/hawkbit-ddi-server/README.md | 37 +--- .../src/main/resources/application.properties | 14 +- .../amqp/AmqpMessageDispatcherService.java | 87 ++++---- .../hawkbit/amqp/DmfApiConfiguration.java | 11 +- .../AmqpMessageDispatcherServiceTest.java | 35 ++- ...pMessageHandlerServiceIntegrationTest.java | 40 ++-- .../test/AbstractAmqpIntegrationTest.java | 4 +- hawkbit-dmf/hawkbit-dmf-server/README.md | 34 +-- .../src/main/resources/application.properties | 13 +- .../resource/MgmtTargetTagResourceTest.java | 4 +- .../src/test/resources/mgmt-test.properties | 4 +- hawkbit-mgmt/hawkbit-mgmt-server/README.md | 56 +++-- .../src/main/resources/application.properties | 17 +- .../hawkbit-update-server/README.md | 33 --- .../src/main/resources/application.properties | 10 +- .../hawkbit-repository-api/pom.xml | 2 +- .../event/EventPublisherHolder.java | 203 ++++++++++++++---- .../event/remote/AbstractRemoteEvent.java | 36 ++++ .../event/remote/RemoteTenantAwareEvent.java | 17 +- .../service/AbstractServiceRemoteEvent.java | 25 +++ .../CancelTargetAssignmentServiceEvent.java | 31 +++ .../MultiActionAssignServiceEvent.java | 31 +++ .../MultiActionCancelServiceEvent.java | 31 +++ ...rgetAssignDistributionSetServiceEvent.java | 35 +++ ...TargetAttributesRequestedServiceEvent.java | 31 +++ .../service/TargetCreatedServiceEvent.java | 31 +++ .../service/TargetDeletedServiceEvent.java | 31 +++ .../event/EventJacksonMessageConverter.java | 24 +++ ...a => EventProtoStuffMessageConverter.java} | 8 +- .../event/EventPublisherConfiguration.java | 84 ++++---- .../org/eclipse/hawkbit/event/EventType.java | 24 +++ .../hawkbit-eventbus-defaults.properties | 21 -- .../hawkbit-events-defaults.properties | 47 ++++ .../EventJacksonMessageConverterTest.java | 77 +++++++ ... EventProtoStuffMessageConverterTest.java} | 8 +- .../JpaTenantConfigurationManagement.java | 23 +- .../repository/jpa/model/JpaAction.java | 1 - .../event/remote/AbstractRemoteEventTest.java | 58 ++--- .../event/remote/ServiceEventsTest.java | 150 +++++++++++++ .../src/test/resources/jpa-test.properties | 5 +- .../repository/test/TestConfiguration.java | 14 +- .../test/matcher/EventVerifier.java | 72 ++++++- .../test/util/AbstractIntegrationTest.java | 7 +- .../hawkbit-test-defaults.properties | 5 +- site/content/guides/clustering.md | 67 +++++- .../static/images/eventing-within-cluster.png | Bin 16299 -> 0 bytes 49 files changed, 1147 insertions(+), 461 deletions(-) create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/AbstractServiceRemoteEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/CancelTargetAssignmentServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionAssignServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionCancelServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAssignDistributionSetServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAttributesRequestedServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetCreatedServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetDeletedServiceEvent.java create mode 100644 hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventJacksonMessageConverter.java rename hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/{BusProtoStuffMessageConverter.java => EventProtoStuffMessageConverter.java} (95%) delete mode 100644 hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-eventbus-defaults.properties create mode 100644 hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-events-defaults.properties create mode 100644 hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventJacksonMessageConverterTest.java rename hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/{BusProtoStuffMessageConverterTest.java => EventProtoStuffMessageConverterTest.java} (92%) create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/ServiceEventsTest.java delete mode 100644 site/static/images/eventing-within-cluster.png diff --git a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/repository/event/EventPublisherAutoConfiguration.java b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/repository/event/EventPublisherAutoConfiguration.java index 6a4e98242..64fe11c06 100644 --- a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/repository/event/EventPublisherAutoConfiguration.java +++ b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/repository/event/EventPublisherAutoConfiguration.java @@ -15,7 +15,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; /** - * Autoconfiguration for the event bus. + * Autoconfiguration for the events.. */ @Configuration @Import(EventPublisherConfiguration.class) diff --git a/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java b/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java index d2ce3f180..3d6b54b21 100644 --- a/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java +++ b/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java @@ -262,7 +262,7 @@ class DdiConfirmationBaseTest extends AbstractDDiApiIntegrationTest { @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 3), // implicit lock @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TargetUpdatedEvent.class, count = 1), @@ -440,7 +440,7 @@ class DdiConfirmationBaseTest extends AbstractDDiApiIntegrationTest { @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 3), // implicit lock @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) diff --git a/hawkbit-ddi/hawkbit-ddi-resource/src/test/resources/ddi-test.properties b/hawkbit-ddi/hawkbit-ddi-resource/src/test/resources/ddi-test.properties index 2db581c1c..06903de8c 100644 --- a/hawkbit-ddi/hawkbit-ddi-resource/src/test/resources/ddi-test.properties +++ b/hawkbit-ddi/hawkbit-ddi-resource/src/test/resources/ddi-test.properties @@ -25,6 +25,4 @@ spring.servlet.multipart.max-file-size=5MB hawkbit.server.security.dos.maxStatusEntriesPerAction=100 hawkbit.server.security.dos.maxAttributeEntriesPerTarget=10 # Quota - END - -# disable spring cloud bus for tests -spring.cloud.bus.enabled=false +org.eclipse.hawkbit.events.remote-enabled=false diff --git a/hawkbit-ddi/hawkbit-ddi-server/README.md b/hawkbit-ddi/hawkbit-ddi-server/README.md index 0d230c15a..663f5168e 100644 --- a/hawkbit-ddi/hawkbit-ddi-server/README.md +++ b/hawkbit-ddi/hawkbit-ddi-server/README.md @@ -24,37 +24,6 @@ run org.eclipse.hawkbit.app.ddi.DDIStart The Management API can be accessed via http://localhost:8081/rest/v1 The root url http://localhost:8081 will redirect directly to the Swagger Management UI -### Clustering (Experimental!!!) - -The micro-service instances are configured to communicate via Spring Cloud Bus. You could run multiple instances of any -micro-service but hawkbit-mgmt-server. Management server run some schedulers which shall not run simultaneously - e.g. -auto assignment checker and rollouts executor. To run multiple management server instances you shall do some extensions -of hawkbit to ensure that they wont run schedulers simultaneously or you shall configure all instances but one to do not -run schedulers! - -## Optional Protostuff for Spring cloud bus - -The micro-service instances are configured to communicate via Spring Cloud Bus. Optionally, you could -use [Protostuff](https://github.com/protostuff/protostuff) based message payload serialization for improved performance. - -**Note**: If Protostuff is enabled it shall be enabled on all microservices! - -Add/Uncomment to/in your `application.properties` : - -```properties -spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff -``` - -Add to your `pom.xml` : - -```xml - - io.protostuff - protostuff-core - - - io.protostuff - protostuff-runtime - -``` \ No newline at end of file +# Clustering (Experimental!!!) +## Remote Events between micro-services +[See more information](../../site/content/guides/clustering.md) \ No newline at end of file diff --git a/hawkbit-ddi/hawkbit-ddi-server/src/main/resources/application.properties b/hawkbit-ddi/hawkbit-ddi-server/src/main/resources/application.properties index a26f83e3f..6c949f01c 100644 --- a/hawkbit-ddi/hawkbit-ddi-server/src/main/resources/application.properties +++ b/hawkbit-ddi/hawkbit-ddi-server/src/main/resources/application.properties @@ -14,7 +14,6 @@ spring.main.allow-bean-definition-overriding=true server.port=8081 # Logging configuration -logging.level.org.eclipse.hawkbit.eventbus.DeadEventListener=WARN logging.level.org.springframework.boot.actuate.audit.listener.AuditListener=WARN logging.level.org.hibernate.validator.internal.util.Version=WARN # security Log with hints on potential attacks @@ -50,15 +49,10 @@ hawkbit.lock=inMemory # Disable discovery client of spring-cloud-commons spring.cloud.discovery.enabled=false -# Configure communication between services -endpoints.spring.cloud.bus.refresh.enabled=false -endpoints.spring.cloud.bus.env.enabled=false -spring.cloud.stream.bindings.springCloudBusInput.group=ddi-server - -# To use protostuff (for instance fot improved performance) you shall uncomment -# the following two lines and add io.protostuff:protostuff-core and io.protostuff:protostuff-runtime to dependencies -#spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -#spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff +# remote events configuration +spring.config.import=classpath:/hawkbit-events-defaults.properties +# Optional: Use protostuff (if enabled) +# spring.cloud.stream.default.content-type=application/binary+protostuff # Swagger Configuration / https://springdoc.org/v2/#properties springdoc.api-docs.version=openapi_3_0 diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherService.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherService.java index f5d2a80c2..b47f3df6a 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherService.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherService.java @@ -55,7 +55,14 @@ import org.eclipse.hawkbit.repository.SystemManagement; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; -import org.eclipse.hawkbit.repository.event.remote.MultiActionEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionAssignServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionCancelServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; @@ -73,8 +80,6 @@ import org.springframework.amqp.core.Message; import org.springframework.amqp.core.MessageBuilder; import org.springframework.amqp.core.MessageProperties; import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.cloud.bus.ServiceMatcher; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; import org.springframework.context.event.EventListener; import org.springframework.data.domain.PageRequest; import org.springframework.security.core.context.SecurityContext; @@ -96,7 +101,6 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { private final SystemSecurityContext systemSecurityContext; private final SystemManagement systemManagement; private final TargetManagement targetManagement; - private final ServiceMatcher serviceMatcher; private final SoftwareModuleManagement softwareModuleManagement; private final DistributionSetManagement distributionSetManagement; private final DeploymentManagement deploymentManagement; @@ -111,7 +115,6 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { * @param systemSecurityContext for execution with system permissions * @param systemManagement the systemManagement * @param targetManagement to access target information - * @param serviceMatcher to check in cluster case if the message is from the same cluster node * @param distributionSetManagement to retrieve modules * @param tenantConfigurationManagement to access tenant configuration */ @@ -120,7 +123,7 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { final RabbitTemplate rabbitTemplate, final AmqpMessageSenderService amqpSenderService, final ArtifactUrlHandler artifactUrlHandler, final SystemSecurityContext systemSecurityContext, final SystemManagement systemManagement, - final TargetManagement targetManagement, final ServiceMatcher serviceMatcher, + final TargetManagement targetManagement, final SoftwareModuleManagement softwareModuleManagement, final DistributionSetManagement distributionSetManagement, final DeploymentManagement deploymentManagement, final TenantConfigurationManagement tenantConfigurationManagement) { @@ -130,7 +133,6 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { this.systemSecurityContext = systemSecurityContext; this.systemManagement = systemManagement; this.targetManagement = targetManagement; - this.serviceMatcher = serviceMatcher; this.softwareModuleManagement = softwareModuleManagement; this.distributionSetManagement = distributionSetManagement; this.deploymentManagement = deploymentManagement; @@ -146,14 +148,11 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { /** * Method to send a message to a RabbitMQ Exchange after the Distribution set has been assign to a Target. * - * @param assignedEvent the object to be sent. + * @param targetAssignDistributionSetServiceEvent event to be processed */ - @EventListener(classes = TargetAssignDistributionSetEvent.class) - protected void targetAssignDistributionSet(final TargetAssignDistributionSetEvent assignedEvent) { - if (shouldSkip(assignedEvent)) { - return; - } - + @EventListener(classes = TargetAssignDistributionSetServiceEvent.class) + protected void targetAssignDistributionSet(final TargetAssignDistributionSetServiceEvent targetAssignDistributionSetServiceEvent) { + final TargetAssignDistributionSetEvent assignedEvent = targetAssignDistributionSetServiceEvent.getRemoteEvent(); final List filteredTargetList = getTargetsWithoutPendingCancellations(assignedEvent.getActions().keySet()); if (!filteredTargetList.isEmpty()) { @@ -165,16 +164,25 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { /** * Listener for Multi-Action events. * - * @param multiActionEvent the Multi-Action event to be processed + * @param multiActionAssignServiceEvent the Multi-Action event to be processed */ - @EventListener(classes = MultiActionEvent.class) - protected void onMultiAction(final MultiActionEvent multiActionEvent) { - if (shouldSkip(multiActionEvent)) { - return; - } + @EventListener(classes = MultiActionAssignServiceEvent.class) + protected void onMultiActionAssign(final MultiActionAssignServiceEvent multiActionAssignServiceEvent) { + final MultiActionAssignEvent multiActionAssignEvent = multiActionAssignServiceEvent.getRemoteEvent(); + log.debug("MultiActionAssignEvent received for {}", multiActionAssignEvent.getControllerIds()); + sendMultiActionRequestMessages(multiActionAssignEvent.getControllerIds()); + } - log.debug("MultiActionEvent received for {}", multiActionEvent.getControllerIds()); - sendMultiActionRequestMessages(multiActionEvent.getControllerIds()); + /** + * Listener for Multi-Action events. + * + * @param multiActionCancelServiceEvent the Multi-Action event to be processed + */ + @EventListener(classes = MultiActionCancelServiceEvent.class) + protected void onMultiActionCancel(final MultiActionCancelServiceEvent multiActionCancelServiceEvent) { + final MultiActionCancelEvent multiActionCancelEvent = multiActionCancelServiceEvent.getRemoteEvent(); + log.debug("MultiActionCancelEvent received for {}", multiActionCancelEvent.getControllerIds()); + sendMultiActionRequestMessages(multiActionCancelEvent.getControllerIds()); } protected void sendUpdateMessageToTarget( @@ -198,14 +206,11 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { * Method to send a message to a RabbitMQ Exchange after the assignment of * the Distribution set to a Target has been canceled. * - * @param cancelEvent that is to be converted to a DMF message + * @param cancelTargetAssignmentServiceEvent that is to be converted to a DMF message */ - @EventListener(classes = CancelTargetAssignmentEvent.class) - protected void targetCancelAssignmentToDistributionSet(final CancelTargetAssignmentEvent cancelEvent) { - if (shouldSkip(cancelEvent)) { - return; - } - + @EventListener(classes = CancelTargetAssignmentServiceEvent.class) + protected void targetCancelAssignmentToDistributionSet(final CancelTargetAssignmentServiceEvent cancelTargetAssignmentServiceEvent) { + final CancelTargetAssignmentEvent cancelEvent = cancelTargetAssignmentServiceEvent.getRemoteEvent(); final List eventTargets = partitionedParallelExecution( cancelEvent.getActions().keySet(), targetManagement::getByControllerID); @@ -221,19 +226,17 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { /** * Method to send a message to a RabbitMQ Exchange after a Target was deleted. * - * @param deleteEvent the TargetDeletedEvent which holds the necessary data for sending a target delete message. + * @param serviceTargetDeleteEvent the TargetDeletedEvent which holds the necessary data for sending a target delete message. */ - @EventListener(classes = TargetDeletedEvent.class) - protected void targetDelete(final TargetDeletedEvent deleteEvent) { - if (shouldSkip(deleteEvent)) { - return; - } - + @EventListener(classes = TargetDeletedServiceEvent.class) + protected void targetDelete(final TargetDeletedServiceEvent serviceTargetDeleteEvent) { + final TargetDeletedEvent deleteEvent = serviceTargetDeleteEvent.getRemoteEvent(); sendDeleteMessage(deleteEvent.getTenant(), deleteEvent.getControllerId(), deleteEvent.getTargetAddress()); } - @EventListener(classes = TargetAttributesRequestedEvent.class) - protected void targetTriggerUpdateAttributes(final TargetAttributesRequestedEvent updateAttributesEvent) { + @EventListener(classes = TargetAttributesRequestedServiceEvent.class) + protected void targetTriggerUpdateAttributes(final TargetAttributesRequestedServiceEvent serviceTargetUpdateAttributesEvent) { + final TargetAttributesRequestedEvent updateAttributesEvent = serviceTargetUpdateAttributesEvent.getRemoteEvent(); sendUpdateAttributesMessageToTarget( updateAttributesEvent.getTenant(), updateAttributesEvent.getControllerId(), updateAttributesEvent.getTargetAddress()); @@ -252,10 +255,6 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { IpUtil.createAmqpUri(virtualHost, ping.getMessageProperties().getReplyTo())); } - protected boolean shouldSkip(final RemoteApplicationEvent event) { - return !isFromSelf(event); - } - protected void sendCancelMessageToTarget(final String tenant, final String controllerId, final Long actionId, final URI address) { if (!IpUtil.isAmqpUri(address)) { return; @@ -515,10 +514,6 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { return targetAddress == null || !IpUtil.isAmqpUri(URI.create(targetAddress)); } - private boolean isFromSelf(final RemoteApplicationEvent event) { - return serviceMatcher == null || serviceMatcher.isFromSelf(event); - } - private boolean hasPendingCancellations(final Long targetId) { return deploymentManagement.hasPendingCancellations(targetId); } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DmfApiConfiguration.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DmfApiConfiguration.java index 0ecbc5402..870d196d8 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DmfApiConfiguration.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DmfApiConfiguration.java @@ -45,13 +45,11 @@ import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy; import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory; import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.amqp.SimpleRabbitListenerContainerFactoryConfigurer; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.cloud.bus.ServiceMatcher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.PropertySource; @@ -73,8 +71,6 @@ public class DmfApiConfiguration { private final AmqpDeadletterProperties amqpDeadletterProperties; private final ConnectionFactory rabbitConnectionFactory; - private ServiceMatcher serviceMatcher; - public DmfApiConfiguration( final AmqpProperties amqpProperties, final AmqpDeadletterProperties amqpDeadletterProperties, final ConnectionFactory rabbitConnectionFactory) { @@ -83,11 +79,6 @@ public class DmfApiConfiguration { this.rabbitConnectionFactory = rabbitConnectionFactory; } - @Autowired(required = false) // spring setter injection - public void setServiceMatcher(final ServiceMatcher serviceMatcher) { - this.serviceMatcher = serviceMatcher; - } - @Bean public FatalExceptionStrategy sqlFatalSQLExceptionStrategy(final AmqpProperties amqpProperties) { return new SqlFatalExceptionStrategy(amqpProperties.getFatalSqlExceptionPolicy()); @@ -281,7 +272,7 @@ public class DmfApiConfiguration { final SoftwareModuleManagement softwareModuleManagement, final DeploymentManagement deploymentManagement, final TenantConfigurationManagement tenantConfigurationManagement) { return new AmqpMessageDispatcherService(rabbitTemplate, amqpSenderService, artifactUrlHandler, - systemSecurityContext, systemManagement, targetManagement, serviceMatcher, softwareModuleManagement, distributionSetManagement, + systemSecurityContext, systemManagement, targetManagement, softwareModuleManagement, distributionSetManagement, deploymentManagement, tenantConfigurationManagement); } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java index 5cb1f1329..ec0963b40 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java @@ -36,6 +36,10 @@ import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; import org.eclipse.hawkbit.repository.SystemManagement; import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; @@ -69,8 +73,8 @@ import org.springframework.test.context.TestPropertySource; @ActiveProfiles({ "test" }) @SpringBootTest(classes = { JpaRepositoryConfiguration.class }, webEnvironment = SpringBootTest.WebEnvironment.NONE) @TestPropertySource(properties = { - "spring.main.allow-bean-definition-overriding=true", - "spring.cloud.bus.enabled=true" }) + "org.eclipse.hawkbit.events.remote-enabled=false", + "spring.main.allow-bean-definition-overriding=true" }) class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { private static final String TENANT = "DEFAULT"; @@ -108,7 +112,7 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { when(systemManagement.getTenantMetadataWithoutDetails()).thenReturn(tenantMetaData); amqpMessageDispatcherService = new AmqpMessageDispatcherService(rabbitTemplate, senderService, - artifactUrlHandlerMock, systemSecurityContext, systemManagement, targetManagement, serviceMatcher, + artifactUrlHandlerMock, systemSecurityContext, systemManagement, targetManagement, softwareModuleManagement, distributionSetManagement, deploymentManagement, tenantConfigurationManagement); } @@ -131,7 +135,9 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { final Action action = createAction(createDistributionSet); final TargetAssignDistributionSetEvent targetAssignDistributionSetEvent = new TargetAssignDistributionSetEvent(action); - amqpMessageDispatcherService.targetAssignDistributionSet(targetAssignDistributionSetEvent); + final TargetAssignDistributionSetServiceEvent targetAssignDistributionSetServiceEvent = + new TargetAssignDistributionSetServiceEvent(targetAssignDistributionSetEvent); + amqpMessageDispatcherService.targetAssignDistributionSet(targetAssignDistributionSetServiceEvent); final Message sendMessage = getCaptureAddressEvent(targetAssignDistributionSetEvent); final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = assertDownloadAndInstallMessage(sendMessage, action.getId()); @@ -179,7 +185,8 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { Mockito.when(rabbitTemplate.convertSendAndReceive(any())).thenReturn(receivedList); final TargetAssignDistributionSetEvent targetAssignDistributionSetEvent = new TargetAssignDistributionSetEvent(action); - amqpMessageDispatcherService.targetAssignDistributionSet(targetAssignDistributionSetEvent); + final TargetAssignDistributionSetServiceEvent targetAssignDistributionSetServiceEvent = new TargetAssignDistributionSetServiceEvent(targetAssignDistributionSetEvent); + amqpMessageDispatcherService.targetAssignDistributionSet(targetAssignDistributionSetServiceEvent); final Message sendMessage = getCaptureAddressEvent(targetAssignDistributionSetEvent); final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = assertDownloadAndInstallMessage(sendMessage, action.getId()); @@ -219,8 +226,9 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { final String amqpUri = "amqp://anyhost"; final TargetAttributesRequestedEvent targetAttributesRequestedEvent = new TargetAttributesRequestedEvent( TENANT,1L, Target.class, CONTROLLER_ID, amqpUri); - - amqpMessageDispatcherService.targetTriggerUpdateAttributes(targetAttributesRequestedEvent); + final TargetAttributesRequestedServiceEvent targetAttributesRequestedServiceEvent = + new TargetAttributesRequestedServiceEvent(targetAttributesRequestedEvent); + amqpMessageDispatcherService.targetTriggerUpdateAttributes(targetAttributesRequestedServiceEvent); final Message sendMessage = createArgumentCapture(URI.create(amqpUri)); assertUpdateAttributesMessage(sendMessage); @@ -236,7 +244,9 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { when(action.getTenant()).thenReturn(TENANT); when(action.getTarget()).thenReturn(testTarget); final CancelTargetAssignmentEvent cancelTargetAssignmentDistributionSetEvent = new CancelTargetAssignmentEvent(action); - amqpMessageDispatcherService.targetCancelAssignmentToDistributionSet(cancelTargetAssignmentDistributionSetEvent); + final CancelTargetAssignmentServiceEvent serviceCancelTargetAssignmentDistributionSetEvent = + new CancelTargetAssignmentServiceEvent(cancelTargetAssignmentDistributionSetEvent); + amqpMessageDispatcherService.targetCancelAssignmentToDistributionSet(serviceCancelTargetAssignmentDistributionSetEvent); final Message sendMessage = createArgumentCapture(AMQP_URI); assertCancelMessage(sendMessage); @@ -251,9 +261,10 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { // setup final String amqpUri = "amqp://anyhost"; final TargetDeletedEvent targetDeletedEvent = new TargetDeletedEvent(TENANT, 1L, Target.class, CONTROLLER_ID, amqpUri); + final TargetDeletedServiceEvent targetDeletedServiceEvent = new TargetDeletedServiceEvent(targetDeletedEvent); // test - amqpMessageDispatcherService.targetDelete(targetDeletedEvent); + amqpMessageDispatcherService.targetDelete(targetDeletedServiceEvent); // verify final Message sendMessage = createArgumentCapture(URI.create(amqpUri)); @@ -269,9 +280,10 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { // setup final String noAmqpUri = "http://anyhost"; final TargetDeletedEvent targetDeletedEvent = new TargetDeletedEvent(TENANT, 1L, Target.class, CONTROLLER_ID, noAmqpUri); + final TargetDeletedServiceEvent targetDeletedServiceEvent = new TargetDeletedServiceEvent(targetDeletedEvent); // test - amqpMessageDispatcherService.targetDelete(targetDeletedEvent); + amqpMessageDispatcherService.targetDelete(targetDeletedServiceEvent); // verify Mockito.verifyNoInteractions(senderService); @@ -286,9 +298,10 @@ class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { // setup final String noAmqpUri = null; final TargetDeletedEvent targetDeletedEvent = new TargetDeletedEvent(TENANT, 1L, Target.class, CONTROLLER_ID, noAmqpUri); + final TargetDeletedServiceEvent targetDeletedServiceEvent = new TargetDeletedServiceEvent(targetDeletedEvent); // test - amqpMessageDispatcherService.targetDelete(targetDeletedEvent); + amqpMessageDispatcherService.targetDelete(targetDeletedServiceEvent); // verify Mockito.verifyNoInteractions(senderService); diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageHandlerServiceIntegrationTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageHandlerServiceIntegrationTest.java index f98f54e89..8213fd365 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageHandlerServiceIntegrationTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageHandlerServiceIntegrationTest.java @@ -128,7 +128,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TargetPollEvent.class, count = 2) }) void registerTargetWithName() { final String controllerId = TARGET_PREFIX + "registerTargetWithName"; @@ -148,7 +148,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), @Expect(type = TargetPollEvent.class, count = 2) }) void registerTargetWithAttributes() { final String controllerId = TARGET_PREFIX + "registerTargetWithAttributes"; @@ -171,7 +171,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 3), + @Expect(type = TargetUpdatedEvent.class, count = 3), @Expect(type = TargetPollEvent.class, count = 2) }) void registerTargetWithNameAndAttributes() { final String controllerId = TARGET_PREFIX + "registerTargetWithAttributes"; @@ -404,14 +404,14 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 9), // implicit lock @Expect(type = TargetAttributesRequestedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), @Expect(type = TargetPollEvent.class, count = 1) }) void finishActionStatus() { final String controllerId = TARGET_PREFIX + "finishActionStatus"; @@ -425,7 +425,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class), + @Expect(type = ActionUpdatedEvent.class), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @@ -445,7 +445,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class), + @Expect(type = ActionUpdatedEvent.class), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @@ -484,7 +484,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @@ -663,7 +663,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 3), // implicit lock @Expect(type = CancelTargetAssignmentEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TargetPollEvent.class, count = 2) }) void receiveCancelUpdateMessageAfterAssignmentWasCanceled() { @@ -691,14 +691,14 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 9), // implicit lock @Expect(type = CancelTargetAssignmentEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TargetPollEvent.class, count = 1) }) void actionNotExists() { final String controllerId = TARGET_PREFIX + "actionNotExists"; @@ -738,7 +738,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), @Expect(type = CancelTargetAssignmentEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = ActionUpdatedEvent.class, count = 2), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @@ -766,7 +766,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 4), + @Expect(type = TargetUpdatedEvent.class, count = 4), @Expect(type = TargetPollEvent.class, count = 1) }) void updateAttributesWithDifferentUpdateModes() { final String controllerId = TARGET_PREFIX + "updateAttributes"; @@ -794,7 +794,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class), + @Expect(type = TargetUpdatedEvent.class), @Expect(type = TargetPollEvent.class, count = 1) }) void updateAttributesWithNoThingId() { final String controllerId = TARGET_PREFIX + "updateAttributesWithNoThingId"; @@ -822,7 +822,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @Test @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class), + @Expect(type = TargetUpdatedEvent.class), @Expect(type = TargetPollEvent.class, count = 1) }) void updateAttributesWithWrongBody() { // setup @@ -857,14 +857,14 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 9), // implicit lock @Expect(type = TargetAttributesRequestedEvent.class, count = 1), - @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), @Expect(type = TargetPollEvent.class, count = 1) }) void downloadOnlyAssignmentFinishesActionWhenTargetReportsDownloaded() throws IOException { // create target @@ -893,14 +893,14 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = ActionUpdatedEvent.class, count = 2), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), @Expect(type = DistributionSetUpdatedEvent.class, count = 1), // implicit lock @Expect(type = SoftwareModuleUpdatedEvent.class, count = 9), // implicit lock @Expect(type = TargetAttributesRequestedEvent.class, count = 2), - @Expect(type = TargetUpdatedEvent.class, count = 3), + @Expect(type = TargetUpdatedEvent.class, count = 3), @Expect(type = TargetPollEvent.class, count = 1) }) void downloadOnlyAssignmentAllowsActionStatusUpdatesWhenTargetReportsFinishedAndUpdatesInstalledDS() throws IOException { @@ -1098,7 +1098,7 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), - @Expect(type = ActionUpdatedEvent.class), + @Expect(type = ActionUpdatedEvent.class), @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), diff --git a/hawkbit-dmf/hawkbit-dmf-rabbitmq-test/src/main/java/org/eclipse/hawkbit/rabbitmq/test/AbstractAmqpIntegrationTest.java b/hawkbit-dmf/hawkbit-dmf-rabbitmq-test/src/main/java/org/eclipse/hawkbit/rabbitmq/test/AbstractAmqpIntegrationTest.java index b0946cd32..af0a29fd9 100644 --- a/hawkbit-dmf/hawkbit-dmf-rabbitmq-test/src/main/java/org/eclipse/hawkbit/rabbitmq/test/AbstractAmqpIntegrationTest.java +++ b/hawkbit-dmf/hawkbit-dmf-rabbitmq-test/src/main/java/org/eclipse/hawkbit/rabbitmq/test/AbstractAmqpIntegrationTest.java @@ -38,8 +38,8 @@ import org.springframework.test.context.TestPropertySource; // Dirty context is necessary to create a new vhost and recreate all necessary beans after every test class. @DirtiesContext(classMode = ClassMode.AFTER_CLASS) @TestPropertySource(properties = { - "spring.main.allow-bean-definition-overriding=true", - "spring.cloud.bus.enabled=true" }) + "org.eclipse.hawkbit.events.remote-enabled=false", + "spring.main.allow-bean-definition-overriding=true" }) @SuppressWarnings("java:S6813") // constructor injects are not possible for test classes public abstract class AbstractAmqpIntegrationTest extends AbstractIntegrationTest { diff --git a/hawkbit-dmf/hawkbit-dmf-server/README.md b/hawkbit-dmf/hawkbit-dmf-server/README.md index 9d721f5ed..770d6d3b9 100644 --- a/hawkbit-dmf/hawkbit-dmf-server/README.md +++ b/hawkbit-dmf/hawkbit-dmf-server/README.md @@ -14,34 +14,6 @@ Or: ```bash run org.eclipse.hawkbit.app.dmf.DMFStart ``` - -### Clustering (Experimental) -The micro-service instances are configured to communicate via Spring Cloud Bus. You could run multiple instances of any -micro-service but hawkbit-mgmt-server. Management server run some schedulers which shall not run simultaneously - e.g. -auto assignment checker and rollouts executor. To run multiple management server instances you shall do some extensions -of hawkbit to ensure that they wont run schedulers simultaneously or you shall configure all instances but one to do not -run schedulers! - -## Optional Protostuff for Spring cloud bus -The micro-service instances are configured to communicate via Spring Cloud Bus. Optionally, you could -use [Protostuff](https://github.com/protostuff/protostuff) based message payload serialization for improved performance. - -**Note**: If Protostuff is enabled it shall be enabled on all microservices! - -Add/Uncomment to/in your `application.properties` : -```properties -spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff -``` - -Add to your `pom.xml` : -```xml - - io.protostuff - protostuff-core - - - io.protostuff - protostuff-runtime - -``` \ No newline at end of file +# Clustering (Experimental!!!) +## Remote Events between micro-services +[See more information](../../site/content/guides/clustering.md) \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-server/src/main/resources/application.properties b/hawkbit-dmf/hawkbit-dmf-server/src/main/resources/application.properties index 60e3d08ad..eb65077a7 100644 --- a/hawkbit-dmf/hawkbit-dmf-server/src/main/resources/application.properties +++ b/hawkbit-dmf/hawkbit-dmf-server/src/main/resources/application.properties @@ -13,7 +13,6 @@ spring.application.name=dmf-server spring.main.allow-bean-definition-overriding=true # Logging configuration -logging.level.org.eclipse.hawkbit.eventbus.DeadEventListener=WARN logging.level.org.springframework.boot.actuate.audit.listener.AuditListener=WARN logging.level.org.hibernate.validator.internal.util.Version=WARN # security Log with hints on potential attacks @@ -35,12 +34,8 @@ hawkbit.lock=inMemory # Disable discovery client of spring-cloud-commons spring.cloud.discovery.enabled=false -# Configure communication between services -endpoints.spring.cloud.bus.refresh.enabled=false -endpoints.spring.cloud.bus.env.enabled=false -spring.cloud.stream.bindings.springCloudBusInput.group=dmf-server -# To use protostuff (for instance fot improved performance) you shall uncomment -# the following two lines and add io.protostuff:protostuff-core and io.protostuff:protostuff-runtime to dependencies -#spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -#spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff +# remote events configuration +spring.config.import=classpath:/hawkbit-events-defaults.properties +# Optional: Use protostuff (if enabled) +# spring.cloud.stream.default.content-type=application/binary+protostuff diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResourceTest.java index c52143449..778d9fe07 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResourceTest.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResourceTest.java @@ -256,7 +256,7 @@ public class MgmtTargetTagResourceTest extends AbstractManagementApiIntegrationT @Test @ExpectEvents({ @Expect(type = TargetTagCreatedEvent.class, count = 1), - @Expect(type = TargetCreatedEvent.class, count = 5), + @Expect(type = TargetCreatedEvent.class, count = 5), @Expect(type = TargetUpdatedEvent.class, count = 5) }) public void getAssignedTargetsWithPagingLimitRequestParameter() throws Exception { final TargetTag tag = testdataFactory.createTargetTags(1, "").get(0); @@ -280,7 +280,7 @@ public class MgmtTargetTagResourceTest extends AbstractManagementApiIntegrationT @Test @ExpectEvents({ @Expect(type = TargetTagCreatedEvent.class, count = 1), - @Expect(type = TargetCreatedEvent.class, count = 5), + @Expect(type = TargetCreatedEvent.class, count = 5), @Expect(type = TargetUpdatedEvent.class, count = 5) }) public void getAssignedTargetsWithPagingLimitAndOffsetRequestParameter() throws Exception { final TargetTag tag = testdataFactory.createTargetTags(1, "").get(0); diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/resources/mgmt-test.properties b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/resources/mgmt-test.properties index 29803bb9a..140242d3b 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/resources/mgmt-test.properties +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/resources/mgmt-test.properties @@ -11,6 +11,4 @@ # Logging START - activate to see request/response details #logging.level.org.eclipse.hawkbit.rest.util.MockMvcResultPrinter=DEBUG # Logging END - -# disable spring cloud bus for tests -spring.cloud.bus.enabled=false \ No newline at end of file +org.eclipse.hawkbit.events.remote-enabled=false diff --git a/hawkbit-mgmt/hawkbit-mgmt-server/README.md b/hawkbit-mgmt/hawkbit-mgmt-server/README.md index c3994a996..4e5b63d25 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-server/README.md +++ b/hawkbit-mgmt/hawkbit-mgmt-server/README.md @@ -24,17 +24,46 @@ run org.eclipse.hawkbit.app.mgmt.MgmtServerStart The Management API can be accessed via http://localhost:8080/rest/v1 The root url http://localhost:8080 will redirect directly to the Swagger Management UI -### Clustering (Experimental!!!) +# Clustering (Experimental!!!) +## Events -The micro-service instances are configured to communicate via Spring Cloud Bus. You could run multiple instances of any -micro-service but hawkbit-mgmt-server. Management server run some schedulers which shall not run simultaneously - e.g. -auto assignment checker and rollouts executor. To run multiple management server instances you shall do some extensions -of hawkbit to ensure that they wont run schedulers simultaneously or you shall configure all instances but one to do not -run schedulers! +Event communication between nodes is based on [Spring Cloud Stream](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/). +There are different [binder implementations](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/#_binders) +available. The _hawkbit Update Server_ uses RabbitMQ binder. -## Optional Protostuff for Spring cloud bus +You can run multiple instances of any micro-service, including those consuming events. +However, the `hawkbit-mgmt-server` should typically be run as a single instance, as it schedules time-sensitive jobs such as auto-assignment checking and rollout execution. +If multiple management server instances are needed, you must extend hawkBit to ensure that scheduled tasks do not run concurrently. +Alternatively, configure all but one instance to disable scheduler execution. -The micro-service instances are configured to communicate via Spring Cloud Bus. Optionally, you could +## Event Channel Types in Spring Cloud Stream + +Remote events in hawkBit are distributed through **two distinct types of channels**: + +### 1. Fanout Event Channel + +- Every service instance listening to `fanoutEventChannel` receives **a copy of every message**, regardless of instance count. +- Common for events that should be processed by each consumer independently + - In-memory cache updates + - Internal state propagation + - Logging or auditing +- Not recommended for scenarios where only one consumer should process an event (see `serviceEventChannel` for that). + +**Note**: Every instance bound to this channel will get its own copy of the message. + +### 2. Service Event Channel + +The `serviceEventChannel` is used to **ensure exclusive consumption of events** across service instances. +Only **one instance per consumer group** receives and processes each message, which is critical for non-idempotent or resource-sensitive operations. + +- Only one instance in a consumer group receives each message. +- Ideal for external integrations, third-party API calls, or any task that must not be duplicated. +- Load-balanced across instances within the same group. + + +### Optional Protostuff for Spring cloud stream + +The micro-service instances are configured to communicate via Spring Cloud Stream. Optionally, you could use [Protostuff](https://github.com/protostuff/protostuff) based message payload serialization for improved performance. **Note**: If Protostuff is enabled it shall be enabled on all microservices! @@ -42,19 +71,18 @@ use [Protostuff](https://github.com/protostuff/protostuff) based message payload Add/Uncomment to/in your `application.properties` : ```properties -spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff +spring.cloud.stream.default.content-type=application/binary+protostuff ``` Add to your `pom.xml` : ```xml - io.protostuff - protostuff-core + io.protostuff + protostuff-core - io.protostuff - protostuff-runtime + io.protostuff + protostuff-runtime ``` diff --git a/hawkbit-mgmt/hawkbit-mgmt-server/src/main/resources/application.properties b/hawkbit-mgmt/hawkbit-mgmt-server/src/main/resources/application.properties index 5d3f86195..8a03ffc05 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-server/src/main/resources/application.properties +++ b/hawkbit-mgmt/hawkbit-mgmt-server/src/main/resources/application.properties @@ -14,7 +14,6 @@ spring.main.allow-bean-definition-overriding=true spring.port=8080 # Logging configuration -logging.level.org.eclipse.hawkbit.eventbus.DeadEventListener=WARN logging.level.org.springframework.boot.actuate.audit.listener.AuditListener=WARN logging.level.org.hibernate.validator.internal.util.Version=WARN # security Log with hints on potential attacks @@ -43,15 +42,6 @@ hawkbit.server.repository.publish-target-poll-event=false # Disable discovery client of spring-cloud-commons spring.cloud.discovery.enabled=false -# Configure communication between services -endpoints.spring.cloud.bus.refresh.enabled=false -endpoints.spring.cloud.bus.env.enabled=false -spring.cloud.stream.bindings.springCloudBusInput.group=mgmt-server - -# To use protostuff (for instance fot improved performance) you shall uncomment -# the following two lines and add io.protostuff:protostuff-core and io.protostuff:protostuff-runtime to dependencies -#spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -#spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff # Swagger Configuration / https://springdoc.org/v2/#properties springdoc.api-docs.version=openapi_3_0 @@ -61,4 +51,9 @@ springdoc.packages-to-scan=org.eclipse.hawkbit.mgmt springdoc.paths-to-exclude=/system/** springdoc.swagger-ui.enabled=true springdoc.swagger-ui.csrf.enabled=true -springdoc.swagger-ui.doc-expansion=none \ No newline at end of file +springdoc.swagger-ui.doc-expansion=none + +# remote events configuration +spring.config.import=classpath:/hawkbit-events-defaults.properties +# Optional: Use protostuff (if enabled) +# spring.cloud.stream.default.content-type=application/binary+protostuff \ No newline at end of file diff --git a/hawkbit-monolith/hawkbit-update-server/README.md b/hawkbit-monolith/hawkbit-update-server/README.md index c825f279b..bbe8fdc3a 100644 --- a/hawkbit-monolith/hawkbit-update-server/README.md +++ b/hawkbit-monolith/hawkbit-update-server/README.md @@ -21,36 +21,3 @@ run org.eclipse.hawkbit.doc.Start ### Usage The Management API can be accessed via http://localhost:8080/rest/v1 - -## Enable Clustering (experimental) - -Clustering in hawkBit is based on _Spring Cloud Bus_. It is enabled by default in microservice apps and disabled (by default) in the -monolith app. To enable it for monolith app you should set (via environment, system properties or properties files) the following: - -Add to your `pom.xml` : - -```properties -spring.autoconfigure.exclude= -spring.cloud.bus.enabled=true -``` - -Optional as well is the addition of [Protostuff](https://github.com/protostuff/protostuff) based message payload -serialization for improved performance. To enable it set (via environment, system properties or properties files): - -```properties -spring.cloud.stream.bindings.springCloudBusInput.content-type=application/binary+protostuff -spring.cloud.stream.bindings.springCloudBusOutput.content-type=application/binary+protostuff -``` - -and add to your `pom.xml` : - -```xml - - io.protostuff - protostuff-core - - - io.protostuff - protostuff-runtime - -``` diff --git a/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties b/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties index 406594630..231c7d905 100644 --- a/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties +++ b/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties @@ -13,7 +13,6 @@ spring.application.name=update-server spring.main.allow-bean-definition-overriding=true # Logging configuration -logging.level.org.eclipse.hawkbit.eventbus.DeadEventListener=WARN logging.level.org.springframework.boot.actuate.audit.listener.AuditListener=WARN logging.level.org.hibernate.validator.internal.util.Version=WARN # security Log with hints on potential attacks @@ -47,9 +46,12 @@ hawkbit.server.repository.publish-target-poll-event=false ## Disable RabbitMQ auto configuration. Comment it to enable RabbitMQ support. spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration -## Configuration Spring Bus (disabled by default) - no cluster support. To enable it, enable RabbitMQ (see above) -## and comment the line (spring.cloud.bus.enabled=false) or set spring.cloud.bus.enabled=true -spring.cloud.bus.enabled=false + +## Uncomment bellow to Enable communication between services (disabled by default) - no cluster support. +# To enable it, enable RabbitMQ (see above) +# and set below 'org.eclipse.hawkbit.events.remote-enabled=true' +org.eclipse.hawkbit.events.remote-enabled=false + ## Disable DMF (by default) - no DMF support. To enable it, enable RabbitMQ (see above) and comment the line ## (hawkbit.dmf.rabbitmq.enabled=false) set hawkbit.dmf.rabbitmq.enabled=true hawkbit.dmf.enabled=false diff --git a/hawkbit-repository/hawkbit-repository-api/pom.xml b/hawkbit-repository/hawkbit-repository-api/pom.xml index 5490eab1f..73b3f5985 100644 --- a/hawkbit-repository/hawkbit-repository-api/pom.xml +++ b/hawkbit-repository/hawkbit-repository-api/pom.xml @@ -35,7 +35,7 @@ org.springframework.cloud - spring-cloud-starter-bus-amqp + spring-cloud-starter-stream-rabbit diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/EventPublisherHolder.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/EventPublisherHolder.java index 4ce5ac11f..33d9cf934 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/EventPublisherHolder.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/EventPublisherHolder.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 @@ -9,63 +9,174 @@ */ package org.eclipse.hawkbit.repository.event; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import java.util.Set; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; +import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionAssignServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionCancelServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetCreatedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cloud.bus.BusProperties; -import org.springframework.cloud.bus.ServiceMatcher; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationEventPublisher; -/** - * A singleton bean which holds the event publisher and service origin id in order to publish remote application events. - * It can be used in beans not instantiated by spring e.g. JPA entities which cannot be auto-wired. - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -@SuppressWarnings("java:S6548") // java:S6548 - singleton holder ensures static access to spring resources in some places +@Slf4j public final class EventPublisherHolder { + @Value("${org.eclipse.hawkbit.events.remote-enabled:true}") + private boolean remoteEventsEnabled; + @Value("${org.eclipse.hawkbit.events.remote.destination:fanoutEventChannel}") + private String fanoutEventChannel; + @Value("${org.eclipse.hawkbit.events.remote-service-enabled:true}") + private boolean remoteServiceEventsEnabled; + @Value("${org.eclipse.hawkbit.events.remote.service.destination:serviceEventChannel}") + private String serviceEventChannel; + + private static final EventPublisherHolder SINGLETON = new EventPublisherHolder(); + private ApplicationEventPublisher delegateEventPublisher; + private StreamBridge streamBridge; - @Getter - private ApplicationEventPublisher eventPublisher; - private ServiceMatcher serviceMatcher; - private BusProperties bus; - - /** - * @return the event publisher holder singleton instance - */ public static EventPublisherHolder getInstance() { return SINGLETON; } - @Autowired // spring setter injection - public void setApplicationEventPublisher(final ApplicationEventPublisher eventPublisher) { - this.eventPublisher = eventPublisher; - } - - @Autowired(required = false) // spring setter injection - public void setServiceMatcher(final ServiceMatcher serviceMatcher) { - this.serviceMatcher = serviceMatcher; - } - - @Autowired(required = false) // spring setter injection - public void setBusProperties(final BusProperties bus) { - this.bus = bus; - } - - /** - * @return the service origin Id coming either from {@link ServiceMatcher} when available or {@link BusProperties} otherwise. - */ - public String getApplicationId() { - String id = null; - if (serviceMatcher != null) { - id = serviceMatcher.getBusId(); + @PostConstruct + private void validateRemoteEventConfig() { + if (remoteEventsEnabled && streamBridge == null) { + throw new IllegalStateException("'org.eclipse.hawkbit.events.remote-enabled' is true but streamBridge is not configured. Check if 'spring-cloud-starter-stream-rabbit' dependency is included."); } - if (id == null && bus != null) { - id = bus.getId(); + } + + public static final Set> SERVICE_EVENTS = Set.of( + TargetCreatedEvent.class, + TargetDeletedEvent.class, + MultiActionAssignEvent.class, + MultiActionCancelEvent.class, + TargetAssignDistributionSetEvent.class, + TargetAttributesRequestedEvent.class, + CancelTargetAssignmentEvent.class + ); + + @Autowired + public void setApplicationEventPublisher(final ApplicationEventPublisher delegate) { + this.delegateEventPublisher = delegate; + } + + @Autowired(required = false) + public void setStreamBridge(final StreamBridge streamBridge) { + this.streamBridge = streamBridge; + } + + public ApplicationEventPublisher getEventPublisher() { + return new RoutingEventPublisher(streamBridge, delegateEventPublisher); + } + + class RoutingEventPublisher implements ApplicationEventPublisher { + + private final StreamBridge streamBridge; + private final ApplicationEventPublisher delegate; + + public RoutingEventPublisher(final StreamBridge streamBridge, final ApplicationEventPublisher delegate) { + this.streamBridge = streamBridge; + this.delegate = delegate; + } + + @Override + public void publishEvent(final Object event) { + routeEvent(event); + } + + @Override + public void publishEvent(final ApplicationEvent event) { + routeEvent(event); + } + + private void routeEvent(Object event) { + if (remoteEventsEnabled && event instanceof AbstractRemoteEvent remoteEvent) { + // send events to remote nodes + publishRemotely(remoteEvent); + } else { + // publish locally + publishLocally(event); + } + } + + private void publishRemotely(final AbstractRemoteEvent remoteEvent) { + streamBridge.send(fanoutEventChannel, remoteEvent); + + // some events need to be processed only by single service replica + // wrap the entity event into a service event and send it to the service channel + if (shouldForwardAsServiceEvent(remoteEvent)) { + final AbstractRemoteEvent serviceEvent = toServiceEvent(remoteEvent); + if (serviceEvent != null) { + log.debug("Publishing Service event: {} to remote channel: {}", serviceEvent, serviceEventChannel); + streamBridge.send(serviceEventChannel, serviceEvent); + } else { + log.error("No Service event created for: {}. Skipping send Service event to Service channel. {}", remoteEvent.getClass(), + serviceEventChannel); + } + } + } + + private void publishLocally(final Object event) { + delegate.publishEvent(event); + + // check if the event should be forwarded as a service event even if it is not a remote event + if (shouldForwardAsServiceEvent(event)) { + final AbstractRemoteEvent serviceEvent = toServiceEvent((AbstractRemoteEvent) event); + if (serviceEvent != null) { + log.debug("Publishing Service event: {} to locally.", serviceEvent); + delegate.publishEvent(serviceEvent); + } else { + log.error("No Service event created for: {}. Skipping send Service event locally.", event.getClass()); + } + } + } + + /** + * Checks if the event should be forwarded as a service event. + * If remote service events are enabled and the event is one of the service events, + * + * @param remoteEvent the event to check whether it should be forwarded as a service event + * @return true if the event should be forwarded as a service event, false otherwise + */ + private boolean shouldForwardAsServiceEvent(final Object remoteEvent) { + return remoteServiceEventsEnabled && SERVICE_EVENTS.contains(remoteEvent.getClass()); + } + + private AbstractRemoteEvent toServiceEvent(final AbstractRemoteEvent event) { + if (event instanceof TargetAssignDistributionSetEvent targetAssignDistributionSetEvent) { + return new TargetAssignDistributionSetServiceEvent(targetAssignDistributionSetEvent); + } else if (event instanceof MultiActionAssignEvent multiActionAssignEvent) { + return new MultiActionAssignServiceEvent(multiActionAssignEvent); + } else if (event instanceof MultiActionCancelEvent multiActionCancelEvent) { + return new MultiActionCancelServiceEvent(multiActionCancelEvent); + } else if (event instanceof CancelTargetAssignmentEvent cancelTargetAssignmentEvent) { + return new CancelTargetAssignmentServiceEvent(cancelTargetAssignmentEvent); + } else if (event instanceof TargetDeletedEvent targetDeletedEvent) { + return new TargetDeletedServiceEvent(targetDeletedEvent); + } else if (event instanceof TargetCreatedEvent targetCreatedEvent) { + return new TargetCreatedServiceEvent(targetCreatedEvent); + } else if (event instanceof TargetAttributesRequestedEvent targetAttributesRequestedEvent) { + return new TargetAttributesRequestedServiceEvent(targetAttributesRequestedEvent); + } + return null; } - // due to a bug (?) in Spring Cloud, we cannot pass null for applicationId - return id == null ? "" : id; } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEvent.java new file mode 100644 index 000000000..cd558577e --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEvent.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@Getter +@EqualsAndHashCode(callSuper = false) +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class AbstractRemoteEvent extends ApplicationEvent { + + private final String id; + + protected AbstractRemoteEvent() { + this("_empty_default_"); + } + + protected AbstractRemoteEvent(Object source) { + super(source); + this.id = UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/RemoteTenantAwareEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/RemoteTenantAwareEvent.java index 4bb36cf25..8d494547b 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/RemoteTenantAwareEvent.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/RemoteTenantAwareEvent.java @@ -10,15 +10,14 @@ package org.eclipse.hawkbit.repository.event.remote; import java.io.Serial; +import java.util.UUID; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; -import org.eclipse.hawkbit.repository.event.EventPublisherHolder; import org.eclipse.hawkbit.repository.event.TenantAwareEvent; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; /** * A distributed tenant aware event. It's the base class of the other @@ -29,19 +28,21 @@ import org.springframework.cloud.bus.event.RemoteApplicationEvent; @Getter @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) -public class RemoteTenantAwareEvent extends RemoteApplicationEvent implements TenantAwareEvent { +public class RemoteTenantAwareEvent extends AbstractRemoteEvent implements TenantAwareEvent { @Serial private static final long serialVersionUID = 1L; private String tenant; + /** + * Constructor. + * + * @param source the for the remote event. + * @param tenant the tenant + */ public RemoteTenantAwareEvent(final String tenant, final Object source) { - super(source == null ? getApplicationId() : source, getApplicationId(), DEFAULT_DESTINATION_FACTORY.getDestination(null)); + super(source == null ? UUID.randomUUID() : source); this.tenant = tenant; } - - private static String getApplicationId() { - return EventPublisherHolder.getInstance().getApplicationId(); - } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/AbstractServiceRemoteEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/AbstractServiceRemoteEvent.java new file mode 100644 index 000000000..28ff47690 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/AbstractServiceRemoteEvent.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import lombok.Getter; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; + +@Getter +public abstract class AbstractServiceRemoteEvent extends AbstractRemoteEvent { + + private final T remoteEvent; + + protected AbstractServiceRemoteEvent(T remoteEvent) { + super(remoteEvent == null ? "_empty_source_" : remoteEvent.getSource()); + this.remoteEvent = remoteEvent; + } + +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/CancelTargetAssignmentServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/CancelTargetAssignmentServiceEvent.java new file mode 100644 index 000000000..05087c724 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/CancelTargetAssignmentServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; + +/** + * Service event for {@link CancelTargetAssignmentEvent}. Event that needs single replica processing + */ +public class CancelTargetAssignmentServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public CancelTargetAssignmentServiceEvent(@JsonProperty("payload") final CancelTargetAssignmentEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionAssignServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionAssignServiceEvent.java new file mode 100644 index 000000000..11275a6c8 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionAssignServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; + +/** + * Service event for {@link MultiActionAssignEvent}. Event that needs single replica processing + */ +public class MultiActionAssignServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public MultiActionAssignServiceEvent(@JsonProperty("payload") final MultiActionAssignEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionCancelServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionCancelServiceEvent.java new file mode 100644 index 000000000..c8c88e913 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/MultiActionCancelServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; + +/** + * Service event for {@link MultiActionCancelEvent}. Event that needs single replica processing + */ +public class MultiActionCancelServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public MultiActionCancelServiceEvent(@JsonProperty("payload") final MultiActionCancelEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAssignDistributionSetServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAssignDistributionSetServiceEvent.java new file mode 100644 index 000000000..aba2b1a0d --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAssignDistributionSetServiceEvent.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; + +/** + * Service event for {@link TargetAssignDistributionSetEvent}. Event that needs single replica processing + */ +public class TargetAssignDistributionSetServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Constructor. + * + * @param remoteEvent the remote event to group + */ + @JsonCreator + public TargetAssignDistributionSetServiceEvent(@JsonProperty("payload") final TargetAssignDistributionSetEvent remoteEvent) { + super(remoteEvent); + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAttributesRequestedServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAttributesRequestedServiceEvent.java new file mode 100644 index 000000000..8af95e323 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetAttributesRequestedServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; + +/** + * Service event for {@link TargetAttributesRequestedEvent}. Event that needs single replica processing + */ +public class TargetAttributesRequestedServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public TargetAttributesRequestedServiceEvent(@JsonProperty("payload") final TargetAttributesRequestedEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetCreatedServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetCreatedServiceEvent.java new file mode 100644 index 000000000..7a692b50c --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetCreatedServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; + +/** + * Service event for {@link TargetCreatedEvent}. Event that needs single replica processing + */ +public class TargetCreatedServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public TargetCreatedServiceEvent(@JsonProperty("payload") final TargetCreatedEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetDeletedServiceEvent.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetDeletedServiceEvent.java new file mode 100644 index 000000000..38d9d2707 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/event/remote/service/TargetDeletedServiceEvent.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote.service; + +import java.io.Serial; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; + +/** + * Service event for {@link TargetDeletedEvent}. Event that needs single replica processing + */ +public class TargetDeletedServiceEvent extends AbstractServiceRemoteEvent { + + @Serial + private static final long serialVersionUID = 1L; + + + @JsonCreator + public TargetDeletedServiceEvent(@JsonProperty("payload") final TargetDeletedEvent remoteEvent) { + super(remoteEvent); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventJacksonMessageConverter.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventJacksonMessageConverter.java new file mode 100644 index 000000000..039dfb16c --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventJacksonMessageConverter.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.util.MimeType; + +public class EventJacksonMessageConverter extends MappingJackson2MessageConverter { + + public EventJacksonMessageConverter() { + super(new MimeType("application", "remote-event-json")); + ObjectMapper objectMapper = new ObjectMapper(); + EventType.getNamedTypes().forEach(objectMapper::registerSubtypes); + setObjectMapper(objectMapper); + } +} diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverter.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverter.java similarity index 95% rename from hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverter.java rename to hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverter.java index 248aa5e72..0e3aa222e 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverter.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverter.java @@ -14,7 +14,7 @@ import io.protostuff.ProtobufIOUtil; import io.protostuff.Schema; import io.protostuff.runtime.RuntimeSchema; import lombok.extern.slf4j.Slf4j; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.converter.AbstractMessageConverter; @@ -30,20 +30,20 @@ import org.springframework.util.MimeType; * values of {@link EventType}. */ @Slf4j -public class BusProtoStuffMessageConverter extends AbstractMessageConverter { +public class EventProtoStuffMessageConverter extends AbstractMessageConverter { public static final MimeType APPLICATION_BINARY_PROTOSTUFF = new MimeType("application", "binary+protostuff"); /** The length of the class type length of the payload. */ private static final byte EVENT_TYPE_LENGTH = 2; - public BusProtoStuffMessageConverter() { + public EventProtoStuffMessageConverter() { super(APPLICATION_BINARY_PROTOSTUFF); } @Override protected boolean supports(final Class aClass) { - return RemoteApplicationEvent.class.isAssignableFrom(aClass); + return AbstractRemoteEvent.class.isAssignableFrom(aClass); } @Override diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventPublisherConfiguration.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventPublisherConfiguration.java index 45c1c73a5..a40de4e57 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventPublisherConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventPublisherConfiguration.java @@ -10,23 +10,18 @@ package org.eclipse.hawkbit.event; import java.util.concurrent.Executor; +import java.util.function.Consumer; -import io.protostuff.ProtostuffIOUtil; -import io.protostuff.Schema; import org.eclipse.hawkbit.repository.event.ApplicationEventFilter; -import org.eclipse.hawkbit.repository.event.EventPublisherHolder; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; import org.eclipse.hawkbit.repository.event.remote.RemoteTenantAwareEvent; +import org.eclipse.hawkbit.repository.event.EventPublisherHolder; import org.eclipse.hawkbit.security.SystemSecurityContext; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.cloud.bus.BusProperties; -import org.springframework.cloud.bus.ConditionalOnBusEnabled; -import org.springframework.cloud.bus.ServiceMatcher; -import org.springframework.cloud.bus.jackson.RemoteApplicationEventScan; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -37,12 +32,10 @@ import org.springframework.core.ResolvableType; import org.springframework.messaging.converter.MessageConverter; /** - * Autoconfiguration for the event bus. + * Autoconfiguration for the events. */ @Configuration -@RemoteApplicationEventScan(basePackages = "org.eclipse.hawkbit.repository.event.remote") -@PropertySource("classpath:/hawkbit-eventbus-defaults.properties") -@EnableConfigurationProperties(BusProperties.class) +@PropertySource("classpath:/hawkbit-events-defaults.properties") public class EventPublisherConfiguration { /** @@ -66,7 +59,7 @@ public class EventPublisherConfiguration { * @return the singleton instance of the {@link EventPublisherHolder} */ @Bean - EventPublisherHolder eventBusHolder() { + public EventPublisherHolder eventPublisherHolder() { return EventPublisherHolder.getInstance(); } @@ -84,19 +77,12 @@ public class EventPublisherConfiguration { private final SystemSecurityContext systemSecurityContext; private final ApplicationEventFilter applicationEventFilter; - private ServiceMatcher serviceMatcher; - protected TenantAwareApplicationEventPublisher( final SystemSecurityContext systemSecurityContext, final ApplicationEventFilter applicationEventFilter) { this.systemSecurityContext = systemSecurityContext; this.applicationEventFilter = applicationEventFilter; } - @Autowired(required = false) - public void setServiceMatcher(final ServiceMatcher serviceMatcher) { - this.serviceMatcher = serviceMatcher; - } - /** * Was overridden that not every event has to run within an own tenantAware. */ @@ -106,33 +92,53 @@ public class EventPublisherConfiguration { return; } - if (serviceMatcher == null || !(event instanceof final RemoteTenantAwareEvent remoteEvent)) { - super.multicastEvent(event, eventType); + if (event instanceof final RemoteTenantAwareEvent remoteEvent) { + systemSecurityContext.runAsSystemAsTenant(() -> { + super.multicastEvent(event, eventType); + return null; + }, remoteEvent.getTenant()); return; } - if (serviceMatcher.isFromSelf(remoteEvent)) { - super.multicastEvent(event, eventType); - return; - } - - systemSecurityContext.runAsSystemAsTenant(() -> { - super.multicastEvent(event, eventType); - return null; - }, remoteEvent.getTenant()); + super.multicastEvent(event, eventType); } } - @ConditionalOnBusEnabled - @ConditionalOnClass({ Schema.class, ProtostuffIOUtil.class }) - protected static class BusProtoStuffAutoConfiguration { + @Bean + @ConditionalOnProperty(name = "org.eclipse.hawkbit.events.remote-enabled", havingValue = "true") + public Consumer serviceEventConsumer(ApplicationEventPublisher publisher) { + return publisher::publishEvent; + } + + @Bean + @ConditionalOnProperty(name = "org.eclipse.hawkbit.events.remote-enabled", havingValue = "true") + public Consumer fanoutEventConsumer(ApplicationEventPublisher publisher) { + return publisher::publishEvent; + } + + @ConditionalOnProperty(name = "org.eclipse.hawkbit.events.remote-enabled", havingValue = "true") + @ConditionalOnProperty(name = "spring.cloud.stream.default.content-type", havingValue = "application/binary+stuff") + protected static class EventProtoStuffAutoConfiguration { /** - * @return the protostuff io message converter + * @return the protostuff io message converter for events */ @Bean - public MessageConverter busProtoBufConverter() { - return new BusProtoStuffMessageConverter(); + public MessageConverter eventProtoStuffConverter() { + return new EventProtoStuffMessageConverter(); + } + } + + @ConditionalOnProperty(name = "org.eclipse.hawkbit.events.remote-enabled", havingValue = "true") + @ConditionalOnProperty(name = "spring.cloud.stream.default.content-type", havingValue = "application/remote-event-json") + protected static class EventJacksonAutoConfiguration { + + /** + * @return the Jackson message converter for events + */ + @Bean + public MessageConverter eventJacksonMessageConverter() { + return new EventJacksonMessageConverter(); } } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventType.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventType.java index cbd8cdfd9..600ba91db 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventType.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/event/EventType.java @@ -9,10 +9,12 @@ */ package org.eclipse.hawkbit.event; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; +import com.fasterxml.jackson.databind.jsontype.NamedType; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,6 +26,13 @@ import org.eclipse.hawkbit.repository.event.remote.DistributionSetTypeDeletedEve import org.eclipse.hawkbit.repository.event.remote.DownloadProgressEvent; import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionAssignServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionCancelServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetCreatedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; import org.eclipse.hawkbit.repository.event.remote.RolloutDeletedEvent; import org.eclipse.hawkbit.repository.event.remote.RolloutGroupDeletedEvent; import org.eclipse.hawkbit.repository.event.remote.RolloutStoppedEvent; @@ -166,6 +175,15 @@ public class EventType { TYPES.put(44, TargetTypeCreatedEvent.class); TYPES.put(45, TargetTypeUpdatedEvent.class); TYPES.put(46, TargetTypeDeletedEvent.class); + + // processing events - start from 1000 to leave room for future db events + TYPES.put(1000, TargetCreatedServiceEvent.class); + TYPES.put(1001, TargetDeletedServiceEvent.class); + TYPES.put(1002, TargetAssignDistributionSetServiceEvent.class); + TYPES.put(1003, TargetAttributesRequestedServiceEvent.class); + TYPES.put(1004, CancelTargetAssignmentServiceEvent.class); + TYPES.put(1005, MultiActionAssignServiceEvent.class); + TYPES.put(1006, MultiActionCancelServiceEvent.class); } /** @@ -197,4 +215,10 @@ public class EventType { public Class getTargetClass() { return TYPES.get(value); } + + public static Collection getNamedTypes() { + return TYPES.entrySet().stream() + .map(e -> new NamedType(e.getValue(), String.valueOf(e.getKey()))) + .toList(); + } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-eventbus-defaults.properties b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-eventbus-defaults.properties deleted file mode 100644 index 4c7bf9e96..000000000 --- a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-eventbus-defaults.properties +++ /dev/null @@ -1,21 +0,0 @@ -# -# Copyright (c) 2015 Bosch Software Innovations GmbH and others -# -# This program and the accompanying materials are made -# available under the terms of the Eclipse Public License 2.0 -# which is available at https://www.eclipse.org/legal/epl-2.0/ -# -# SPDX-License-Identifier: EPL-2.0 -# - -# Spring cloud bus and stream -spring.cloud.bus.enabled=true -# Disable Cloud Bus default events -spring.cloud.bus.env.enabled=false -spring.cloud.bus.ack.enabled=false -spring.cloud.bus.trace.enabled=false -spring.cloud.bus.refresh.enabled=false -# Disable Cloud Bus endpoints -management.endpoint.busrefresh.access=none -management.endpoint.busenv.access=none -# Spring cloud bus and stream END \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-events-defaults.properties b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-events-defaults.properties new file mode 100644 index 000000000..481b5cfc2 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-events-defaults.properties @@ -0,0 +1,47 @@ +# +# Copyright (c) 2015 Bosch Software Innovations GmbH and others +# +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# + +org.eclipse.hawkbit.events.remote-enabled=true + +spring.cloud.function.definition=fanoutEventConsumer;serviceEventConsumer + +spring.cloud.stream.default.content-type=application/remote-event-json +# -- Consumer bindings -- +spring.cloud.stream.bindings.fanoutEventConsumer-in-0.destination=fanoutEventChannel +spring.cloud.stream.bindings.serviceEventConsumer-in-0.destination=serviceEventChannel + +# -- Producer bindings (for StreamBridge) -- +spring.cloud.stream.bindings.fanoutEventChannel.destination=fanoutEventChannel +spring.cloud.stream.bindings.serviceEventChannel.destination=serviceEventChannel + +spring.cloud.stream.bindings.serviceEventConsumer-in-0.group=${spring.application.name} + +# Performance +spring.cloud.stream.rabbit.binder.compressionLevel=0 +spring.cloud.stream.rabbit.bindings.fanoutEventConsumer-in-0.consumer.anonymousGroupPrefix=${spring.application.name}- +spring.cloud.stream.rabbit.bindings.fanoutEventConsumer-in-0.consumer.durableSubscription=false +spring.cloud.stream.rabbit.bindings.fanoutEventConsumer-in-0.consumer.maxConcurrency=1 +spring.cloud.stream.rabbit.bindings.fanoutEventConsumer-in-0.consumer.requeueRejected=false +spring.cloud.stream.rabbit.bindings.fanoutEventConsumer-in-0.consumer.prefetch=100 +spring.cloud.stream.rabbit.bindings.serviceEventConsumer-in-0.consumer.maxConcurrency=1 +spring.cloud.stream.rabbit.bindings.serviceEventConsumer-in-0.consumer.requeueRejected=false +spring.cloud.stream.rabbit.bindings.serviceEventConsumer-in-0.consumer.prefetch=100 + +spring.cloud.stream.rabbit.bindings.fanoutEventChannel.producer.declareExchange=false +spring.cloud.stream.rabbit.bindings.fanoutEventChannel.producer.batchingEnabled=true +spring.cloud.stream.rabbit.bindings.fanoutEventChannel.producer.batchSize=1000 +spring.cloud.stream.rabbit.bindings.fanoutEventChannel.producer.batch-buffer-limit=100000 +spring.cloud.stream.rabbit.bindings.fanoutEventChannel.producer.deliveryMode=NON_PERSISTENT + +spring.cloud.stream.rabbit.bindings.serviceEventChannel.producer.declareExchange=false +spring.cloud.stream.rabbit.bindings.serviceEventChannel.producer.batchingEnabled=true +spring.cloud.stream.rabbit.bindings.serviceEventChannel.producer.batchSize=1000 +spring.cloud.stream.rabbit.bindings.serviceEventChannel.producer.batch-buffer-limit=100000 +spring.cloud.stream.rabbit.bindings.serviceEventChannel.producer.deliveryMode=NON_PERSISTENT \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventJacksonMessageConverterTest.java b/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventJacksonMessageConverterTest.java new file mode 100644 index 000000000..2a721c834 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventJacksonMessageConverterTest.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.event; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.HashMap; + +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; +import org.eclipse.hawkbit.repository.model.Target; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +@ExtendWith(MockitoExtension.class) +class EventJacksonMessageConverterTest { + + private final TestEventJacksonMessageConverter underTest = new TestEventJacksonMessageConverter(); + + @Mock + private Target targetMock; + + @Mock + private Message messageMock; + + @BeforeEach + void before() { + when(targetMock.getId()).thenReturn(1L); + } + + /** + * Verifies that the TargetCreatedEvent can be successfully serialized and deserialized + */ + @Test + void successfullySerializeAndDeserializeEvent() { + final TargetCreatedEvent targetCreatedEvent = new TargetCreatedEvent(targetMock); + // serialize + final Object serializedEvent = underTest.convertToInternal(targetCreatedEvent, + new MessageHeaders(new HashMap<>()), null); + assertThat(serializedEvent).isInstanceOf(byte[].class); + + // deserialize + when(messageMock.getPayload()).thenReturn(serializedEvent); + final Object deserializedEvent = underTest.convertFromInternal(messageMock, AbstractRemoteEvent.class, null); + assertThat(deserializedEvent) + .isInstanceOf(TargetCreatedEvent.class) + .isEqualTo(targetCreatedEvent); + } + + /** + * Test subclass to expose protected methods for testing. + */ + private static class TestEventJacksonMessageConverter extends EventJacksonMessageConverter { + @Override + public Object convertToInternal(Object payload, MessageHeaders headers, Object conversionHint) { + return super.convertToInternal(payload, headers, conversionHint); + } + + @Override + public Object convertFromInternal(Message message, Class targetClass, Object conversionHint) { + return super.convertFromInternal(message, targetClass, conversionHint); + } + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverterTest.java b/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverterTest.java similarity index 92% rename from hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverterTest.java rename to hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverterTest.java index d9f4c13c8..260472c2b 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/BusProtoStuffMessageConverterTest.java +++ b/hawkbit-repository/hawkbit-repository-core/src/test/java/org/eclipse/hawkbit/event/EventProtoStuffMessageConverterTest.java @@ -16,6 +16,7 @@ import static org.mockito.Mockito.when; import java.io.Serial; import java.util.HashMap; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; import org.eclipse.hawkbit.repository.event.remote.entity.RemoteEntityEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; import org.eclipse.hawkbit.repository.model.Target; @@ -24,15 +25,14 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.converter.MessageConversionException; @ExtendWith(MockitoExtension.class) -class BusProtoStuffMessageConverterTest { +class EventProtoStuffMessageConverterTest { - private final BusProtoStuffMessageConverter underTest = new BusProtoStuffMessageConverter(); + private final EventProtoStuffMessageConverter underTest = new EventProtoStuffMessageConverter(); @Mock private Target targetMock; @@ -58,7 +58,7 @@ class BusProtoStuffMessageConverterTest { // deserialize when(messageMock.getPayload()).thenReturn(serializedEvent); - final Object deserializedEvent = underTest.convertFromInternal(messageMock, RemoteApplicationEvent.class, null); + final Object deserializedEvent = underTest.convertFromInternal(messageMock, AbstractRemoteEvent.class, null); assertThat(deserializedEvent) .isInstanceOf(TargetCreatedEvent.class) .isEqualTo(targetCreatedEvent); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTenantConfigurationManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTenantConfigurationManagement.java index 7c82bdde2..60f333cc1 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTenantConfigurationManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTenantConfigurationManagement.java @@ -30,7 +30,6 @@ import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.eclipse.hawkbit.im.authentication.SpPermission; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; -import org.eclipse.hawkbit.repository.event.remote.RemoteTenantAwareEvent; import org.eclipse.hawkbit.repository.event.remote.TenantConfigurationDeletedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.RemoteEntityEvent; import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException; @@ -58,7 +57,6 @@ import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.cloud.bus.ServiceMatcher; import org.springframework.context.ApplicationContext; import org.springframework.context.event.EventListener; import org.springframework.core.convert.support.ConfigurableConversionService; @@ -89,9 +87,7 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana private final CacheManager cacheManager; private final AfterTransactionCommitExecutor afterCommitExecutor; - private ServiceMatcher serviceMatcher; - - protected JpaTenantConfigurationManagement( + public JpaTenantConfigurationManagement( final TenantConfigurationRepository tenantConfigurationRepository, final TenantConfigurationProperties tenantConfigurationProperties, final CacheManager cacheManager, final AfterTransactionCommitExecutor afterCommitExecutor, @@ -103,11 +99,6 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana this.applicationContext = applicationContext; } - @Autowired(required = false) - public void setServiceMatcher(final ServiceMatcher serviceMatcher) { - this.serviceMatcher = serviceMatcher; - } - @Override @CacheEvict(value = "tenantConfiguration", key = "#configurationKeyName") @Transactional @@ -186,10 +177,6 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana */ @EventListener public void onTenantConfigurationDeletedEvent(final TenantConfigurationDeletedEvent event) { - if (!shouldProcessRemoteTenantAwareEvent(event)) { - return; - } - evictCacheEntryByKeyIfPresent(event.getConfigKey()); } @@ -200,10 +187,6 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana */ @EventListener public void onTenantConfigurationRemoteEntityEvent(final RemoteEntityEvent event) { - if (!shouldProcessRemoteTenantAwareEvent(event)) { - return; - } - event.getEntity().ifPresent(tenantConfiguration -> evictCacheEntryByKeyIfPresent(tenantConfiguration.getKey())); } @@ -391,8 +374,4 @@ public class JpaTenantConfigurationManagement implements TenantConfigurationMana cache.evictIfPresent(key); } } - - private boolean shouldProcessRemoteTenantAwareEvent(final RemoteTenantAwareEvent event) { - return serviceMatcher == null || !serviceMatcher.isFromSelf(event) && serviceMatcher.isForSelf(event); - } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java index 7a3016f7e..4b8cbfe81 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java @@ -51,7 +51,6 @@ import org.eclipse.hawkbit.repository.event.remote.entity.ActionUpdatedEvent; import org.eclipse.hawkbit.repository.jpa.utils.MapAttributeConverter; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.ActionStatus; -import org.eclipse.hawkbit.repository.model.BaseEntity; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Rollout; import org.eclipse.hawkbit.repository.model.RolloutGroup; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEventTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEventTest.java index e2a50a5f0..1ff670d1a 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEventTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/AbstractRemoteEventTest.java @@ -11,49 +11,42 @@ package org.eclipse.hawkbit.repository.event.remote; import static org.junit.jupiter.api.Assertions.fail; -import java.util.LinkedHashMap; import java.util.Map; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.eclipse.hawkbit.event.BusProtoStuffMessageConverter; +import org.eclipse.hawkbit.event.EventProtoStuffMessageConverter; import org.eclipse.hawkbit.repository.event.TenantAwareEvent; import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; import org.junit.jupiter.api.BeforeEach; -import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; -import org.springframework.cloud.bus.jackson.BusJacksonAutoConfiguration; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.integration.support.MessageBuilder; import org.springframework.integration.support.MutableMessageHeaders; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.converter.AbstractMessageConverter; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.util.ClassUtils; -import org.springframework.util.MimeType; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; import org.springframework.util.MimeTypeUtils; /** * Test the remote entity events. */ -@TestPropertySource(properties = { "spring.cloud.bus.enabled=true" }) @SuppressWarnings("java:S6813") // constructor injects are not possible for test classes - public abstract class AbstractRemoteEventTest extends AbstractJpaIntegrationTest { +@Import(AbstractRemoteEventTest.EventProtoStuffTestConfig.class) +public abstract class AbstractRemoteEventTest extends AbstractJpaIntegrationTest { @Autowired - private BusProtoStuffMessageConverter busProtoStuffMessageConverter; + private EventProtoStuffMessageConverter eventProtoStuffMessageConverter; + private AbstractMessageConverter jacksonMessageConverter; @BeforeEach - public void setup() throws Exception { - final BusJacksonAutoConfiguration autoConfiguration = new BusJacksonAutoConfiguration(); - this.jacksonMessageConverter = autoConfiguration.busJsonConverter(null); - ReflectionTestUtils.setField( - jacksonMessageConverter, "packagesToScan", - new String[] { "org.eclipse.hawkbit.repository.event.remote", ClassUtils.getPackageName(RemoteApplicationEvent.class) }); - ((InitializingBean) jacksonMessageConverter).afterPropertiesSet(); + public void setup() { + this.jacksonMessageConverter = new MappingJackson2MessageConverter(); } @SuppressWarnings("unchecked") @@ -65,24 +58,33 @@ import org.springframework.util.MimeTypeUtils; @SuppressWarnings("unchecked") protected T createProtoStuffEvent(final T event) { final Message message = createProtoStuffMessage(event); - return (T) busProtoStuffMessageConverter.fromMessage(message, event.getClass()); + return (T) eventProtoStuffMessageConverter.fromMessage(message, event.getClass()); } private Message createProtoStuffMessage(final TenantAwareEvent event) { - final Map headers = new LinkedHashMap<>(); - headers.put(MessageHeaders.CONTENT_TYPE, BusProtoStuffMessageConverter.APPLICATION_BINARY_PROTOSTUFF); - return busProtoStuffMessageConverter.toMessage(event, new MutableMessageHeaders(headers)); + return eventProtoStuffMessageConverter.toMessage( + event, new MutableMessageHeaders(Map.of(MessageHeaders.CONTENT_TYPE, + EventProtoStuffMessageConverter.APPLICATION_BINARY_PROTOSTUFF)) + ); } private Message createJsonMessage(final Object event) { - final Map headers = new LinkedHashMap<>(); - headers.put(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON); try { - final String json = new ObjectMapper().writeValueAsString(event); - return MessageBuilder.withPayload(json).copyHeaders(headers).build(); - } catch (final JsonProcessingException e) { + String json = new ObjectMapper().writeValueAsString(event); + return MessageBuilder.withPayload(json) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON) + .build(); + } catch (JsonProcessingException e) { fail(e.getMessage()); } return null; } + + @TestConfiguration + static class EventProtoStuffTestConfig { + @Bean + public MessageConverter eventProtoBufConverter() { + return new EventProtoStuffMessageConverter(); + } + } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/ServiceEventsTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/ServiceEventsTest.java new file mode 100644 index 000000000..5a63f0980 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/ServiceEventsTest.java @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.event.remote; + +import org.eclipse.hawkbit.repository.event.EventPublisherHolder; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionAssignServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetCreatedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.Target; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.context.ApplicationEventPublisher; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Set; + +class ServiceEventsTest { + + private StreamBridge streamBridge; + private ApplicationEventPublisher delegate; + private ApplicationEventPublisher publisher; + + @BeforeEach + void setUp() throws IllegalAccessException, NoSuchFieldException { + streamBridge = mock(StreamBridge.class); + delegate = mock(ApplicationEventPublisher.class); + EventPublisherHolder.getInstance().setApplicationEventPublisher(delegate); + EventPublisherHolder.getInstance().setStreamBridge(streamBridge); + publisher = EventPublisherHolder.getInstance().getEventPublisher(); + + // Set up fields via reflection + var remoteEventsEnabledField = EventPublisherHolder.class.getDeclaredField("remoteEventsEnabled"); + remoteEventsEnabledField.setAccessible(true); + remoteEventsEnabledField.set(EventPublisherHolder.getInstance(), true); + + var remoteServiceEventsEnabledField = EventPublisherHolder.class.getDeclaredField("remoteServiceEventsEnabled"); + remoteServiceEventsEnabledField.setAccessible(true); + remoteServiceEventsEnabledField.set(EventPublisherHolder.getInstance(), true); + + var fanoutChannelField = EventPublisherHolder.class.getDeclaredField("fanoutEventChannel"); + fanoutChannelField.setAccessible(true); + fanoutChannelField.set(EventPublisherHolder.getInstance(), "fanout"); + + var groupChannelField = EventPublisherHolder.class.getDeclaredField("serviceEventChannel"); + groupChannelField.setAccessible(true); + groupChannelField.set(EventPublisherHolder.getInstance(), "group"); + } + + @Test + void testExpectedServiceEvents(){ + var expected = Set.of( + TargetAssignDistributionSetEvent.class, + MultiActionAssignEvent.class, + MultiActionCancelEvent.class, + CancelTargetAssignmentEvent.class, + TargetDeletedEvent.class, + TargetCreatedEvent.class, + TargetAttributesRequestedEvent.class + ); + assertEquals(EventPublisherHolder.SERVICE_EVENTS, expected); + } + + @Test + void testProcessingTargetAssignDistributionSetEventIsSent() { + TargetAssignDistributionSetEvent event = new TargetAssignDistributionSetEvent(mockAction()); + + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(TargetAssignDistributionSetServiceEvent.class)); + } + + @Test + void testProcessingTargetCreatedEventIsSent() { + TargetCreatedEvent event = new TargetCreatedEvent(mock(Target.class)); + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(TargetCreatedServiceEvent.class)); + } + + @Test + void testProcessingTargetDeletedEventIsSent() { + TargetDeletedEvent event = new TargetDeletedEvent("testtenant", 1l, Target.class, "testControllerId", "address"); + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(TargetDeletedServiceEvent.class)); + } + + @Test + void testProcessingTargetAttributesRequestedEventIsSent() { + TargetAttributesRequestedEvent event = new TargetAttributesRequestedEvent("testtenant", 1l, Target.class, "testControllerId","address"); + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(TargetAttributesRequestedServiceEvent.class)); + } + + @Test + void testProcessingMultiActionAssignmentEventIsSent() { + MultiActionAssignEvent event = new MultiActionAssignEvent("testtenant", List.of(mockAction())); + + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(MultiActionAssignServiceEvent.class)); + } + + @Test + void testCancelTargetAssignmentEventIsSent() { + CancelTargetAssignmentEvent event = new CancelTargetAssignmentEvent(mockAction()); + + publisher.publishEvent(event); + + verify(streamBridge).send("fanout", event); + verify(streamBridge).send(eq("group"), any(CancelTargetAssignmentServiceEvent.class)); + } + + private Action mockAction() { + final Action actionMock = mock(Action.class); + final Target targetMock = mock(Target.class); + final DistributionSet distributionSetMock = mock(DistributionSet.class); + when(distributionSetMock.getId()).thenReturn(1L); + when(actionMock.getDistributionSet()).thenReturn(distributionSetMock); + when(actionMock.getId()).thenReturn(1l); + when(actionMock.getTenant()).thenReturn("DEFAULT"); + when(actionMock.getTarget()).thenReturn(targetMock); + when(actionMock.getActionType()).thenReturn(Action.ActionType.SOFT); + when(targetMock.getControllerId()).thenReturn("target1"); + return actionMock; + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/resources/jpa-test.properties b/hawkbit-repository/hawkbit-repository-jpa/src/test/resources/jpa-test.properties index 26a433cb5..062f18d79 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/resources/jpa-test.properties +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/resources/jpa-test.properties @@ -9,7 +9,6 @@ # ### Debug & Monitor Eclipselink - START - logging.level.org.eclipse.persistence=ERROR ## Uncomment to see the debug of persistence, e.g. to see the generated SQLs #logging.level.org.eclipse.persistence=DEBUG @@ -56,5 +55,5 @@ hawkbit.repository.cluster.lock.refreshOnRemainMS=200 hawkbit.repository.cluster.lock.refreshOnRemainPercent=10 # reduce scheduler tic period to speed up tests hawkbit.repository.cluster.lock.ticPeriodMS=10 -# disable spring cloud bus for tests -spring.cloud.bus.enabled=false \ No newline at end of file + +org.eclipse.hawkbit.events.remote-enabled=false \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/TestConfiguration.java b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/TestConfiguration.java index a8adf0a04..e7d90f6ab 100644 --- a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/TestConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/TestConfiguration.java @@ -24,7 +24,6 @@ import org.eclipse.hawkbit.repository.artifact.ArtifactRepository; import org.eclipse.hawkbit.repository.artifact.urlhandler.ArtifactUrlHandlerProperties; import org.eclipse.hawkbit.repository.artifact.urlhandler.PropertyBasedArtifactUrlHandler; import org.eclipse.hawkbit.cache.TenantAwareCacheManager; -import org.eclipse.hawkbit.event.BusProtoStuffMessageConverter; import org.eclipse.hawkbit.repository.RolloutApprovalStrategy; import org.eclipse.hawkbit.repository.RolloutStatusCache; import org.eclipse.hawkbit.repository.event.ApplicationEventFilter; @@ -51,7 +50,6 @@ import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.caffeine.CaffeineCacheManager; -import org.springframework.cloud.bus.ConditionalOnBusEnabled; import org.springframework.context.ApplicationEvent; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -63,7 +61,6 @@ import org.springframework.core.ResolvableType; import org.springframework.data.domain.AuditorAware; import org.springframework.integration.support.locks.DefaultLockRegistry; import org.springframework.integration.support.locks.LockRegistry; -import org.springframework.messaging.converter.MessageConverter; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService; @@ -181,7 +178,7 @@ public class TestConfiguration implements AsyncConfigurer { } @Bean - EventPublisherHolder eventBusHolder() { + EventPublisherHolder eventPublisherHolder() { return EventPublisherHolder.getInstance(); } @@ -208,15 +205,6 @@ public class TestConfiguration implements AsyncConfigurer { return new RolloutTestApprovalStrategy(); } - /** - * @return the protostuff io message converter - */ - @Bean - @ConditionalOnBusEnabled - MessageConverter busProtoBufConverter() { - return new BusProtoStuffMessageConverter(); - } - private static class FilterEnabledApplicationEventPublisher extends SimpleApplicationEventMulticaster { private final ApplicationEventFilter applicationEventFilter; diff --git a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/matcher/EventVerifier.java b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/matcher/EventVerifier.java index 239ea3442..d9ce0cff4 100644 --- a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/matcher/EventVerifier.java +++ b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/matcher/EventVerifier.java @@ -15,8 +15,12 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.fail; import java.io.Serial; +import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -27,10 +31,23 @@ import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.awaitility.core.ConditionTimeoutException; +import org.eclipse.hawkbit.repository.event.remote.AbstractRemoteEvent; +import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; +import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; +import org.eclipse.hawkbit.repository.event.remote.service.CancelTargetAssignmentServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionAssignServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.MultiActionCancelServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAssignDistributionSetServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetAttributesRequestedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetCreatedServiceEvent; +import org.eclipse.hawkbit.repository.event.remote.service.TargetDeletedServiceEvent; import org.eclipse.hawkbit.repository.event.remote.RemoteIdEvent; import org.eclipse.hawkbit.repository.event.remote.RemoteTenantAwareEvent; import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; -import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; @@ -86,7 +103,50 @@ public class EventVerifier extends AbstractTestExecutionListener { } private Optional getExpectationsFrom(final Method testMethod) { - return Optional.ofNullable(testMethod.getAnnotation(ExpectEvents.class)).map(ExpectEvents::value); + final Optional expectedEvents = Optional.ofNullable(testMethod.getAnnotation(ExpectEvents.class)).map(ExpectEvents::value); + if (expectedEvents.isPresent()) { + List modifiedEvents = new ArrayList<>(Arrays.asList(expectedEvents.get())); + for (Expect event : expectedEvents.get()) { + final Class type = event.type(); + if (type.isAssignableFrom(TargetCreatedEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(TargetCreatedServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(TargetDeletedEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(TargetDeletedServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(TargetAssignDistributionSetEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(TargetAssignDistributionSetServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(MultiActionAssignEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(MultiActionAssignServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(MultiActionCancelEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(MultiActionCancelServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(TargetAttributesRequestedEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(TargetAttributesRequestedServiceEvent.class, event.count())); + } else if (type.isAssignableFrom(CancelTargetAssignmentEvent.class)) { + modifiedEvents.add(toExpectServiceEvent(CancelTargetAssignmentServiceEvent.class, event.count())); + } + } + return Optional.of(modifiedEvents.toArray(new Expect[0])); + } + return Optional.empty(); + } + + private static Expect toExpectServiceEvent(final Class clazz, final int count) { + return new Expect() { + + @Override + public Class annotationType() { + return Expect.class; + } + + @Override + public Class type() { + return clazz; + } + + @Override + public int count() { + return count; + } + }; } private void beforeTest(final TestContext testContext) { @@ -129,12 +189,12 @@ public class EventVerifier extends AbstractTestExecutionListener { testContext.getApplicationContext().getBean(ApplicationEventMulticaster.class).removeApplicationListener(eventCaptor); } - private static class EventCaptor implements ApplicationListener { + private static class EventCaptor implements ApplicationListener { private final ConcurrentHashMap, Integer> capturedEvents = new ConcurrentHashMap<>(); @Override - public void onApplicationEvent(final RemoteApplicationEvent event) { + public void onApplicationEvent(final AbstractRemoteEvent event) { log.debug("Received event {}", event.getClass().getSimpleName()); if (ResetCounterMarkerEvent.class.isAssignableFrom(event.getClass())) { @@ -170,13 +230,13 @@ public class EventVerifier extends AbstractTestExecutionListener { } } - private static final class ResetCounterMarkerEvent extends RemoteApplicationEvent { + private static final class ResetCounterMarkerEvent extends AbstractRemoteEvent { @Serial private static final long serialVersionUID = 1L; private ResetCounterMarkerEvent() { - super(new Object(), "resetcounter", DEFAULT_DESTINATION_FACTORY.getDestination(null)); + super("event-verifier"); } } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/AbstractIntegrationTest.java b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/AbstractIntegrationTest.java index 442ea2409..8344d0a00 100644 --- a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/AbstractIntegrationTest.java +++ b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/AbstractIntegrationTest.java @@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; import org.awaitility.Awaitility; import org.awaitility.core.ConditionFactory; +import org.eclipse.hawkbit.repository.artifact.ArtifactRepository; +import org.eclipse.hawkbit.repository.artifact.exception.ArtifactStoreException; import org.eclipse.hawkbit.repository.ArtifactManagement; import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; @@ -52,8 +54,6 @@ import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.TargetTagManagement; import org.eclipse.hawkbit.repository.TargetTypeManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; -import org.eclipse.hawkbit.repository.artifact.ArtifactRepository; -import org.eclipse.hawkbit.repository.artifact.exception.ArtifactStoreException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.ActionType; @@ -78,7 +78,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.cloud.bus.ServiceMatcher; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; @@ -181,8 +180,6 @@ public abstract class AbstractIntegrationTest { @Autowired protected TestdataFactory testdataFactory; - @Autowired(required = false) - protected ServiceMatcher serviceMatcher; @Autowired protected ApplicationEventPublisher eventPublisher; private static final String ARTIFACT_DIRECTORY = createTempDir().getAbsolutePath() + "/" + randomString(20); diff --git a/hawkbit-repository/hawkbit-repository-test/src/main/resources/hawkbit-test-defaults.properties b/hawkbit-repository/hawkbit-repository-test/src/main/resources/hawkbit-test-defaults.properties index de2d280b2..ba30c46b6 100644 --- a/hawkbit-repository/hawkbit-repository-test/src/main/resources/hawkbit-test-defaults.properties +++ b/hawkbit-repository/hawkbit-repository-test/src/main/resources/hawkbit-test-defaults.properties @@ -78,7 +78,4 @@ hawkbit.server.security.dos.maxTargetsPerAutoAssignment=20 hawkbit.server.security.dos.maxActionsPerTarget=20 # Quota - END -# Properties that are managed by autoconfigure module at runtime and not available during test - END - -# disable spring cloud bus for tests -spring.cloud.bus.enabled=false \ No newline at end of file +# Properties that are managed by autoconfigure module at runtime and not available during test - END \ No newline at end of file diff --git a/site/content/guides/clustering.md b/site/content/guides/clustering.md index 111b9f365..3d9c6f062 100644 --- a/site/content/guides/clustering.md +++ b/site/content/guides/clustering.md @@ -15,18 +15,65 @@ the [hawkBit runtimes's README](https://github.com/eclipse-hawkbit/hawkbit/blob/ ## Events -Event communication between nodes is based on [Spring Cloud Bus](https://cloud.spring.io/spring-cloud-bus/) -and [Spring Cloud Stream](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/). There are -different [binder implementations](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/#_binders) -available. The _hawkbit Update Server_ uses RabbitMQ binder. Every node gets his own queue to receive cluster events, -the default payload is JSON. -If an event is thrown locally at one node, it will be automatically delivered to all other available nodes via the -Spring Cloud Bus's topic exchange: +Event communication between nodes is based on [Spring Cloud Stream](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/). +There are different [binder implementations](http://docs.spring.io/spring-cloud-stream/docs/current/reference/htmlsingle/#_binders) +available. The _hawkbit Update Server_ uses RabbitMQ binder. -![](../../images/eventing-within-cluster.png) +You can run multiple instances of any micro-service, including those consuming events. +However, the `hawkbit-mgmt-server` should typically be run as a single instance, as it schedules time-sensitive jobs such as auto-assignment checking and rollout execution. +If multiple management server instances are needed, you must extend hawkBit to ensure that scheduled tasks do not run concurrently. +Alternatively, configure all but one instance to disable scheduler execution. -Via the ServiceMatcher you can check whether an event happened locally at one node or on a different node. -`serviceMatcher.isFromSelf(event)` +## Event Channel Types in Spring Cloud Stream + +Remote events in hawkBit are distributed through **two distinct types of channels**: + +### 1. Fanout Event Channel + +- Every service instance listening to `fanoutEventChannel` receives **a copy of every message**, regardless of instance count. +- Common for events that should be processed by each consumer independently + - In-memory cache updates + - Internal state propagation + - Logging or auditing +- Not recommended for scenarios where only one consumer should process an event (see `serviceEventChannel` for that). + +**Note**: Every instance bound to this channel will get its own copy of the message. + +### 2. Service Event Channel + +The `serviceEventChannel` is used to **ensure exclusive consumption of events** across service instances. +Only **one instance per consumer group** receives and processes each message, which is critical for non-idempotent or resource-sensitive operations. + +- Only one instance in a consumer group receives each message. +- Ideal for external integrations, third-party API calls, or any task that must not be duplicated. +- Load-balanced across instances within the same group. + + +### Optional Protostuff for Spring cloud stream + +The micro-service instances are configured to communicate via Spring Cloud Stream. Optionally, you could +use [Protostuff](https://github.com/protostuff/protostuff) based message payload serialization for improved performance. + +**Note**: If Protostuff is enabled it shall be enabled on all microservices! + +Add/Uncomment to/in your `application.properties` : + +```properties +spring.cloud.stream.default.content-type=application/binary+protostuff +``` + +Add to your `pom.xml` : + +```xml + + io.protostuff + protostuff-core + + + io.protostuff + protostuff-runtime + +``` ## Caching diff --git a/site/static/images/eventing-within-cluster.png b/site/static/images/eventing-within-cluster.png deleted file mode 100644 index 1baa538d131fa4c33c3091e134ac0fcdc2c7f11b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16299 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