diff --git a/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java b/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java index 910fb64d0..c07377ddd 100644 --- a/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java +++ b/hawkbit-ddi/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfigDataTest.java @@ -137,8 +137,9 @@ class DdiConfigDataTest extends AbstractDDiApiIntegrationTest { } @Test - @Description("We verify that the config data (i.e. device attributes like serial number, hardware revision etc.) " + - "upload quota is enforced to protect the server from malicious attempts.") + @Description(""" + We verify that the config data (i.e. device attributes like serial number, hardware revision etc.) + upload quota is enforced to protect the server from malicious attempts.""") void putTooMuchConfigData() throws Exception { testdataFactory.createTarget(TARGET1_ID); @@ -148,15 +149,16 @@ class DdiConfigDataTest extends AbstractDDiApiIntegrationTest { attributes.put("dsafsdf" + i, "sdsds" + i); } mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant()) - .content(JsonBuilder.configData(attributes).toString()).contentType(MediaType.APPLICATION_JSON)) + .content(JsonBuilder.configData(attributes).toString()) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); mvc.perform(put(TARGET1_CONFIG_DATA_PATH, tenantAware.getCurrentTenant()) - .content(JsonBuilder.configData(Map.of("on too many", "sdsds")).toString()).contentType(MediaType.APPLICATION_JSON)) + .content(JsonBuilder.configData(Map.of("on too many", "sdsds")).toString()) + .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.exceptionClass", equalTo(AssignmentQuotaExceededException.class.getName()))) .andExpect(jsonPath("$.errorCode", equalTo(SpServerError.SP_QUOTA_EXCEEDED.getKey()))); - } @Test diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java deleted file mode 100644 index e47b12ed6..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AbstractAmqpErrorHandler.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import org.springframework.amqp.AmqpRejectAndDontRequeueException; - -/** - * An abstract error handler for errors resulting from AMQP. - */ -public abstract class AbstractAmqpErrorHandler implements AmqpErrorHandler { - - @Override - public void doHandle(Throwable throwable, AmqpErrorHandlerChain chain) { - // retrieving the cause of throwable as it contains the actual class of exception - final Throwable cause = throwable.getCause(); - if (getExceptionClass().isAssignableFrom(cause.getClass())) { - throw new AmqpRejectAndDontRequeueException(getErrorMessage(throwable)); - } else { - chain.handle(throwable); - } - } - - /** - * Returns the class of the exception. - * - * @return the exception class - */ - public abstract Class getExceptionClass(); - - /** - * Returns the customized error message. - * - * @return the customized error message - */ - public String getErrorMessage(Throwable throwable) { - return AmqpErrorMessageComposer.constructErrorMessage(throwable); - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java index bf63e0cb6..c7485073f 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpConfiguration.java @@ -9,11 +9,14 @@ */ package org.eclipse.hawkbit.amqp; +import java.sql.SQLException; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; +import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.eclipse.hawkbit.artifact.repository.urlhandler.ArtifactUrlHandler; import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; @@ -36,11 +39,14 @@ import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler; +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.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -65,7 +71,8 @@ public class AmqpConfiguration { private final ConnectionFactory rabbitConnectionFactory; private ServiceMatcher serviceMatcher; - public AmqpConfiguration(final AmqpProperties amqpProperties, final AmqpDeadletterProperties amqpDeadletterProperties, final ConnectionFactory rabbitConnectionFactory) { + public AmqpConfiguration(final AmqpProperties amqpProperties, final AmqpDeadletterProperties amqpDeadletterProperties, + final ConnectionFactory rabbitConnectionFactory) { this.amqpProperties = amqpProperties; this.amqpDeadletterProperties = amqpDeadletterProperties; this.rabbitConnectionFactory = rabbitConnectionFactory; @@ -76,48 +83,24 @@ public class AmqpConfiguration { this.serviceMatcher = serviceMatcher; } + @Bean + public FatalExceptionStrategy sqlFatalSQLExceptionStrategy(final AmqpProperties amqpProperties) { + return new SqlFatalExceptionStrategy(amqpProperties.getFatalSqlExceptionPolicy()); + } + /** * Creates a custom error handler bean. * - * @param handlers list of {@link AmqpErrorHandler} handlers + * @param fatalExceptionStrategies list of {@link FatalExceptionStrategy} handlers. isFatal will be called for causes, + * up to the first fatal, so the implementation don't need to iterate over the causes. * @return the delegating error handler bean */ @Bean @ConditionalOnMissingBean - public ErrorHandler errorHandler(final List handlers) { - return new DelegatingConditionalErrorHandler( - handlers, - new ConditionalRejectingErrorHandler(new DelayedRequeueExceptionStrategy(amqpProperties.getRequeueDelay()))); - } - - /** - * Error handler bean for all target attributes related fatal errors - * - * @return the invalid target attribute exception handler bean - */ - @Bean - public AmqpErrorHandler invalidTargetAttributeConditionalExceptionHandler() { - return new InvalidTargetAttributeExceptionHandler(); - } - - /** - * Error handler bean for entity not found errors - * - * @return the entity not found exception handler bean - */ - @Bean - public AmqpErrorHandler entityNotFoundExceptionHandler() { - return new EntityNotFoundExceptionHandler(); - } - - /** - * Error handler bean for amqp message conversion errors - * - * @return the amqp message conversion exception handler bean - */ - @Bean - public AmqpErrorHandler messageConversionExceptionHandler() { - return new MessageConversionExceptionHandler(); + public ErrorHandler errorHandler( + final List fatalExceptionStrategies, + @Value("${hawkbit.dmf.rabbitmq.fatalExceptionTypes:}") final List fatalExceptionTypes) { + return new ConditionalRejectingErrorHandler(new RequeueExceptionStrategy(fatalExceptionStrategies, fatalExceptionTypes)); } /** @@ -303,4 +286,39 @@ public class AmqpConfiguration { args.put("x-max-length", 1_000); return args; } + + @ToString + private static class SqlFatalExceptionStrategy implements FatalExceptionStrategy { + + private final boolean fatalByDefault; + private final List unlessErrorCodeIn; + private final List unlessSqlStateIn; + private final List unlessMessageMatches; + + public SqlFatalExceptionStrategy(final AmqpProperties.FatalSqlExceptionPolicy fatalSqlExceptions) { + this.fatalByDefault = fatalSqlExceptions.isByDefault(); + this.unlessErrorCodeIn = fatalSqlExceptions.getUnlessErrorCodeIn(); + this.unlessSqlStateIn = fatalSqlExceptions.getUnlessSqlStateIn(); + this.unlessMessageMatches = fatalSqlExceptions.getUnlessMessageMatches(); + } + + @Override + public boolean isFatal(final Throwable t) { + if (t instanceof SQLException sqlException) { + if (unlessErrorCodeIn.contains(sqlException.getErrorCode())) { + return !fatalByDefault; + } else if (unlessSqlStateIn.contains(sqlException.getSQLState())) { + return !fatalByDefault; + } else { + for (final Pattern pattern : unlessMessageMatches) { + if (pattern.matcher(sqlException.getMessage()).matches()) { + return !fatalByDefault; + } + } + return fatalByDefault; + } + } + return false; + } + } } \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java deleted file mode 100644 index b4454fcb0..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandler.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -/** - * Interface declaration of {@link AmqpErrorHandler} that handles errors based on the types of exception. - */ -@FunctionalInterface -public interface AmqpErrorHandler { - - /** - * Handles the error based on the type of exception - * - * @param throwable the throwable - * @param chain an {@link AmqpErrorHandlerChain} - */ - void doHandle(final Throwable throwable, final AmqpErrorHandlerChain chain); -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java deleted file mode 100644 index 58985a075..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpErrorHandlerChain.java +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import java.util.Iterator; -import java.util.List; - -import org.springframework.util.ErrorHandler; - -/** - * An error handler chain that delegates the error to the matching error handler based on the type of exception - */ -public final class AmqpErrorHandlerChain { - - private final Iterator iterator; - private final ErrorHandler defaultHandler; - - /** - * Constructor. - * - * @param iterator the {@link AmqpErrorHandler} iterator - * @param defaultHandler the default handler - */ - private AmqpErrorHandlerChain(Iterator iterator, ErrorHandler defaultHandler) { - this.iterator = iterator; - this.defaultHandler = defaultHandler; - } - - /** - * Returns an {@link AmqpErrorHandlerChain} - * - * @param errorHandlers {@link List} of error handlers - * @param defaultHandler the default error handler - * @return an {@link AmqpErrorHandlerChain} - */ - public static AmqpErrorHandlerChain getHandlerChain(final List errorHandlers, final ErrorHandler defaultHandler) { - return new AmqpErrorHandlerChain(errorHandlers.iterator(), defaultHandler); - } - - /** - * Handles the error based on the type of exception - * - * @param error the throwable containing the cause of exception - */ - public void handle(final Throwable error) { - if (iterator.hasNext()) { - final AmqpErrorHandler handler = iterator.next(); - handler.doHandle(error, this); - } else { - defaultHandler.handleError(error); - } - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpProperties.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpProperties.java index f1a459bb3..cf6d0e98d 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpProperties.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpProperties.java @@ -9,19 +9,21 @@ */ package org.eclipse.hawkbit.amqp; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Bean which holds the necessary properties for configuring the AMQP - * connection. + * Bean which holds the necessary properties for configuring the AMQP connection. */ @Data @ConfigurationProperties("hawkbit.dmf.rabbitmq") public class AmqpProperties { private static final int DEFAULT_QUEUE_DECLARATION_RETRIES = 50; - private static final long DEFAULT_REQUEUE_DELAY = 0; /** * Enable DMF API based on AMQP 0.9 @@ -44,8 +46,7 @@ public class AmqpProperties { private String receiverQueue = "dmf_receiver"; /** - * Authentication request called by 3rd party artifact storages for download - * authorizations. + * Authentication request called by 3rd party artifact storages for download authorizations. */ private String authenticationReceiverQueue = "authentication_receiver"; @@ -56,14 +57,37 @@ public class AmqpProperties { /** * The number of retry attempts when passive queue declaration fails. - * Passive queue declaration occurs when the consumer starts or, when - * consuming from multiple queues, when not all queues were available during - * initialization. + * Passive queue declaration occurs when the consumer starts or, when consuming from multiple queues, when not all queues were + * available during initialization. */ private int declarationRetries = DEFAULT_QUEUE_DECLARATION_RETRIES; /** - * Delay for messages that are requeued in milliseconds. + * Represents which {@link }SQLExceptions} should be considered fatal. By default, (without any configuration) it's simply disabled. */ - private long requeueDelay = DEFAULT_REQUEUE_DELAY; + private final FatalSqlExceptionPolicy fatalSqlExceptionPolicy = new FatalSqlExceptionPolicy(); + + @Data + public static class FatalSqlExceptionPolicy { + + /** + * The mode of the policy. If set to {@code true}, the every {@link java.sql.SQLException} would be assessed as fatal unless + * matching the filters. Otherwise, every {@link java.sql.SQLException} will be assessed as non-fatal unless matching the filter. + * The {@link java.sql.SQLException} that matches the filters are considered non-fatal if byDefault is {@code true} and fatal otherwise. + */ + private boolean byDefault = false; + /** + * Error codes of the {@link java.sql.SQLException} that will be excluded from the default fatal policy. DB depended. + */ + private final List unlessErrorCodeIn = new ArrayList<>(); + /** + * SQL states of the {@link java.sql.SQLException} that will be excluded from the default fatal policy. DB depended. + */ + private final List unlessSqlStateIn = new ArrayList<>(); + /** + * Java regex message matching patterns. The {@link java.sql.SQLException} with messages matching any of the patterns + * will be excluded from the default fatal policy. DB depended. + */ + private final List unlessMessageMatches = new ArrayList<>(); + } } \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelayedRequeueExceptionStrategy.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelayedRequeueExceptionStrategy.java deleted file mode 100644 index 8a5524c30..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelayedRequeueExceptionStrategy.java +++ /dev/null @@ -1,93 +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 - */ -package org.eclipse.hawkbit.amqp; - -import java.util.concurrent.TimeUnit; - -import jakarta.validation.ConstraintViolationException; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException; -import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException; -import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; -import org.eclipse.hawkbit.repository.exception.InvalidTargetAddressException; -import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; -import org.eclipse.hawkbit.repository.exception.TenantNotExistException; -import org.springframework.amqp.rabbit.listener.ConditionalRejectingErrorHandler; -import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy; -import org.springframework.amqp.support.converter.MessageConversionException; -import org.springframework.messaging.MessageHandlingException; - -/** - * Custom {@link FatalExceptionStrategy} that markes defined hawkBit internal - * exceptions not to be requeued. In addition it throttles in case of a requeue - * by means of blocking the processing thread for a certain amount of time. That - * avoids a back and forth between broker and hawkBit at maximum speed. - */ -@Slf4j -public class DelayedRequeueExceptionStrategy extends ConditionalRejectingErrorHandler.DefaultExceptionStrategy { - - private final long delay; - - /** - * @param delay in {@link TimeUnit#MILLISECONDS} before requeue. - */ - public DelayedRequeueExceptionStrategy(final long delay) { - this.delay = delay; - } - - @Override - protected boolean isUserCauseFatal(final Throwable cause) { - if (invalidMessage(cause)) { - return true; - } - - log.error("Found a message that has to be requeued. Processing with delay of {}ms: ", delay, cause); - - try { - TimeUnit.MILLISECONDS.sleep(delay); - } catch (final InterruptedException e) { - log.error("Delay interrupted!", e); - Thread.currentThread().interrupt(); - } - - return false; - } - - private static boolean invalidMessage(final Throwable cause) { - return doesNotExist(cause) || quotaHit(cause) || invalidContent(cause) || invalidState(cause); - } - - private static boolean invalidState(final Throwable cause) { - return cause instanceof CancelActionNotAllowedException; - } - - private static boolean quotaHit(final Throwable cause) { - return cause instanceof AssignmentQuotaExceededException; - } - - private static boolean doesNotExist(final Throwable cause) { - return cause instanceof TenantNotExistException || cause instanceof EntityNotFoundException; - } - - private static boolean invalidContent(final Throwable cause) { - return isRepositoryException(cause) || isMessageException(cause); - } - - private static boolean isRepositoryException(final Throwable cause) { - return cause instanceof ConstraintViolationException || cause instanceof InvalidTargetAttributeException; - } - - private static boolean isMessageException(final Throwable cause) { - return cause instanceof InvalidTargetAddressException || - cause instanceof MessageConversionException || - cause instanceof MessageHandlingException; - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java deleted file mode 100644 index 3f062f5f0..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/DelegatingConditionalErrorHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import java.util.List; - -import jakarta.validation.constraints.NotNull; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.amqp.AmqpRejectAndDontRequeueException; -import org.springframework.util.ErrorHandler; - -/** - * An error handler delegates error handling to the matching {@link AmqpErrorHandler} based on the type of exception - */ -@Slf4j -public class DelegatingConditionalErrorHandler implements ErrorHandler { - - private final List handlers; - private final ErrorHandler defaultHandler; - - /** - * Constructor - * - * @param handlers {@link List} of error handlers - * @param defaultHandler the default error handler - */ - public DelegatingConditionalErrorHandler(final List handlers, @NotNull final ErrorHandler defaultHandler) { - this.handlers = handlers; - this.defaultHandler = defaultHandler; - } - - @Override - public void handleError(final Throwable t) { - if (t.getCause() == null) { - log.error("Cannot handle the error as the cause of the error is null!"); - return; - } - - if (includesAmqpRejectException(t.getCause())) { - log.error("Received an AmqpRejectAndDontRequeueException due to {}", t.getCause().getMessage()); - return; - } - - AmqpErrorHandlerChain.getHandlerChain(handlers, defaultHandler).handle(t); - } - - private boolean includesAmqpRejectException(final Throwable t) { - if (t instanceof AmqpRejectAndDontRequeueException) { - return true; - } - if (t.getCause() != null) { - return includesAmqpRejectException(t.getCause()); - } - return false; - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java deleted file mode 100644 index 86d40b495..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/EntityNotFoundExceptionHandler.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; - -/** - * An error handler for entity not found exception resulting from AMQP. - */ -public class EntityNotFoundExceptionHandler extends AbstractAmqpErrorHandler { - - @Override - public Class getExceptionClass() { - return EntityNotFoundException.class; - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java deleted file mode 100644 index ebcb034e3..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/InvalidTargetAttributeExceptionHandler.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; - -/** - * An error handler for all invalid target attributes resulting from AMQP. - */ -public class InvalidTargetAttributeExceptionHandler extends AbstractAmqpErrorHandler { - - @Override - public Class getExceptionClass() { - return InvalidTargetAttributeException.class; - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java deleted file mode 100644 index e126b28c5..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/MessageConversionExceptionHandler.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import java.util.Optional; - -import com.fasterxml.jackson.databind.exc.InvalidFormatException; -import org.springframework.amqp.support.converter.MessageConversionException; - -/** - * An error handler for message conversion exception resulting from AMQP. - */ -public class MessageConversionExceptionHandler extends AbstractAmqpErrorHandler { - - @Override - public Class getExceptionClass() { - return MessageConversionException.class; - } - - @Override - public String getErrorMessage(Throwable throwable) { - final String errorMessage = super.getErrorMessage(throwable); - // since the detailed error message lies in the first parent of current throwable we retrieve it and append it to the errorMessage - final Optional detailedErrorMessage = getFirstAncestralErrorMessage(throwable.getCause()); - return detailedErrorMessage.isPresent() ? (detailedErrorMessage.get() + errorMessage) : errorMessage; - } - - private Optional getFirstAncestralErrorMessage(final Throwable throwable) { - if (throwable.getCause() instanceof InvalidFormatException) { - return Optional.of(throwable.getCause().getMessage()); - } - return Optional.empty(); - } -} diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/RequeueExceptionStrategy.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/RequeueExceptionStrategy.java new file mode 100644 index 000000000..387ba449e --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/RequeueExceptionStrategy.java @@ -0,0 +1,125 @@ +/** + * 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.amqp; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.validation.ConstraintViolationException; + +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException; +import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException; +import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidTargetAddressException; +import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; +import org.eclipse.hawkbit.repository.exception.TenantNotExistException; +import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy; +import org.springframework.amqp.support.converter.MessageConversionException; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.util.ObjectUtils; + +/** + * Custom {@link FatalExceptionStrategy} that marks defined hawkBit internal exceptions not to be re-queued. + */ +@ToString +@Slf4j +class RequeueExceptionStrategy implements FatalExceptionStrategy { + + private final List fatalExceptionStrategies = new ArrayList<>(); + + @SuppressWarnings("unchecked") + RequeueExceptionStrategy(final List fatalExceptionStrategies, final List fatalExceptionTypes) { + this.fatalExceptionStrategies.add(new TypeBasedFatalExceptionStrategy( + // default, see DefaultExceptionStrategy + MessageConversionException.class, + org.springframework.messaging.converter.MessageConversionException.class, + MethodArgumentResolutionException.class, NoSuchMethodException.class, ClassCastException.class, + // invalid state + CancelActionNotAllowedException.class, + // quota hit + AssignmentQuotaExceededException.class, + // does not exist + TenantNotExistException.class, EntityNotFoundException.class, + // is invalid content, repository exception + ConstraintViolationException.class, InvalidTargetAttributeException.class, + // is invalid content, message exception + InvalidTargetAddressException.class, MessageHandlingException.class + )); + if (!ObjectUtils.isEmpty(fatalExceptionTypes)) { + // add explicitly configured fatal exception types + fatalExceptionTypes.forEach(type -> { + try { + final Class clazz = Class.forName(type); + if (Throwable.class.isAssignableFrom(clazz)) { + this.fatalExceptionStrategies.add(new TypeBasedFatalExceptionStrategy((Class) clazz)); + } else { + log.warn("Fatal exception type {} is not a Throwable", type); + } + } catch (final ClassNotFoundException e) { + log.warn("Could not find class for fatal exception type {}", type); + } + }); + } + this.fatalExceptionStrategies.addAll(fatalExceptionStrategies); + log.info("RequeueExceptionStrategy created: {}", this); + } + + @Override + public boolean isFatal(final Throwable t) { + for (Throwable cause = t; cause != null; cause = cause.getCause()) { + // default exception from DefaultExceptionStrategy + if (isCauseFatal(cause)) { + return true; + } + } + + if (log.isDebugEnabled()) { + log.warn("Found a message that has to be re-queued", t); + } else { + log.warn("Found a message that has to be re-queued: {}", t.getMessage()); + } + + return false; + } + + protected boolean isCauseFatal(final Throwable cause) { + for (final FatalExceptionStrategy handler : fatalExceptionStrategies) { + if (handler.isFatal(cause)) { + return true; + } + } + + return false; + } + + @ToString + public static class TypeBasedFatalExceptionStrategy implements FatalExceptionStrategy { + + private final Class[] types; + + @SafeVarargs + public TypeBasedFatalExceptionStrategy(final Class... types) { + this.types = types; + } + + @Override + public boolean isFatal(final Throwable cause) { + for (final Class type : types) { + if (type.isAssignableFrom(cause.getClass())) { + return true; + } + } + return false; + } + } +} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java deleted file mode 100644 index fada7a40a..000000000 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/DelegatingAmqpErrorHandlerTest.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2021 Bosch.IO 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 - */ -package org.eclipse.hawkbit.amqp; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -import java.util.List; - -import io.qameta.allure.Description; -import io.qameta.allure.Feature; -import io.qameta.allure.Story; -import org.junit.jupiter.api.Test; -import org.springframework.util.ErrorHandler; - -@Feature("Unit Tests - Delegating Conditional Error Handler") -@Story("Delegating Conditional Error Handler") -class DelegatingAmqpErrorHandlerTest { - - private final DelegatingConditionalErrorHandler delegatingConditionalErrorHandler = - new DelegatingConditionalErrorHandler( - List.of(new IllegalArgumentExceptionHandler(), new IndexOutOfBoundsExceptionHandler()), - new DefaultErrorHandler()); - - @Test - @Description("Verifies that with a list of conditional error handlers, the error is delegated to specific handler.") - void verifyDelegationHandling() { - final Throwable error = new Throwable(new IllegalArgumentException()); - assertThatExceptionOfType(IllegalArgumentException.class) - .as("Expected handled exception to be of type IllegalArgumentException") - .isThrownBy(() -> delegatingConditionalErrorHandler.handleError(error)); - } - - @Test - @Description("Verifies that default handler is used if no handlers are defined for the specific exception.") - void verifyDefaultDelegationHandling() { - final Throwable error = new Throwable(new NullPointerException()); - assertThatExceptionOfType(RuntimeException.class) - .as("Expected handled exception to be of type RuntimeException") - .isThrownBy(() -> delegatingConditionalErrorHandler.handleError(error)); - } - - // Test class - static class IllegalArgumentExceptionHandler implements AmqpErrorHandler { - - @Override - public void doHandle(final Throwable t, final AmqpErrorHandlerChain chain) { - if (t.getCause() instanceof IllegalArgumentException) { - throw new IllegalArgumentException(t.getMessage()); - } else { - chain.handle(t); - } - } - } - - // Test class - static class IndexOutOfBoundsExceptionHandler implements AmqpErrorHandler { - - @Override - public void doHandle(final Throwable t, final AmqpErrorHandlerChain chain) { - if (t.getCause() instanceof IndexOutOfBoundsException) { - throw new IndexOutOfBoundsException(t.getMessage()); - } else { - chain.handle(t); - } - } - } - - // Test class - static class DefaultErrorHandler implements ErrorHandler { - - @Override - public void handleError(final Throwable t) { - throw new RuntimeException(t); - } - } -} \ No newline at end of file diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/RequestExceptionStrategyTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/RequestExceptionStrategyTest.java new file mode 100644 index 000000000..442ac9d84 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/RequestExceptionStrategyTest.java @@ -0,0 +1,58 @@ +/** + * 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.amqp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import io.qameta.allure.Description; +import io.qameta.allure.Feature; +import io.qameta.allure.Story; +import org.junit.jupiter.api.Test; +import org.springframework.amqp.rabbit.listener.FatalExceptionStrategy; +import org.springframework.amqp.support.converter.MessageConversionException; + +@Feature("Unit Tests - Requeue Exception Strategy") +@Story("Requeue Exception Strategy") +class RequestExceptionStrategyTest { + + private final FatalExceptionStrategy requeueExceptionStrategy = new RequeueExceptionStrategy( + List.of(new RequeueExceptionStrategy.TypeBasedFatalExceptionStrategy( + IllegalArgumentException.class, IndexOutOfBoundsException.class)), null); + + @Test + @Description("Verifies that default handler is used if no handlers are defined for the specific exception.") + void verifyDefaultFatal() { + assertThat(requeueExceptionStrategy.isFatal(new MessageConversionException("t"))).as("Non Fatal error").isTrue(); + assertThat(requeueExceptionStrategy.isFatal(new Throwable(new MessageConversionException("t")))).as("Non Fatal error").isTrue(); + } + + @Test + @Description("Verifies additional fatal exception types are fatal.") + void verifyAdditionalFatal() { + assertThat(requeueExceptionStrategy.isFatal(new IllegalArgumentException())).isTrue(); + assertThat(requeueExceptionStrategy.isFatal(new IndexOutOfBoundsException())).isTrue(); + } + + @Test + @Description("Verifies additional fatal exception types are fatal.") + void verifyAdditionalWrappedFatal() { + assertThat(requeueExceptionStrategy.isFatal(new Throwable(new IllegalArgumentException()))).isTrue(); + assertThat(requeueExceptionStrategy.isFatal(new Throwable(new IndexOutOfBoundsException()))).isTrue(); + } + + @Test + @Description("Verifies that default handler is used if no handlers are defined for the specific exception.") + void verifyNonFatal() { + assertThat(requeueExceptionStrategy.isFatal(new NullPointerException())).isFalse(); + assertThat(requeueExceptionStrategy.isFatal(new Throwable(new NullPointerException()))).isFalse(); + } +} \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java index 96b1f75ab..c760fb9e7 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaControllerManagement.java @@ -31,6 +31,7 @@ import java.util.concurrent.BlockingDeque; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -126,6 +127,8 @@ import org.springframework.validation.annotation.Validated; @Validated public class JpaControllerManagement extends JpaActionManagement implements ControllerManagement { + private static final Pattern PATTERN = Pattern.compile("[a-zA-Z0-9_\\-!@#$%^&*()+=\\[\\]{}|;:'\",.<>/\\\\?\\s]*"); + private final BlockingDeque queue; // TODO - make it final @@ -197,7 +200,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont final long occurredAt = newActionStatus.getOccurredAt(); switch (updatedActionStatus) { case ERROR: { - final JpaTarget target = (JpaTarget) action.getTarget(); + final JpaTarget target = action.getTarget(); target.setUpdateStatus(TargetUpdateStatus.ERROR); handleErrorOnAction(action, target); break; @@ -512,7 +515,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont if (action.isActive()) { log.debug("action ({}) was still active. Change to {}.", action, Status.CANCELING); - final JpaAction jpaAction = (JpaAction)action; + final JpaAction jpaAction = (JpaAction) action; jpaAction.setStatus(Status.CANCELING); // document that the status has been retrieved @@ -533,7 +536,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont targetRepository.getAccessController().ifPresent( accessController -> accessController.assertOperationAllowed( AccessController.Operation.UPDATE, - (JpaTarget) actionRepository + actionRepository .findById(actionId) .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)) .getTarget())); @@ -615,11 +618,11 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont } private static boolean isAttributeKeyValid(final String key) { - return key != null && key.length() <= CONTROLLER_ATTRIBUTE_KEY_SIZE; + return key != null && key.length() <= CONTROLLER_ATTRIBUTE_KEY_SIZE && PATTERN.matcher(key).matches(); } private static boolean isAttributeValueValid(final String value) { - return value == null || value.length() <= CONTROLLER_ATTRIBUTE_VALUE_SIZE; + return value == null || (value.length() <= CONTROLLER_ATTRIBUTE_VALUE_SIZE && PATTERN.matcher(value).matches()); } private static void copy(final Map src, final Map trg) { @@ -820,7 +823,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont return Optional.empty(); } - final JpaTarget target = (JpaTarget) action.getTarget(); + final JpaTarget target = action.getTarget(); action.setActive(false); action.setStatus(DOWNLOADED); target.setUpdateStatus(TargetUpdateStatus.IN_SYNC); @@ -854,11 +857,11 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont * @return a present controllerId in case the attributes needs to be requested. */ private JpaTarget handleFinishedAndStoreInTargetStatus(final long occurredAt, final JpaAction action) { - final JpaTarget target = (JpaTarget) action.getTarget(); + final JpaTarget target = action.getTarget(); action.setActive(false); action.setStatus(Status.FINISHED); if (target.getInstallationDate() == null || target.getInstallationDate() < occurredAt) { - final JpaDistributionSet ds = (JpaDistributionSet) entityManager.merge(action.getDistributionSet()); + final JpaDistributionSet ds = entityManager.merge(action.getDistributionSet()); target.setInstalledDistributionSet(ds); target.setInstallationDate(occurredAt); @@ -866,7 +869,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont // Target reported an installation of a DOWNLOAD_ONLY assignment, the assigned DS has to be adapted // because the currently assigned DS can be unequal to the currently installed DS (the downloadOnly DS) if (isDownloadOnly(action)) { - target.setAssignedDistributionSet((JpaDistributionSet) action.getDistributionSet()); + target.setAssignedDistributionSet(action.getDistributionSet()); } // check if the assigned set is equal to the installed set (not diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFieldTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFieldTest.java index 7c63da293..150204e45 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFieldTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFieldTest.java @@ -47,7 +47,6 @@ class RSQLTargetFieldTest extends AbstractJpaIntegrationTest { @BeforeEach void setupBeforeTest() { - final DistributionSet ds = testdataFactory.createDistributionSet("AssignedDs"); final Map attributes = new HashMap<>();