diff --git a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/security/SecurityManagedConfiguration.java b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/security/SecurityManagedConfiguration.java index bed9a49a3..410004533 100644 --- a/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/security/SecurityManagedConfiguration.java +++ b/hawkbit-autoconfigure/src/main/java/org/eclipse/hawkbit/autoconfigure/security/SecurityManagedConfiguration.java @@ -159,6 +159,7 @@ public class SecurityManagedConfiguration { static class ControllerSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter { private static final String[] DDI_ANT_MATCHERS = { DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}", + DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/confirmationBase/**", DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/deploymentBase/**", DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/installedBase/**", DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/cancelAction/**", diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java index c640d597a..4e12ceff8 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java @@ -20,10 +20,23 @@ public enum SpServerError { SP_REPO_GENERIC_ERROR("hawkbit.server.error.repo.genericError", "unknown error occurred"), /** - * - */ + * + */ SP_REPO_ENTITY_ALREADY_EXISTS("hawkbit.server.error.repo.entitiyAlreayExists", - "The given entity already exists in database"), + "The given entity already exists in database"), + + /** + * + */ + SP_REPO_AUTO_CONFIRMATION_ALREADY_ACTIVE("hawkbit.server.error.repo.autoConfAlreadyActive", + "Auto confirmation is already active"), + + + /** + * + */ + SP_CONFIRMATION_FEEDBACK_INVALID("hawkbit.server.confirmation.feedback.invalid", + "Confirmation feedback is not valid"), /** * 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 19b326d80..e727bb0b8 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 @@ -14,6 +14,7 @@ import org.eclipse.hawkbit.api.HostnameResolver; import org.eclipse.hawkbit.cache.DownloadIdCache; import org.eclipse.hawkbit.dmf.amqp.api.AmqpSettings; import org.eclipse.hawkbit.repository.ArtifactManagement; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; @@ -275,9 +276,10 @@ public class AmqpConfiguration { final AmqpMessageDispatcherService amqpMessageDispatcherService, final ControllerManagement controllerManagement, final EntityFactory entityFactory, final SystemSecurityContext systemSecurityContext, - final TenantConfigurationManagement tenantConfigurationManagement) { + final TenantConfigurationManagement tenantConfigurationManagement, + final ConfirmationManagement confirmationManagement) { return new AmqpMessageHandlerService(rabbitTemplate, amqpMessageDispatcherService, controllerManagement, - entityFactory, systemSecurityContext, tenantConfigurationManagement); + entityFactory, systemSecurityContext, tenantConfigurationManagement, confirmationManagement); } /** 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 933b67012..94b2b9ea5 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 @@ -36,6 +36,7 @@ import org.eclipse.hawkbit.dmf.json.model.DmfActionRequest; import org.eclipse.hawkbit.dmf.json.model.DmfArtifact; import org.eclipse.hawkbit.dmf.json.model.DmfArtifactHash; import org.eclipse.hawkbit.dmf.json.model.DmfBatchDownloadAndUpdateRequest; +import org.eclipse.hawkbit.dmf.json.model.DmfConfirmRequest; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; import org.eclipse.hawkbit.dmf.json.model.DmfMetadata; import org.eclipse.hawkbit.dmf.json.model.DmfMultiActionRequest; @@ -273,6 +274,8 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { final Map> softwareModules) { if (action.isCancelingOrCanceled()) { return createPlainActionRequest(action); + } else if (action.isWaitingConfirmation()) { + return createConfirmRequest(target, action.getId(), softwareModules); } return createDownloadAndUpdateRequest(target, action.getId(), softwareModules); } @@ -310,6 +313,9 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { * @return {@link EventTopic} to use for message. */ private static EventTopic getEventTypeForTarget(final ActionProperties action) { + if (action.isWaitingConfirmation()) { + return EventTopic.CONFIRM; + } return (Action.ActionType.DOWNLOAD_ONLY == action.getActionType() || !action.isMaintenanceWindowAvailable()) ? EventTopic.DOWNLOAD : EventTopic.DOWNLOAD_AND_INSTALL; @@ -415,9 +421,16 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { return; } - final DmfDownloadAndUpdateRequest downloadAndUpdateRequest = createDownloadAndUpdateRequest(target, - action.getId(), modules); - final Message message = getMessageConverter().toMessage(downloadAndUpdateRequest, + DmfActionRequest request; + if (action.isWaitingConfirmation()) { + // For the moment the confirmation request is the same as download and update request. + // It can be modified not to expose all the software modules in the future. + request = createConfirmRequest(target, action.getId(), modules); + } else { + request = createDownloadAndUpdateRequest(target, action.getId(), modules); + } + + final Message message = getMessageConverter().toMessage(request, createConnectorMessagePropertiesEvent(tenant, target.getControllerId(), getEventTypeForTarget(action))); amqpSenderService.sendMessage(message, targetAddress); } @@ -624,4 +637,25 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { ? EventTopic.BATCH_DOWNLOAD : EventTopic.BATCH_DOWNLOAD_AND_INSTALL; } + + /** + * Creates a Confirmation request. + * @param target the target + * @param actionId the actionId + * @param softwareModules the software modules + * @return + */ + protected DmfConfirmRequest createConfirmRequest(final Target target, final Long actionId, final Map> softwareModules) { + final DmfConfirmRequest request = new DmfConfirmRequest(); + request.setActionId(actionId); + request.setTargetSecurityToken(systemSecurityContext.runAsSystem(target::getSecurityToken)); + + //Software modules can be filtered in the future exposing only the needed. + if (softwareModules != null) { + softwareModules.entrySet() + .forEach(entry -> request.addSoftwareModule(convertToAmqpSoftwareModule(target, entry))); + } + return request; + } } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerService.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerService.java index 25e6e0a4d..1c64f4195 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerService.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/main/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerService.java @@ -24,12 +24,15 @@ import java.util.stream.Collectors; import org.eclipse.hawkbit.dmf.amqp.api.EventTopic; import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; import org.eclipse.hawkbit.dmf.amqp.api.MessageType; +import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; +import org.eclipse.hawkbit.dmf.json.model.DmfAutoConfirmation; import org.eclipse.hawkbit.dmf.json.model.DmfCreateThing; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; import org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpressions; import org.eclipse.hawkbit.im.authentication.TenantAwareAuthenticationDetails; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.RepositoryConstants; @@ -75,6 +78,7 @@ public class AmqpMessageHandlerService extends BaseAmqpService { private final AmqpMessageDispatcherService amqpMessageDispatcherService; private ControllerManagement controllerManagement; + private ConfirmationManagement confirmationManagement; private final EntityFactory entityFactory; @@ -101,18 +105,21 @@ public class AmqpMessageHandlerService extends BaseAmqpService { * the system Security Context * @param tenantConfigurationManagement * the tenant configuration Management + * @param confirmationManagement + * the confirmation management */ public AmqpMessageHandlerService(final RabbitTemplate rabbitTemplate, final AmqpMessageDispatcherService amqpMessageDispatcherService, final ControllerManagement controllerManagement, final EntityFactory entityFactory, final SystemSecurityContext systemSecurityContext, - final TenantConfigurationManagement tenantConfigurationManagement) { + final TenantConfigurationManagement tenantConfigurationManagement, final ConfirmationManagement confirmationManagement) { super(rabbitTemplate); this.amqpMessageDispatcherService = amqpMessageDispatcherService; this.controllerManagement = controllerManagement; this.entityFactory = entityFactory; this.systemSecurityContext = systemSecurityContext; this.tenantConfigurationManagement = tenantConfigurationManagement; + this.confirmationManagement = confirmationManagement; } /** @@ -314,6 +321,9 @@ public class AmqpMessageHandlerService extends BaseAmqpService { case UPDATE_ATTRIBUTES: updateAttributes(message); break; + case UPDATE_AUTO_CONFIRM: + setAutoConfirmationState(message); + break; default: logAndThrowMessageError(message, "Got event without appropriate topic."); break; @@ -331,7 +341,23 @@ public class AmqpMessageHandlerService extends BaseAmqpService { final String thingId = getStringHeaderKey(message, MessageHeaderKey.THING_ID, THING_ID_NULL); controllerManagement.updateControllerAttributes(thingId, attributeUpdate.getAttributes(), - getUpdateMode(attributeUpdate)); + getUpdateMode(attributeUpdate)); + } + + private void setAutoConfirmationState(final Message message) { + final DmfAutoConfirmation autoConfirmation = convertMessage(message, DmfAutoConfirmation.class); + final String thingId = getStringHeaderKey(message, MessageHeaderKey.THING_ID, THING_ID_NULL); + if (autoConfirmation.isEnabled()) { + LOG.debug("Activate auto-confirmation for device {} using DMF. Initiator: {}. Remark: {}", thingId, + autoConfirmation.getInitiator(), autoConfirmation.getRemark()); + final String remark = autoConfirmation.getRemark() == null + ? "Activated using Device Management Federation API." + : autoConfirmation.getRemark(); + controllerManagement.activateAutoConfirmation(thingId, autoConfirmation.getInitiator(), remark); + } else { + LOG.debug("Deactivate auto-confirmation for device {} using DMF.", thingId); + controllerManagement.deactivateAutoConfirmation(thingId); + } } /** @@ -352,19 +378,27 @@ public class AmqpMessageHandlerService extends BaseAmqpService { } final Status status = mapStatus(message, actionUpdateStatus, action); - final ActionStatusCreate actionStatus = entityFactory.actionStatus().create(action.getId()).status(status) - .messages(messages); - actionUpdateStatus.getCode().ifPresent(code -> { - actionStatus.code(code); - actionStatus.message("Device reported status code: " + code); - }); + final Action updatedAction; - final Action updatedAction = (Status.CANCELED == status) - ? controllerManagement.addCancelActionStatus(actionStatus) - : controllerManagement.addUpdateActionStatus(actionStatus); + if (actionUpdateStatus.getActionStatus() == DmfActionStatus.CONFIRMED) { + updatedAction = confirmationManagement.confirmAction(action.getId(), + actionUpdateStatus.getCode().orElse(null), messages); + } else if (actionUpdateStatus.getActionStatus() == DmfActionStatus.DENIED) { + updatedAction = confirmationManagement.denyAction(action.getId(), actionUpdateStatus.getCode().orElse(null), + messages); + } else { + final ActionStatusCreate actionStatus = entityFactory.actionStatus().create(action.getId()).status(status) + .messages(messages); + actionUpdateStatus.getCode().ifPresent(code -> { + actionStatus.code(code); + actionStatus.message("Device reported status code: " + code); + }); + updatedAction = (Status.CANCELED == status) ? controllerManagement.addCancelActionStatus(actionStatus) + : controllerManagement.addUpdateActionStatus(actionStatus); + } - if (shouldTargetProceed(updatedAction)) { + if (shouldTargetProceed(updatedAction) || actionUpdateStatus.getActionStatus() == DmfActionStatus.CONFIRMED) { sendUpdateCommandToTarget(action.getTarget()); } } @@ -391,6 +425,7 @@ public class AmqpMessageHandlerService extends BaseAmqpService { status = Status.RETRIEVED; break; case RUNNING: + case CONFIRMED: status = Status.RUNNING; break; case CANCELED: @@ -411,6 +446,9 @@ public class AmqpMessageHandlerService extends BaseAmqpService { case CANCEL_REJECTED: status = handleCancelRejectedState(message, action); break; + case DENIED: + status = Status.WAIT_FOR_CONFIRMATION; + break; default: logAndThrowMessageError(message, "Status for action does not exisit."); } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpControllerAuthenticationTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpControllerAuthenticationTest.java index b123ee483..d92e9557a 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpControllerAuthenticationTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpControllerAuthenticationTest.java @@ -27,6 +27,7 @@ import org.eclipse.hawkbit.dmf.amqp.api.MessageHeaderKey; import org.eclipse.hawkbit.dmf.amqp.api.MessageType; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadResponse; import org.eclipse.hawkbit.repository.ArtifactManagement; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.SystemManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; @@ -117,6 +118,9 @@ public class AmqpControllerAuthenticationTest { @Mock private ControllerManagement controllerManagement; + @Mock + private ConfirmationManagement confirmationManagement; + @Mock private DdiSecurityProperties securityProperties; @@ -156,7 +160,7 @@ public class AmqpControllerAuthenticationTest { amqpMessageHandlerService = new AmqpMessageHandlerService(rabbitTemplate, mock(AmqpMessageDispatcherService.class), controllerManagement, new JpaEntityFactory(), - systemSecurityContext, tenantConfigurationManagementMock); + systemSecurityContext, tenantConfigurationManagementMock, confirmationManagement); amqpAuthenticationMessageHandlerService = new AmqpAuthenticationMessageHandler(rabbitTemplate, authenticationManager, artifactManagementMock, cacheMock, hostnameResolverMock, controllerManagement, diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerServiceTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerServiceTest.java index 83e9a16aa..d80c4ca5c 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerServiceTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageHandlerServiceTest.java @@ -36,16 +36,19 @@ import org.eclipse.hawkbit.dmf.amqp.api.MessageType; import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; +import org.eclipse.hawkbit.dmf.json.model.DmfAutoConfirmation; import org.eclipse.hawkbit.dmf.json.model.DmfCreateThing; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadResponse; import org.eclipse.hawkbit.dmf.json.model.DmfUpdateMode; import org.eclipse.hawkbit.repository.ArtifactManagement; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.UpdateMode; import org.eclipse.hawkbit.repository.builder.ActionStatusBuilder; import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; import org.eclipse.hawkbit.repository.jpa.builder.JpaActionStatusBuilder; import org.eclipse.hawkbit.repository.jpa.model.JpaActionStatus; import org.eclipse.hawkbit.repository.jpa.model.helper.SecurityTokenGeneratorHolder; @@ -110,6 +113,9 @@ public class AmqpMessageHandlerServiceTest { @Mock private ControllerManagement controllerManagementMock; + @Mock + private ConfirmationManagement confirmationManagementMock; + @Mock private EntityFactory entityFactoryMock; @@ -146,6 +152,12 @@ public class AmqpMessageHandlerServiceTest { @Captor private ArgumentCaptor targetIdCaptor; + @Captor + private ArgumentCaptor initiatorCaptor; + + @Captor + private ArgumentCaptor remarkCaptor; + @Captor private ArgumentCaptor targetNameCaptor; @@ -169,7 +181,8 @@ public class AmqpMessageHandlerServiceTest { final SystemSecurityContext systemSecurityContext = new SystemSecurityContext(tenantAware); amqpMessageHandlerService = new AmqpMessageHandlerService(rabbitTemplate, amqpMessageDispatcherServiceMock, - controllerManagementMock, entityFactoryMock, systemSecurityContext, tenantConfigurationManagement); + controllerManagementMock, entityFactoryMock, systemSecurityContext, tenantConfigurationManagement, + confirmationManagementMock); amqpAuthenticationMessageHandlerService = new AmqpAuthenticationMessageHandler(rabbitTemplate, authenticationManagerMock, artifactManagementMock, downloadIdCache, hostnameResolverMock, controllerManagementMock, tenantAwareMock); @@ -230,6 +243,16 @@ public class AmqpMessageHandlerServiceTest { assertThat(uriCaptor.getValue()).as("Uri is not right").hasToString("amqp://" + VIRTUAL_HOST + "/" + replyTo); } + @Step + private void assertInitiatorCapturedField(final String initiator) { + assertThat(initiatorCaptor.getValue()).as("Initiator is wrong").isEqualTo(initiator); + } + + @Step + private void assertRemarkCapturedField(final String remark) { + assertThat(remarkCaptor.getValue()).as("Remark is wrong").isEqualTo(remark); + } + @Test @Description("Tests the creation of a target/thing with specified name by calling the same method that incoming RabbitMQ messages would access.") public void createThingWithName() { @@ -556,6 +579,7 @@ public class AmqpMessageHandlerServiceTest { final ActionStatusCreate create = mock(ActionStatusCreate.class); when(builder.create(22L)).thenReturn(create); when(create.status(any())).thenReturn(create); + when(create.messages(any())).thenReturn(create); when(entityFactoryMock.actionStatus()).thenReturn(builder); // for the test the same action can be used when(controllerManagementMock.findActiveActionWithHighestWeight(any())).thenReturn(Optional.of(action)); @@ -572,7 +596,7 @@ public class AmqpMessageHandlerServiceTest { final ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(Target.class); verify(amqpMessageDispatcherServiceMock, times(1)).sendUpdateMessageToTarget(actionPropertiesCaptor.capture(), - targetCaptor.capture(), any(Map.class)); + targetCaptor.capture(), any(Map.class)); final ActionProperties actionProperties = actionPropertiesCaptor.getValue(); assertThat(actionProperties).isNotNull(); assertThat(actionProperties.getTenant()).as("event has tenant").isEqualTo("DEFAULT"); @@ -616,6 +640,56 @@ public class AmqpMessageHandlerServiceTest { .contains("Device reported status code: 12"); } + @Test + @Description("Tests activating auto-confirmation on a target.") + void setAutoConfirmationStateActive() { + final String knownThingId = "1"; + final String initiator = "iAmTheInitiator"; + final String remark = "remarkForTesting"; + + final MessageProperties messageProperties = createMessageProperties(MessageType.EVENT); + messageProperties.setHeader(MessageHeaderKey.THING_ID, knownThingId); + messageProperties.setHeader(MessageHeaderKey.TOPIC, "UPDATE_AUTO_CONFIRM"); + final DmfAutoConfirmation autoConfirmation = new DmfAutoConfirmation(); + autoConfirmation.setEnabled(true); + autoConfirmation.setInitiator(initiator); + autoConfirmation.setRemark(remark); + + final Message message = createMessage(autoConfirmation, messageProperties); + + when(controllerManagementMock.activateAutoConfirmation(targetIdCaptor.capture(), initiatorCaptor.capture(), + remarkCaptor.capture())).thenReturn(null); + + amqpMessageHandlerService.onMessage(message, MessageType.EVENT.name(), TENANT, VIRTUAL_HOST); + + verify(controllerManagementMock, times(0)).deactivateAutoConfirmation(anyString()); + + assertThingIdCapturedField(knownThingId); + assertInitiatorCapturedField(initiator); + assertRemarkCapturedField(remark); + } + + + @Test + @Description("Tests deactivating auto-confirmation on a target.") + void setAutoConfirmationStateDeactivated() { + final String knownThingId = "1"; + + final MessageProperties messageProperties = createMessageProperties(MessageType.EVENT); + messageProperties.setHeader(MessageHeaderKey.THING_ID, knownThingId); + messageProperties.setHeader(MessageHeaderKey.TOPIC, "UPDATE_AUTO_CONFIRM"); + final DmfAutoConfirmation autoConfirmation = new DmfAutoConfirmation(); + + final Message message = createMessage(autoConfirmation, messageProperties); + + amqpMessageHandlerService.onMessage(message, MessageType.EVENT.name(), TENANT, VIRTUAL_HOST); + + verify(controllerManagementMock).deactivateAutoConfirmation(targetIdCaptor.capture()); + verify(controllerManagementMock, times(0)).activateAutoConfirmation(anyString(), anyString(), anyString()); + + assertThingIdCapturedField(knownThingId); + } + private DmfActionUpdateStatus createActionUpdateStatus(final DmfActionStatus status) { return createActionUpdateStatus(status, 2L); } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AbstractAmqpServiceIntegrationTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AbstractAmqpServiceIntegrationTest.java index 684eac692..e1573c908 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AbstractAmqpServiceIntegrationTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AbstractAmqpServiceIntegrationTest.java @@ -12,7 +12,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -31,6 +30,7 @@ import org.eclipse.hawkbit.dmf.json.model.DmfActionRequest; import org.eclipse.hawkbit.dmf.json.model.DmfActionStatus; import org.eclipse.hawkbit.dmf.json.model.DmfActionUpdateStatus; import org.eclipse.hawkbit.dmf.json.model.DmfAttributeUpdate; +import org.eclipse.hawkbit.dmf.json.model.DmfConfirmRequest; import org.eclipse.hawkbit.dmf.json.model.DmfCreateThing; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; import org.eclipse.hawkbit.dmf.json.model.DmfMetadata; @@ -129,7 +129,13 @@ public abstract class AbstractAmqpServiceIntegrationTest extends AbstractAmqpInt testdataFactory.addSoftwareModuleMetadata(distributionSet); return registerTargetAndAssignDistributionSet(distributionSet.getId(), TargetUpdateStatus.REGISTERED, - distributionSet.getModules(), controllerId); + distributionSet.getModules(), controllerId); + } + + protected DistributionSetAssignmentResult prepareDistributionSetAndAssign(final String controllerId) { + distributionSet = testdataFactory.createDistributionSet(UUID.randomUUID().toString()); + testdataFactory.addSoftwareModuleMetadata(distributionSet); + return assignDistributionSet(distributionSet.getId(), controllerId); } protected DistributionSetAssignmentResult registerTargetAndAssignDistributionSet(final Long assignDs, @@ -138,7 +144,11 @@ public abstract class AbstractAmqpServiceIntegrationTest extends AbstractAmqpInt registerAndAssertTargetWithExistingTenant(controllerId, 1, expectedStatus, CREATED_BY); final DistributionSetAssignmentResult assignmentResult = assignDistributionSet(assignDs, controllerId); - assertDownloadAndInstallMessage(expectedSoftwareModulesInMessage, controllerId); + if (isConfirmationFlowEnabled()) { + assertConfirmMessage(expectedSoftwareModulesInMessage, controllerId); + } else { + assertDownloadAndInstallMessage(expectedSoftwareModulesInMessage, controllerId); + } return assignmentResult; } @@ -452,4 +462,24 @@ public abstract class AbstractAmqpServiceIntegrationTest extends AbstractAmqpInt softwareModules.forEach(dmfModule -> assertThat(dmfModule.getMetadata()).containsExactly( new DmfMetadata(TestdataFactory.VISIBLE_SM_MD_KEY, TestdataFactory.VISIBLE_SM_MD_VALUE))); } + + protected void assertConfirmMessage(final Set dsModules, final String controllerId) { + + final Message replyMessage = assertReplyMessageHeader(EventTopic.CONFIRM, controllerId); + assertAllTargetsCount(1); + + final DmfConfirmRequest confirmRequest = (DmfConfirmRequest) getDmfClient() + .getMessageConverter().fromMessage(replyMessage); + + assertConfirmRequest(confirmRequest, dsModules, controllerId); + } + + protected void assertConfirmRequest(final DmfConfirmRequest request, final Set softwareModules, + final String controllerId) { + assertSoftwareModules(softwareModules, request.getSoftwareModules()); + final Target updatedTarget = waitUntilIsPresent(() -> targetManagement.getByControllerID(controllerId)); + assertThat(updatedTarget).isNotNull(); + assertThat(updatedTarget.getSecurityToken()).isEqualTo(request.getTargetSecurityToken()); + } + } diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageDispatcherServiceIntegrationTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageDispatcherServiceIntegrationTest.java index 21edf1e9a..a4d4630a2 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageDispatcherServiceIntegrationTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/integration/AmqpMessageDispatcherServiceIntegrationTest.java @@ -466,7 +466,7 @@ public class AmqpMessageDispatcherServiceIntegrationTest extends AbstractAmqpSer private Rollout createAndStartRollout(final DistributionSet ds, final String filterQuery, final Integer weight) { final Rollout rollout = testdataFactory.createRolloutByVariables(UUID.randomUUID().toString(), "", 1, - filterQuery, ds, "50", "5", ActionType.FORCED, weight); + filterQuery, ds, "50", "5", ActionType.FORCED, weight, false); rolloutManagement.start(rollout.getId()); rolloutManagement.handleRollouts(); return rollout; @@ -695,4 +695,28 @@ public class AmqpMessageDispatcherServiceIntegrationTest extends AbstractAmqpSer assertThat(tokens.contains(requestTarget.getTargetSecurityToken())); }); } + + @Test + @Description("Verify that a distribution assignment send a confirm message.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void sendConfirmStatus() { + final String controllerId = TARGET_PREFIX + "sendConfirmStatus"; + enableConfirmationFlow(); + registerTargetAndAssignDistributionSet(controllerId); + + waitUntilTargetHasStatus(controllerId, TargetUpdateStatus.PENDING); + assertConfirmMessage(getDistributionSet().getModules(), controllerId); + assertEventMessageNotPresent(EventTopic.DOWNLOAD_AND_INSTALL); + } + protected void assertEventMessageNotPresent(final EventTopic eventTopic) { + assertThat(replyToListener.getLatestEventMessage(eventTopic)).isNull(); + } } 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 203022102..f3572f622 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 @@ -47,6 +47,8 @@ import org.eclipse.hawkbit.repository.event.remote.entity.SoftwareModuleCreatedE import org.eclipse.hawkbit.repository.event.remote.entity.SoftwareModuleUpdatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TenantConfigurationCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TenantConfigurationUpdatedEvent; import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException; import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet; import org.eclipse.hawkbit.repository.jpa.model.JpaTarget; @@ -966,6 +968,26 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr getDmfClient().send(updateMessage); } + private void assertActionStatusList(final Long actionId, final int statusListCount, + final Status... expectedActionStates) { + createConditionFactory().await().untilAsserted(() -> { + try { + WithSpringAuthorityRule.runAsPrivileged(() -> { + final List actionStatusList = deploymentManagement + .findActionStatusByAction(PAGE, actionId).getContent(); + assertThat(actionStatusList).hasSize(statusListCount); + + final List status = actionStatusList.stream().map(ActionStatus::getStatus) + .collect(Collectors.toList()); + assertThat(status).containsOnly(expectedActionStates); + + return null; + }); + } catch (final Exception e) { + throw new RuntimeException(e); + } + }); + } private int getAuthenticationMessageCount() { return Integer .parseInt(Objects.requireNonNull(getRabbitAdmin().getQueueProperties(amqpProperties.getReceiverQueue())) @@ -993,4 +1015,142 @@ class AmqpMessageHandlerServiceIntegrationTest extends AbstractAmqpServiceIntegr assertThat(node.has("actionId")).isTrue(); return node.get("actionId").asText(); } + + @Test + @Description("Register a target and send a update action status (confirmed). Verify if the updated action status is correct.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void confirmedActionStatus() { + enableConfirmationFlow(); + final String controllerId = TARGET_PREFIX + "confirmedActionStatus"; + + final DistributionSetAssignmentResult assignmentResult = registerTargetAndAssignDistributionSet(controllerId); + final Long actionId = getFirstAssignedActionId(assignmentResult); + createAndSendActionStatusUpdateMessage(controllerId, actionId, DmfActionStatus.CONFIRMED); + assertActionStatusList(actionId, 2, Status.WAIT_FOR_CONFIRMATION, Status.RUNNING); + + // assert download and install message + waitUntilEventMessagesAreDispatchedToTarget(EventTopic.CONFIRM, EventTopic.DOWNLOAD_AND_INSTALL); + assertDownloadAndInstallMessage(assignmentResult.getDistributionSet().getModules(), controllerId); + } + + @Test + @Description("Verify the DMF confirmed feedback can be provided if confirmation flow is disabled") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1), + @Expect(type = TenantConfigurationUpdatedEvent.class, count = 1) }) + void verifyActionCanBeConfirmedOnDisabledConfirmationFlow() { + enableConfirmationFlow(); + final String controllerId = TARGET_PREFIX + "confirmedActionStatus"; + + final DistributionSetAssignmentResult assignmentResult = registerTargetAndAssignDistributionSet(controllerId); + final Long actionId = getFirstAssignedActionId(assignmentResult); + // verify action status is in WAIT_FOR_CONFIRMATION + assertActionStatusList(actionId, 1, Status.WAIT_FOR_CONFIRMATION); + + disableConfirmationFlow(); + + createAndSendActionStatusUpdateMessage(controllerId, actionId, DmfActionStatus.CONFIRMED); + assertActionStatusList(actionId, 2, Status.WAIT_FOR_CONFIRMATION, Status.RUNNING); + + // assert download and install message + waitUntilEventMessagesAreDispatchedToTarget(EventTopic.CONFIRM, EventTopic.DOWNLOAD_AND_INSTALL); + assertDownloadAndInstallMessage(assignmentResult.getDistributionSet().getModules(), controllerId); + } + + + @Test + @Description("Verify the DMF confirmed feedback can be provided if confirmation flow is disabled") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 0), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1), + @Expect(type = TenantConfigurationUpdatedEvent.class, count = 1)}) + void verifyActionCanBeDeniedOnDisabledConfirmationFlow() { + enableConfirmationFlow(); + final String controllerId = TARGET_PREFIX + "confirmedActionStatus"; + + final DistributionSetAssignmentResult assignmentResult = registerTargetAndAssignDistributionSet(controllerId); + final Long actionId = getFirstAssignedActionId(assignmentResult); + // verify action status is in WAIT_FOR_CONFIRMATION + assertActionStatusList(actionId, 1, Status.WAIT_FOR_CONFIRMATION); + + disableConfirmationFlow(); + + createAndSendActionStatusUpdateMessage(controllerId, actionId, DmfActionStatus.DENIED); + assertActionStatusList(actionId, 2, Status.WAIT_FOR_CONFIRMATION); + } + + @Test + @Description("Verify the DMF download and install message is send directly if auto-confirmation is active") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 0), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void verifyDownloadAndInstallDirectlySendOnAutoConfirmationEnabled() { + enableConfirmationFlow(); + final String controllerId = TARGET_PREFIX + "confirmedActionStatus"; + + registerAndAssertTargetWithExistingTenant(controllerId); + + confirmationManagement.activateAutoConfirmation(controllerId, null, null); + + final DistributionSetAssignmentResult assignmentResult = prepareDistributionSetAndAssign(controllerId); + final Long actionId = getFirstAssignedActionId(assignmentResult); + // verify action status is in WAIT_FOR_CONFIRMATION + assertActionStatusList(actionId, 1, Status.WAIT_FOR_CONFIRMATION); + + // assert download and install message + waitUntilEventMessagesAreDispatchedToTarget(EventTopic.DOWNLOAD_AND_INSTALL); + assertDownloadAndInstallMessage(assignmentResult.getDistributionSet().getModules(), controllerId); + } + + @Test + @Description("Register a target and send a update action status (denied). Verify if the updated action status is correct.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class), @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = SoftwareModuleUpdatedEvent.class, count = 6), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void deniedActionStatus() { + enableConfirmationFlow(); + final String controllerId = TARGET_PREFIX + "deniedActionStatus"; + final DistributionSetAssignmentResult assignmentResult = registerTargetAndAssignDistributionSet(controllerId); + final Long actionId = getFirstAssignedActionId(assignmentResult); + assertActionStatusList(actionId, 1, Status.WAIT_FOR_CONFIRMATION); + createAndSendActionStatusUpdateMessage(controllerId, actionId, DmfActionStatus.DENIED); + assertActionStatusList(actionId, 2, Status.WAIT_FOR_CONFIRMATION); + } } diff --git a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/amqp/api/EventTopic.java b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/amqp/api/EventTopic.java index 668f9d4b7..ca4b2aaa2 100644 --- a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/amqp/api/EventTopic.java +++ b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/amqp/api/EventTopic.java @@ -33,6 +33,11 @@ public enum EventTopic { */ UPDATE_ATTRIBUTES, + /** + * Topic when updating auto-confirmation state. + */ + UPDATE_AUTO_CONFIRM, + /** * Topic when sending a download only task, skipping the install. */ @@ -56,6 +61,10 @@ public enum EventTopic { /** * Topic when sending a download and install action to multiple devices. */ - BATCH_DOWNLOAD_AND_INSTALL + BATCH_DOWNLOAD_AND_INSTALL, + /** + * Topic when confirmation of an action is requested. + */ + CONFIRM } diff --git a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionStatus.java b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionStatus.java index cdd675c3d..f4e85a4a6 100644 --- a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionStatus.java +++ b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionStatus.java @@ -64,5 +64,15 @@ public enum DmfActionStatus { /** * Action has been downloaded for this target. */ - DOWNLOADED + DOWNLOADED, + + /** + * Action is confirmed by the target. + */ + CONFIRMED, + + /** + * Action has been denied by the target. + */ + DENIED } diff --git a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionUpdateStatus.java b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionUpdateStatus.java index 9202ccaf6..7e12af3b6 100644 --- a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionUpdateStatus.java +++ b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfActionUpdateStatus.java @@ -34,10 +34,10 @@ public class DmfActionUpdateStatus { private Long softwareModuleId; @JsonProperty - private Integer code; + private List message; @JsonProperty - private List message; + private Integer code; public DmfActionUpdateStatus(@JsonProperty(value = "actionId", required = true) final Long actionId, @JsonProperty(value = "actionStatus", required = true) final DmfActionStatus actionStatus) { @@ -99,4 +99,8 @@ public class DmfActionUpdateStatus { return message.addAll(messages); } + public void setCode(final int code) { + this.code = code; + } + } diff --git a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfAutoConfirmation.java b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfAutoConfirmation.java new file mode 100644 index 000000000..6986cc7f3 --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfAutoConfirmation.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.dmf.json.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * JSON representation of auto confirmation config. + */ +@JsonInclude(Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class DmfAutoConfirmation { + + @JsonProperty + private boolean enabled; + + @JsonProperty + private String initiator; + + @JsonProperty + private String remark; + + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public String getInitiator() { + return initiator; + } + + public void setInitiator(final String initiator) { + this.initiator = initiator; + } + + public String getRemark() { + return remark; + } + + public void setRemark(final String remark) { + this.remark = remark; + } + +} diff --git a/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfConfirmRequest.java b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfConfirmRequest.java new file mode 100644 index 000000000..07cf48faf --- /dev/null +++ b/hawkbit-dmf/hawkbit-dmf-api/src/main/java/org/eclipse/hawkbit/dmf/json/model/DmfConfirmRequest.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.dmf.json.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * JSON representation of confirm request. + * + */ +@JsonInclude(Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class DmfConfirmRequest extends DmfActionRequest { + + @JsonProperty + private String targetSecurityToken; + + @JsonProperty + private List softwareModules; + + public String getTargetSecurityToken() { + return targetSecurityToken; + } + + public void setTargetSecurityToken(final String targetSecurityToken) { + this.targetSecurityToken = targetSecurityToken; + } + + public List getSoftwareModules() { + if (softwareModules == null) { + return Collections.emptyList(); + } + + return Collections.unmodifiableList(softwareModules); + } + + /** + * Add a Software module. + * + * @param createSoftwareModule + * the module + */ + public void addSoftwareModule(final DmfSoftwareModule createSoftwareModule) { + if (softwareModules == null) { + softwareModules = new ArrayList<>(); + } + + softwareModules.add(createSoftwareModule); + + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ConfirmationManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ConfirmationManagement.java new file mode 100644 index 000000000..6d57191d3 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ConfirmationManagement.java @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository; + +import org.eclipse.hawkbit.im.authentication.SpPermission; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; +import org.eclipse.hawkbit.tenancy.TenantAware; +import org.springframework.security.access.prepost.PreAuthorize; + +import javax.validation.constraints.NotEmpty; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * Service layer for all confirmation related operations. + */ +public interface ConfirmationManagement { + + /** + * Find active actions in the {@link Action.Status#WAIT_FOR_CONFIRMATION} state + * for a specific target with a specified controllerId. + * + * @param controllerId + * of the target to check + * @return a list of {@link Action} + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.HAS_AUTH_READ_TARGET) + List findActiveActionsWaitingConfirmation(@NotEmpty String controllerId); + + /** + * Activate auto confirmation for a given controller ID. In case auto + * confirmation is active already, this method will fail with an exception. + * + * @param controllerId + * to activate the feature for + * @param initiator + * who initiated this operation. If 'null' we will take the current + * user from {@link TenantAware#getCurrentUsername()} + * @param remark + * optional field to set a remark + * @return the persisted {@link AutoConfirmationStatus} + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET) + AutoConfirmationStatus activateAutoConfirmation(@NotEmpty String controllerId, final String initiator, + final String remark); + + /** + * Get the current state of auto-confirmation for a given controllerId + * + * @param controllerId + * to check the state for + * @return instance of {@link AutoConfirmationStatus} wrapped in an + * {@link Optional}. Present if active and empty if disabled. + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.HAS_AUTH_READ_TARGET) + Optional getStatus(@NotEmpty String controllerId); + + /** + * Auto confirm active actions for a specific controller ID having the + * {@link Action.Status#WAIT_FOR_CONFIRMATION} status. + * + * @param controllerId + * to confirm actions for + * @return a list of confirmed actions + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET) + List autoConfirmActiveActions(@NotEmpty String controllerId); + + /** + * Confirm a given action to put it from + * {@link Action.Status#WAIT_FOR_CONFIRMATION} to {@link Action.Status#RUNNING} + * state. + * + * @param actionId + * mandatory to know which action to confirm + * @param code + * optional value to specify a code for the created action status + * @param messages + * optional value to specify message for the created action status + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET) + Action confirmAction(long actionId, Integer code, Collection messages); + + /** + * Deny a given action and leave it in + * {@link Action.Status#WAIT_FOR_CONFIRMATION} state. + * + * @param actionId + * mandatory to know which action to deny + * @param code + * optional value to specify a code for the created action status + * @param messages + * optional value to specify message for the created action status + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET) + Action denyAction(long actionId, Integer code, Collection messages); + + /** + * Deactivate auto confirmation for a specific controller id + * + * @param controllerId + * to disable auto confirmation for + */ + @PreAuthorize(SpPermission.SpringEvalExpressions.IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET) + void deactivateAutoConfirmation(@NotEmpty String controllerId); + +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ControllerManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ControllerManagement.java index 0a950b1bf..1c61ba5a7 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/ControllerManagement.java @@ -29,6 +29,7 @@ import org.eclipse.hawkbit.repository.exception.InvalidTargetAttributeException; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.Status; import org.eclipse.hawkbit.repository.model.ActionStatus; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.SoftwareModuleMetadata; import org.eclipse.hawkbit.repository.model.Target; @@ -508,4 +509,28 @@ public interface ControllerManagement { */ @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) Optional getInstalledActionByTarget(@NotEmpty String controllerId); + + /** + * Activate auto confirmation for a given controllerId + * + * @param controllerId + * to activate auto-confirmation on + * @param initiator + * can be set optionally (fallback is the current acting security + * user) + * @param remark + * (optional) remark + * @return the persisted {@link AutoConfirmationStatus} + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + AutoConfirmationStatus activateAutoConfirmation(@NotEmpty String controllerId, String initiator, String remark); + + /** + * Deactivate auto confirmation for a given controllerId + * + * @param controllerId + * to deactivate auto-confirmation on + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + void deactivateAutoConfirmation(@NotEmpty String controllerId); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java index 927d44641..caf0fd442 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java @@ -449,8 +449,8 @@ public interface DeploymentManagement { Page findInActiveActionsByTarget(@NotNull Pageable pageable, @NotEmpty String controllerId); /** - * Retrieves active {@link Action}s with highest weight that are assigned to - * a {@link Target}. + * Retrieves active {@link Action}s with highest weight that are assigned to a + * {@link Target}. * * @param controllerId * identifies the target to retrieve the action from @@ -522,6 +522,8 @@ public interface DeploymentManagement { * * @param rolloutId * the rollout the actions belong to + * @param distributionSetId + * to assign * @param rolloutGroupParentId * the parent rollout group the actions should reference. null * references the first group diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RolloutManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RolloutManagement.java index 7dad3b438..ae6039e3c 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RolloutManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RolloutManagement.java @@ -108,27 +108,28 @@ public interface RolloutManagement { /** * Persists a new rollout entity. The filter within the - * {@link Rollout#getTargetFilterQuery()} is used to retrieve the targets - * which are effected by this rollout to create. The amount of groups will - * be defined as equally sized. + * {@link Rollout#getTargetFilterQuery()} is used to retrieve the targets which + * are effected by this rollout to create. The amount of groups will be defined + * as equally sized. * * The rollout is not started. Only the preparation of the rollout is done, - * creating and persisting all the necessary groups. The Rollout and the - * groups are persisted in {@link RolloutStatus#CREATING} and + * creating and persisting all the necessary groups. The Rollout and the groups + * are persisted in {@link RolloutStatus#CREATING} and * {@link RolloutGroupStatus#CREATING}. * * The RolloutScheduler will start to assign targets to the groups. Once all - * targets have been assigned to the groups, the rollout status is changed - * to {@link RolloutStatus#READY} so it can be started with - * {@link #start(Rollout)}. + * targets have been assigned to the groups, the rollout status is changed to + * {@link RolloutStatus#READY} so it can be started with . * * @param create * the rollout entity to create * @param amountGroup * the amount of groups to split the rollout into + * @param confirmationRequired + * if a confirmation is required by the device group(s) of the rollout * @param conditions - * the rolloutgroup conditions and actions which should be - * applied for each {@link RolloutGroup} + * the rolloutgroup conditions and actions which should be applied + * for each {@link RolloutGroup} * @return the persisted rollout. * * @throws EntityNotFoundException @@ -140,22 +141,23 @@ public interface RolloutManagement { * exceeded. */ @PreAuthorize(SpringEvalExpressions.HAS_AUTH_ROLLOUT_MANAGEMENT_CREATE) - Rollout create(@NotNull @Valid RolloutCreate create, int amountGroup, @NotNull RolloutGroupConditions conditions); + Rollout create(@NotNull @Valid RolloutCreate create, int amountGroup, boolean confirmationRequired, + @NotNull RolloutGroupConditions conditions); /** * Persists a new rollout entity. The filter within the - * {@link Rollout#getTargetFilterQuery()} is used to filter the targets - * which are affected by this rollout. The given groups will be used to - * create the groups. + * {@link Rollout#getTargetFilterQuery()} is used to filter the targets which + * are affected by this rollout. The given groups will be used to create the + * groups. * * The rollout is not started. Only the preparation of the rollout is done, - * creating and persisting all the necessary groups. The Rollout and the - * groups are persisted in {@link RolloutStatus#CREATING} and + * creating and persisting all the necessary groups. The Rollout and the groups + * are persisted in {@link RolloutStatus#CREATING} and * {@link RolloutGroupStatus#CREATING}. * * The RolloutScheduler will start to assign targets to the groups. Once all - * targets have been assigned to the groups, the rollout status is changed - * to {@link RolloutStatus#READY} so it can be started with + * targets have been assigned to the groups, the rollout status is changed to + * {@link RolloutStatus#READY} so it can be started with * {@link #start(Rollout)}. * * @param rollout @@ -163,9 +165,9 @@ public interface RolloutManagement { * @param groups * optional definition of groups * @param conditions - * the rollout group conditions and actions which should be - * applied for each {@link RolloutGroup} if not defined by the - * RolloutGroup itself + * the rollout group conditions and actions which should be applied + * for each {@link RolloutGroup} if not defined by the RolloutGroup + * itself * @return the persisted rollout. * * @throws EntityNotFoundException diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/AutoAssignDistributionSetUpdate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/AutoAssignDistributionSetUpdate.java index d80f96991..d26dc0494 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/AutoAssignDistributionSetUpdate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/AutoAssignDistributionSetUpdate.java @@ -29,6 +29,8 @@ public class AutoAssignDistributionSetUpdate { @Max(Action.WEIGHT_MAX) private Integer weight; + private Boolean confirmationRequired; + /** * Constructor * @@ -75,6 +77,19 @@ public class AutoAssignDistributionSetUpdate { return this; } + /** + * Specify initial confirmation state of resulting {@link Action} + * + * @param confirmationRequired + * if confirmation is required for this auto assignment (considered + * with confirmation flow active) + * @return updated builder instance + */ + public AutoAssignDistributionSetUpdate confirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + return this; + } + public Long getDsId() { return dsId; } @@ -87,6 +102,10 @@ public class AutoAssignDistributionSetUpdate { return weight; } + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + public long getTargetFilterId() { return targetFilterId; } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/RolloutGroupCreate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/RolloutGroupCreate.java index c8f5b2a39..46a572b35 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/RolloutGroupCreate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/RolloutGroupCreate.java @@ -63,6 +63,14 @@ public interface RolloutGroupCreate { */ RolloutGroupCreate conditions(RolloutGroupConditions conditions); + /** + * @param confirmationRequired + * if confirmation is required for this rollout group (considered + * with confirmation flow active) + * @return updated builder instance + */ + RolloutGroupCreate confirmationRequired(boolean confirmationRequired); + /** * @return peek on current state of {@link RolloutGroup} in the builder */ diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java index 4d8b42474..572386751 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java @@ -83,6 +83,17 @@ public interface TargetFilterQueryCreate { */ TargetFilterQueryCreate autoAssignWeight(Integer weight); + /** + * Specify initial confirmation state of resulting {@link Action} in auto + * assignment + * + * @param confirmationRequired + * if confirmation is required for configured auto assignment (considered + * with confirmation flow active) + * @return updated builder instance + */ + TargetFilterQueryCreate confirmationRequired(boolean confirmationRequired); + /** * @return peek on current state of {@link TargetFilterQuery} in the builder */ diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/AutoConfirmationAlreadyActiveException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/AutoConfirmationAlreadyActiveException.java new file mode 100644 index 000000000..10cc73fb7 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/AutoConfirmationAlreadyActiveException.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.exception; + +import org.eclipse.hawkbit.exception.AbstractServerRtException; +import org.eclipse.hawkbit.exception.SpServerError; + +/** + * The {@link AutoConfirmationAlreadyActiveException} is thrown when auto + * confirmation is already active for a device but the + * {@link org.eclipse.hawkbit.repository.ConfirmationManagement#activateAutoConfirmation} + * is getting called. + */ +public class AutoConfirmationAlreadyActiveException extends AbstractServerRtException { + + private static final long serialVersionUID = 1L; + private static final SpServerError THIS_ERROR = SpServerError.SP_REPO_AUTO_CONFIRMATION_ALREADY_ACTIVE; + + /** + * Default constructor. + */ + public AutoConfirmationAlreadyActiveException() { + super(THIS_ERROR); + } + + /** + * Parameterized constructor. + * + * @param cause + * of the exception + */ + public AutoConfirmationAlreadyActiveException(final Throwable cause) { + super(THIS_ERROR, cause); + } + + /** + * Parameterized constructor for auto confirmation is already active for given + * controller ID + * + * @param controllerId + * of affected device + */ + public AutoConfirmationAlreadyActiveException(final String controllerId) { + super("Auto confirmation is already active for device " + controllerId, THIS_ERROR); + } + +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidConfirmationFeedbackException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidConfirmationFeedbackException.java new file mode 100644 index 000000000..d2b7fdee3 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidConfirmationFeedbackException.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.exception; + +import org.eclipse.hawkbit.exception.AbstractServerRtException; +import org.eclipse.hawkbit.exception.SpServerError; + +/** + * This exception is indicating that the confirmation feedback cannot be + * processed for a specific actions for different reasons which are listed as + * enum {@link Reason}. + */ +public class InvalidConfirmationFeedbackException extends AbstractServerRtException { + + private static final long serialVersionUID = 1L; + private static final SpServerError THIS_ERROR = SpServerError.SP_CONFIRMATION_FEEDBACK_INVALID; + + private final Reason reason; + + protected InvalidConfirmationFeedbackException(final Reason reason) { + super(THIS_ERROR); + this.reason = reason; + } + + public InvalidConfirmationFeedbackException(final Reason reason, final String message) { + super(message, THIS_ERROR); + this.reason = reason; + } + + public Reason getReason() { + return reason; + } + + public enum Reason { + ACTION_CLOSED, NOT_AWAITING_CONFIRMATION + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Action.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Action.java index c3ec5e424..14c097187 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Action.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Action.java @@ -259,7 +259,12 @@ public interface Action extends TenantAwareBaseEntity { * Action has been downloaded by the target and waiting for update to * start. */ - DOWNLOADED + DOWNLOADED, + + /** + * Action is waiting to be confirmed by the user + */ + WAIT_FOR_CONFIRMATION } /** @@ -330,4 +335,11 @@ public interface Action extends TenantAwareBaseEntity { * @return true if maintenance window is available, else false. */ boolean isMaintenanceWindowAvailable(); + + /** + * Checks if the action is waiting for confirmation. + * @return true if the action is waiting for confirmation, else false + */ + boolean isWaitingConfirmation(); + } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/ActionProperties.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/ActionProperties.java index 489c326c7..b82b14b53 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/ActionProperties.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/ActionProperties.java @@ -8,6 +8,8 @@ */ package org.eclipse.hawkbit.repository.model; +import com.fasterxml.jackson.annotation.JsonIgnore; + import java.io.Serializable; /** @@ -22,6 +24,8 @@ public class ActionProperties implements Serializable { private String tenant; private boolean maintenanceWindowAvailable; + private Action.Status status; + public ActionProperties() { } @@ -35,6 +39,7 @@ public class ActionProperties implements Serializable { this.actionType = action.getActionType(); this.tenant = action.getTenant(); this.maintenanceWindowAvailable = action.isMaintenanceWindowAvailable(); + this.status = action.getStatus(); } public void setId(final Long id) { @@ -68,4 +73,13 @@ public class ActionProperties implements Serializable { public void setActionType(final Action.ActionType actionType) { this.actionType = actionType; } + + public Action.Status getStatus() { + return status; + } + + @JsonIgnore + public boolean isWaitingConfirmation() { + return status == Action.Status.WAIT_FOR_CONFIRMATION; + } } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/AutoConfirmationStatus.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/AutoConfirmationStatus.java new file mode 100644 index 000000000..22c780a4c --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/AutoConfirmationStatus.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.model; + +/** + * {@link AutoConfirmationStatus} of a {@link Target}. + * + */ +public interface AutoConfirmationStatus extends BaseEntity { + + /** + * For which target this status is corresponding to. + * + * @return the {@link Target} + */ + Target getTarget(); + + /** + * The user who initiated the auto confirmation. Will be set on auto + * confirmation activation and could be null. In this case the created_by can be + * considered as initiator. + * + * @return the user + */ + String getInitiator(); + + /** + * Unix timestamp of the activation. + * + * @return activation time as unix timestamp + */ + long getActivatedAt(); + + /** + * Optional value, which can be set during activation. + * + * @return the remark + */ + String getRemark(); + + /** + * Construct the action message based on the current status. + * + * @return the constructed message which can be used for the action status as a + * message + */ + String constructActionMessage(); + +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequest.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequest.java index df0ee59c6..f51b1ccd2 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequest.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequest.java @@ -25,8 +25,8 @@ public class DeploymentRequest { private final TargetWithActionType targetWithActionType; /** - * Constructor that also accepts maintenance schedule parameters and checks - * for validity of the specified maintenance schedule. + * Constructor that also accepts maintenance schedule parameters and checks for + * validity of the specified maintenance schedule. * * @param controllerId * for which the action is created. @@ -48,18 +48,30 @@ public class DeploymentRequest { * window, for example 00:30:00 for 30 minutes * @param maintenanceWindowTimeZone * is the time zone specified as +/-hh:mm offset from UTC, for - * example +02:00 for CET summer time and +00:00 for UTC. The - * start time of a maintenance window calculated based on the - * cron expression is relative to this time zone. + * example +02:00 for CET summer time and +00:00 for UTC. The start + * time of a maintenance window calculated based on the cron + * expression is relative to this time zone. * + * @param confirmationRequired + * is a flag whether the confirmation should be required for the + * resulting {@link Action} or not. In case the confirmation is not + * required, the action will be automatically confirmed and put in + * the + * {@link org.eclipse.hawkbit.repository.model.Action.Status#RUNNING} + * state. Otherwise the confirmation flow will be triggered + * and the {@link Action} will stay in the + * {@link org.eclipse.hawkbit.repository.model.Action.Status#WAIT_FOR_CONFIRMATION} + * state until the confirmation is given. (Only considered + * with CONFIRMATION_FLOW active via tenant configuration) * @throws InvalidMaintenanceScheduleException * if the parameters do not define a valid maintenance schedule. */ public DeploymentRequest(final String controllerId, final Long distributionSetId, final ActionType actionType, final long forceTime, final Integer weight, final String maintenanceSchedule, - final String maintenanceWindowDuration, final String maintenanceWindowTimeZone) { + final String maintenanceWindowDuration, final String maintenanceWindowTimeZone, + final boolean confirmationRequired) { this.targetWithActionType = new TargetWithActionType(controllerId, actionType, forceTime, weight, - maintenanceSchedule, maintenanceWindowDuration, maintenanceWindowTimeZone); + maintenanceSchedule, maintenanceWindowDuration, maintenanceWindowTimeZone, confirmationRequired); this.distributionSetId = distributionSetId; } @@ -78,11 +90,11 @@ public class DeploymentRequest { @Override public String toString() { return String.format( - "DeploymentRequest [controllerId=%s, distributionSetId=%d, actionType=%s, forceTime=%d, weight=%d, maintenanceSchedule=%s, maintenanceWindowDuration=%s, maintenanceWindowTimeZone=%s]", + "DeploymentRequest [controllerId=%s, distributionSetId=%d, actionType=%s, forceTime=%d, weight=%d, maintenanceSchedule=%s, maintenanceWindowDuration=%s, maintenanceWindowTimeZone=%s, confirmationRequired=%s]", targetWithActionType.getControllerId(), getDistributionSetId(), targetWithActionType.getActionType(), targetWithActionType.getForceTime(), targetWithActionType.getWeight(), targetWithActionType.getMaintenanceSchedule(), targetWithActionType.getMaintenanceWindowDuration(), - targetWithActionType.getMaintenanceWindowTimeZone()); + targetWithActionType.getMaintenanceWindowTimeZone(), targetWithActionType.isConfirmationRequired()); } @Override diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequestBuilder.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequestBuilder.java index 268a1bd70..1f5e2be0e 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequestBuilder.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DeploymentRequestBuilder.java @@ -24,6 +24,7 @@ public class DeploymentRequestBuilder { private String maintenanceSchedule; private String maintenanceWindowDuration; private String maintenanceWindowTimeZone; + private boolean confirmationRequired; /** * Create a builder for a target distribution set assignment with the @@ -100,6 +101,18 @@ public class DeploymentRequestBuilder { return this; } + /** + * Set if a confirmation is required. + * + * @param confirmationRequired + * if a confirmation is required for the {@link Action} + * @return builder + */ + public DeploymentRequestBuilder setConfirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + return this; + } + /** * build the request * @@ -107,7 +120,7 @@ public class DeploymentRequestBuilder { */ public DeploymentRequest build() { return new DeploymentRequest(controllerId, distributionSetId, actionType, forceTime, weight, - maintenanceSchedule, maintenanceWindowDuration, maintenanceWindowTimeZone); + maintenanceSchedule, maintenanceWindowDuration, maintenanceWindowTimeZone, confirmationRequired); } } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/RolloutGroup.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/RolloutGroup.java index 0c246f832..aff0eefe9 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/RolloutGroup.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/RolloutGroup.java @@ -109,6 +109,12 @@ public interface RolloutGroup extends NamedEntity { */ float getTargetPercentage(); + /** + * @return if a confirmation is required for the resulting actions (considered + * with confirmation flow active only) + */ + boolean isConfirmationRequired(); + /** * Rollout group state machine. * diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java index bd9353be8..0ea994c2b 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java @@ -98,6 +98,14 @@ public interface Target extends NamedEntity { */ PollStatus getPollStatus(); + /** + * The auto confirmation status is present, when it's active for the target. + * Will only be considered in case the confirmation flow is active. + * + * @return the {@link AutoConfirmationStatus} if activated + */ + AutoConfirmationStatus getAutoConfirmationStatus(); + /** * @return true if the {@link Target} has not jet provided * {@link #getControllerAttributes()}. diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java index 3bc8f3ea8..d5e7372f6 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java @@ -81,4 +81,10 @@ public interface TargetFilterQuery extends TenantAwareBaseEntity { * @return the user that triggered the auto assignment */ String getAutoAssignInitiatedBy(); + + /** + * @return if confirmation is required for configured auto assignment + * (considered with confirmation flow active) + */ + boolean isConfirmationRequired(); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java index 847861636..ada3f27b4 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetWithActionType.java @@ -30,6 +30,7 @@ public class TargetWithActionType { private String maintenanceSchedule; private String maintenanceWindowDuration; private String maintenanceWindowTimeZone; + private final boolean confirmationRequired; /** * Constructor that uses {@link ActionType#FORCED} @@ -38,7 +39,7 @@ public class TargetWithActionType { * ID if the controller */ public TargetWithActionType(final String controllerId) { - this(controllerId, ActionType.FORCED, 0, null); + this(controllerId, ActionType.FORCED, 0, null, false); } /** @@ -49,17 +50,22 @@ public class TargetWithActionType { * @param actionType * specified for the action. * @param forceTime - * after that point in time the action is exposed as forced in - * case the type is {@link ActionType#TIMEFORCED} + * after that point in time the action is exposed as forced in case + * the type is {@link ActionType#TIMEFORCED} * @param weight * the priority of an {@link Action} + * @param confirmationRequired + * sets the confirmation required flag when starting the + * {@link Action} */ - public TargetWithActionType(final String controllerId, final ActionType actionType, final long forceTime, - final Integer weight) { + public TargetWithActionType( + final String controllerId, final ActionType actionType, final long forceTime, + final Integer weight, final boolean confirmationRequired) { this.controllerId = controllerId; this.actionType = actionType != null ? actionType : ActionType.FORCED; this.forceTime = forceTime; this.weight = weight; + this.confirmationRequired = confirmationRequired; } /** @@ -93,8 +99,8 @@ public class TargetWithActionType { */ public TargetWithActionType(final String controllerId, final ActionType actionType, final long forceTime, final Integer weight, final String maintenanceSchedule, final String maintenanceWindowDuration, - final String maintenanceWindowTimeZone) { - this(controllerId, actionType, forceTime, weight); + final String maintenanceWindowTimeZone, final boolean confirmationRequired) { + this(controllerId, actionType, forceTime, weight, confirmationRequired); this.maintenanceSchedule = maintenanceSchedule; this.maintenanceWindowDuration = maintenanceWindowDuration; @@ -152,18 +158,27 @@ public class TargetWithActionType { return maintenanceWindowTimeZone; } + /** + * Return if a confirmation is required for this assignment (depends on confirmation flow active) + * + * @return the flag + */ + public boolean isConfirmationRequired() { + return confirmationRequired; + } + @Override public String toString() { return "TargetWithActionType [controllerId=" + controllerId + ", actionType=" + getActionType() + ", forceTime=" + getForceTime() + ", weight=" + getWeight() + ", maintenanceSchedule=" + getMaintenanceSchedule() + ", maintenanceWindowDuration=" + getMaintenanceWindowDuration() + ", maintenanceWindowTimeZone=" - + getMaintenanceWindowTimeZone() + "]"; + + getMaintenanceWindowTimeZone() + ", confirmationRequired=" + isConfirmationRequired() + "]"; } @Override public int hashCode() { - return Objects.hash(actionType, controllerId, forceTime, weight, maintenanceSchedule, maintenanceWindowDuration, - maintenanceWindowTimeZone); + return Objects.hash(actionType, controllerId, forceTime, weight, confirmationRequired, maintenanceSchedule, + maintenanceWindowDuration, maintenanceWindowTimeZone); } @SuppressWarnings("squid:S1067") @@ -181,8 +196,10 @@ public class TargetWithActionType { final TargetWithActionType other = (TargetWithActionType) obj; return Objects.equals(actionType, other.actionType) && Objects.equals(controllerId, other.controllerId) && Objects.equals(forceTime, other.forceTime) && Objects.equals(weight, other.weight) + && Objects.equals(confirmationRequired, other.confirmationRequired) && Objects.equals(maintenanceSchedule, other.maintenanceSchedule) && Objects.equals(maintenanceWindowDuration, other.maintenanceWindowDuration) && Objects.equals(maintenanceWindowTimeZone, other.maintenanceWindowTimeZone); } + } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TotalTargetCountStatus.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TotalTargetCountStatus.java index a65adbf2a..ca35e73a3 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TotalTargetCountStatus.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TotalTargetCountStatus.java @@ -161,6 +161,7 @@ public class TotalTargetCountStatus { case RUNNING: case WARNING: case DOWNLOAD: + case WAIT_FOR_CONFIRMATION: case CANCELING: return Status.RUNNING; case DOWNLOADED: diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java index c49cd65d9..83086e316 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java @@ -152,6 +152,11 @@ public class TenantConfigurationProperties { */ public static final String BATCH_ASSIGNMENTS_ENABLED = "batch.assignments.enabled"; + /** + * Switch to enable/disable the user-confirmation flow + */ + public static final String USER_CONFIRMATION_ENABLED = "user.confirmation.flow.enabled"; + private String keyName; private String defaultValue = ""; private Class dataType = String.class; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/TenantConfigHelper.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/utils/TenantConfigHelper.java similarity index 80% rename from hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/TenantConfigHelper.java rename to hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/utils/TenantConfigHelper.java index d79a38861..5e4c28034 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/TenantConfigHelper.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/utils/TenantConfigHelper.java @@ -6,9 +6,10 @@ * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ -package org.eclipse.hawkbit.repository.jpa.utils; +package org.eclipse.hawkbit.utils; import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.MULTI_ASSIGNMENTS_ENABLED; +import static org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey.USER_CONFIRMATION_ENABLED; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.security.SystemSecurityContext; @@ -50,4 +51,14 @@ public final class TenantConfigHelper { return systemSecurityContext.runAsSystem(() -> tenantConfigurationManagement .getConfigurationValue(MULTI_ASSIGNMENTS_ENABLED, Boolean.class).getValue()); } + + /** + * Is confirmation flow enabled for the current tenant + * + * @return is enabled + */ + public boolean isConfirmationFlowEnabled() { + return systemSecurityContext.runAsSystem(() -> tenantConfigurationManagement + .getConfigurationValue(USER_CONFIRMATION_ENABLED, Boolean.class).getValue()); + } } diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractRolloutGroupCreate.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractRolloutGroupCreate.java index 8365707e0..cb2c01a9a 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractRolloutGroupCreate.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractRolloutGroupCreate.java @@ -23,6 +23,7 @@ public abstract class AbstractRolloutGroupCreate extends AbstractNamedEntityB protected String targetFilterQuery; protected Float targetPercentage; protected RolloutGroupConditions conditions; + protected boolean confirmationRequired; public T targetFilterQuery(final String targetFilterQuery) { this.targetFilterQuery = StringUtils.trimWhitespace(targetFilterQuery); @@ -39,4 +40,9 @@ public abstract class AbstractRolloutGroupCreate extends AbstractNamedEntityB return (T) this; } + public T confirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + return (T) this; + } + } diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java index 1044f353e..926b6b458 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java @@ -39,6 +39,8 @@ public abstract class AbstractTargetFilterQueryUpdateCreate extends AbstractB @Min(Action.WEIGHT_MIN) @Max(Action.WEIGHT_MAX) protected Integer weight; + + protected Boolean confirmationRequired; /** * Set DS ID of the {@link Action} created during auto assignment @@ -120,4 +122,19 @@ public abstract class AbstractTargetFilterQueryUpdateCreate extends AbstractB public Optional getQuery() { return Optional.ofNullable(query); } + + /** + * @param confirmationRequired + * if confirmation is required for configured auto assignment + * (considered with confirmation flow active) + * @return updated builder instance + */ + public T confirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + return (T) this; + } + + public Optional getConfirmationRequired() { + return Optional.ofNullable(confirmationRequired); + } } diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties index 912c27ee7..6fa0b76bf 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties +++ b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties @@ -101,5 +101,9 @@ hawkbit.server.tenant.configuration.batch-assignments-enabled.defaultValue=false hawkbit.server.tenant.configuration.batch-assignments-enabled.dataType=java.lang.Boolean hawkbit.server.tenant.configuration.batch-assignments-enabled.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationBooleanValidator +hawkbit.server.tenant.configuration.user-confirmation-enabled.keyName=user.confirmation.flow.enabled +hawkbit.server.tenant.configuration.user-confirmation-enabled.defaultValue=false +hawkbit.server.tenant.configuration.user-confirmation-enabled.dataType=java.lang.Boolean +hawkbit.server.tenant.configuration.user-confirmation-enabled.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationBooleanValidator # Default tenant configuration - END diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/AbstractDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/AbstractDsAssignmentStrategy.java index 08e7c280f..e8cd8b111 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/AbstractDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/AbstractDsAssignmentStrategy.java @@ -57,11 +57,13 @@ public abstract class AbstractDsAssignmentStrategy { private final ActionStatusRepository actionStatusRepository; private final QuotaManagement quotaManagement; private final BooleanSupplier multiAssignmentsConfig; + private final BooleanSupplier confirmationFlowConfig; AbstractDsAssignmentStrategy(final TargetRepository targetRepository, final AfterTransactionCommitExecutor afterCommit, final EventPublisherHolder eventPublisherHolder, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, - final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig) { + final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, + final BooleanSupplier confirmationFlowConfig) { this.targetRepository = targetRepository; this.afterCommit = afterCommit; this.eventPublisherHolder = eventPublisherHolder; @@ -69,6 +71,7 @@ public abstract class AbstractDsAssignmentStrategy { this.actionStatusRepository = actionStatusRepository; this.quotaManagement = quotaManagement; this.multiAssignmentsConfig = multiAssignmentsConfig; + this.confirmationFlowConfig = confirmationFlowConfig; } /** @@ -276,4 +279,8 @@ public abstract class AbstractDsAssignmentStrategy { protected boolean isMultiAssignmentsEnabled() { return multiAssignmentsConfig.getAsBoolean(); } + + protected boolean isConfirmationFlowEnabled() { + return confirmationFlowConfig.getAsBoolean(); + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionRepository.java index 7d6d0cd1e..7996aa020 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionRepository.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionRepository.java @@ -233,8 +233,8 @@ public interface ActionRepository extends BaseEntityRepository, /** * Retrieves all {@link Action}s of a specific target and given active flag - * ordered by action ID. Loads also the lazy - * {@link Action#getDistributionSet()} field. + * ordered by action ID. Loads also the lazy {@link Action#getDistributionSet()} + * field. * * @param pageable * page parameters @@ -251,9 +251,8 @@ public interface ActionRepository extends BaseEntityRepository, @Param("active") boolean active); /** - * Switches the status of actions from one specific status into another, - * only if the actions are in a specific status. This should be a atomar - * operation. + * Switches the status of actions from one specific status into another, only if + * the actions are in a specific status. This should be a atomar operation. * * @param statusToSet * the new status the actions should get @@ -270,6 +269,21 @@ public interface ActionRepository extends BaseEntityRepository, void switchStatus(@Param("statusToSet") Action.Status statusToSet, @Param("targetsIds") List targetIds, @Param("active") boolean active, @Param("currentStatus") Action.Status currentStatus); + /** + * + * Retrieves all active {@link Action}s by given controllerId filtered by a + * status + * + * @param controllerId + * the IDs of targets for the actions + * @param status + * the current status of the actions + * @return the found list of {@link Action} + */ + @Query("SELECT a FROM JpaAction a WHERE a.target.controllerId = :controllerId AND a.active = true AND a.status = :status") + List findByTargetIdAndIsActiveAndActionStatus(@Param("controllerId") String controllerId, + @Param("status") Action.Status status); + /** * * Retrieves all IDs for {@link Action}s referring to the given target IDs, @@ -490,8 +504,8 @@ public interface ActionRepository extends BaseEntityRepository, boolean existsByRolloutIdAndStatusNotIn(@Param("rolloutId") Long rolloutId, @Param("status") Status status); /** - * Retrieving all actions referring to a given rollout with a specific - * action as parent reference and a specific status. + * Retrieving all actions referring to a given rollout with a specific action as + * parent reference and a specific status. * * Finding all actions of a specific rolloutgroup parent relation. * @@ -506,7 +520,7 @@ public interface ActionRepository extends BaseEntityRepository, * @return the actions referring a specific rollout and a specific parent * rolloutgroup in a specific status */ - @EntityGraph(attributePaths = { "target" }, type = EntityGraphType.LOAD) + @EntityGraph(attributePaths = { "target", "target.autoConfirmationStatus", "rolloutGroup" }, type = EntityGraphType.LOAD) Page findByRolloutIdAndRolloutGroupParentIdAndStatus(Pageable pageable, Long rollout, Long rolloutGroupParent, Status actionStatus); @@ -522,7 +536,8 @@ public interface ActionRepository extends BaseEntityRepository, * @return the actions referring a specific rollout and a specific parent * rolloutgroup in a specific status */ - @EntityGraph(attributePaths = { "target" }, type = EntityGraphType.LOAD) + @EntityGraph(attributePaths = { "target", "target.autoConfirmationStatus", + "rolloutGroup" }, type = EntityGraphType.LOAD) Page findByRolloutIdAndRolloutGroupParentIsNullAndStatus(Pageable pageable, Long rollout, Status actionStatus); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaActionManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaActionManagement.java index 67ed64264..dcd978dc8 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaActionManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaActionManagement.java @@ -14,24 +14,44 @@ import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; +import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.RepositoryProperties; +import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.jpa.builder.JpaActionStatusCreate; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.jpa.model.JpaActionStatus; +import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper; import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.ActionStatus; +import org.eclipse.hawkbit.repository.model.TargetUpdateStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.PageRequest; +import static org.eclipse.hawkbit.repository.model.Action.ActionType.DOWNLOAD_ONLY; +import static org.eclipse.hawkbit.repository.model.Action.Status.FINISHED; + /** * Implements utility methods for managing {@link Action}s */ public class JpaActionManagement { + private static final Logger LOG = LoggerFactory.getLogger(JpaActionManagement.class); + protected final ActionRepository actionRepository; + protected final ActionStatusRepository actionStatusRepository; + protected final QuotaManagement quotaManagement; protected final RepositoryProperties repositoryProperties; protected JpaActionManagement(final ActionRepository actionRepository, + final ActionStatusRepository actionStatusRepository, final QuotaManagement quotaManagement, final RepositoryProperties repositoryProperties) { this.actionRepository = actionRepository; + this.actionStatusRepository = actionStatusRepository; + this.quotaManagement = quotaManagement; this.repositoryProperties = repositoryProperties; } - + protected List findActiveActionsWithHighestWeightConsideringDefault(final String controllerId, final int maxActionCount) { if (!actionRepository.activeActionExistsForControllerId(controllerId)) { @@ -50,8 +70,90 @@ public class JpaActionManagement { return actions.stream().sorted(actionImportance).limit(maxActionCount).collect(Collectors.toList()); } + protected List findActiveActionsHavingStatus(final String controllerId, final Action.Status status) { + if (!actionRepository.activeActionExistsForControllerId(controllerId)) { + return Collections.emptyList(); + } + return Collections + .unmodifiableList(actionRepository.findByTargetIdAndIsActiveAndActionStatus(controllerId, status)); + } + + protected Action addActionStatus(final JpaActionStatusCreate statusCreate) { + final Long actionId = statusCreate.getActionId(); + final JpaActionStatus actionStatus = statusCreate.build(); + final JpaAction action = getActionAndThrowExceptionIfNotFound(actionId); + + if (isUpdatingActionStatusAllowed(action, actionStatus)) { + return handleAddUpdateActionStatus(actionStatus, action); + } + + LOG.debug("Update of actionStatus {} for action {} not possible since action not active anymore.", + actionStatus.getStatus(), action.getId()); + return action; + } + + /** + * ActionStatus updates are allowed mainly if the action is active. If the + * action is not active we accept further status updates if permitted so by + * repository configuration. In this case, only the values: Status.ERROR and + * Status.FINISHED are allowed. In the case of a DOWNLOAD_ONLY action, we accept + * status updates only once. + */ + protected boolean isUpdatingActionStatusAllowed(final JpaAction action, final JpaActionStatus actionStatus) { + + final boolean isIntermediateFeedback = (FINISHED != actionStatus.getStatus()) + && (Action.Status.ERROR != actionStatus.getStatus()); + + final boolean isAllowedByRepositoryConfiguration = !repositoryProperties.isRejectActionStatusForClosedAction() + && isIntermediateFeedback; + + final boolean isAllowedForDownloadOnlyActions = isDownloadOnly(action) && !isIntermediateFeedback; + + return action.isActive() || isAllowedByRepositoryConfiguration || isAllowedForDownloadOnlyActions; + } + protected int getWeightConsideringDefault(final Action action) { return action.getWeight().orElse(repositoryProperties.getActionWeightIfAbsent()); } + protected JpaAction getActionAndThrowExceptionIfNotFound(final Long actionId) { + return actionRepository.findById(actionId) + .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)); + } + + protected static boolean isDownloadOnly(final JpaAction action) { + return DOWNLOAD_ONLY == action.getActionType(); + } + + + /** + * Sets {@link TargetUpdateStatus} based on given {@link ActionStatus}. + */ + protected Action handleAddUpdateActionStatus(final JpaActionStatus actionStatus, final JpaAction action) { + // information status entry - check for a potential DOS attack + assertActionStatusQuota(action); + assertActionStatusMessageQuota(actionStatus); + actionStatus.setAction(action); + + onActionStatusUpdate(actionStatus.getStatus(), action); + + actionStatusRepository.save(actionStatus); + + action.setLastActionStatusCode(actionStatus.getCode().orElse(null)); + return actionRepository.save(action); + } + + protected void onActionStatusUpdate(final Action.Status updatedActionStatus, final JpaAction action){ + // can be overwritten to intercept the persistence of the action status + } + + protected void assertActionStatusQuota(final JpaAction action) { + QuotaHelper.assertAssignmentQuota(action.getId(), 1, quotaManagement.getMaxStatusEntriesPerAction(), + ActionStatus.class, Action.class, actionStatusRepository::countByActionId); + } + + protected void assertActionStatusMessageQuota(final JpaActionStatus actionStatus) { + QuotaHelper.assertAssignmentQuota(actionStatus.getId(), actionStatus.getMessages().size(), + quotaManagement.getMaxMessagesPerActionStatus(), "Message", ActionStatus.class.getSimpleName(), null); + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfirmationManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfirmationManagement.java new file mode 100644 index 000000000..90361d0c7 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaConfirmationManagement.java @@ -0,0 +1,240 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.jpa; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.repository.ConfirmationManagement; +import org.eclipse.hawkbit.repository.EntityFactory; +import org.eclipse.hawkbit.repository.QuotaManagement; +import org.eclipse.hawkbit.repository.RepositoryConstants; +import org.eclipse.hawkbit.repository.RepositoryProperties; +import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; +import org.eclipse.hawkbit.repository.exception.AutoConfirmationAlreadyActiveException; +import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidConfirmationFeedbackException; +import org.eclipse.hawkbit.repository.jpa.builder.JpaActionStatusCreate; +import org.eclipse.hawkbit.repository.jpa.configuration.Constants; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.jpa.model.JpaActionStatus; +import org.eclipse.hawkbit.repository.jpa.model.JpaAutoConfirmationStatus; +import org.eclipse.hawkbit.repository.jpa.model.JpaTarget; +import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.Action.Status; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; +import org.eclipse.hawkbit.repository.model.Target; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.ConcurrencyFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.validation.annotation.Validated; + +/** + * JPA implementation for {@link ConfirmationManagement}. + * + */ +@Transactional(readOnly = true) +@Validated +public class JpaConfirmationManagement extends JpaActionManagement implements ConfirmationManagement { + + public static final String CONFIRMATION_CODE_MSG_PREFIX = "Confirmation status code: %d"; + + private static final Logger LOG = LoggerFactory.getLogger(JpaConfirmationManagement.class); + + private final EntityFactory entityFactory; + private final TargetRepository targetRepository; + + /** + * Constructor + */ + protected JpaConfirmationManagement(final TargetRepository targetRepository, + final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, + final RepositoryProperties repositoryProperties, final QuotaManagement quotaManagement, + final EntityFactory entityFactory) { + super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties); + this.targetRepository = targetRepository; + this.entityFactory = entityFactory; + } + + @Override + public List findActiveActionsWaitingConfirmation(final String controllerId) { + return Collections.unmodifiableList(findActiveActionsHavingStatus(controllerId, Status.WAIT_FOR_CONFIRMATION)); + } + + @Override + @Transactional + public AutoConfirmationStatus activateAutoConfirmation(final String controllerId, final String initiator, + final String remark) { + LOG.trace("'activateAutoConfirmation' was called with values: controllerId={}; initiator={}; remark={}", + controllerId, initiator, remark); + final JpaTarget target = getTargetByControllerIdAndThrowIfNotFound(controllerId); + if (target.getAutoConfirmationStatus() != null) { + LOG.debug("'activateAutoConfirmation' was called for an controller id {} with active auto confirmation.", + controllerId); + throw new AutoConfirmationAlreadyActiveException(controllerId); + } + final JpaAutoConfirmationStatus confirmationStatus = new JpaAutoConfirmationStatus(initiator, remark, target); + target.setAutoConfirmationStatus(confirmationStatus); + final JpaTarget updatedTarget = targetRepository.save(target); + final AutoConfirmationStatus autoConfStatus = updatedTarget.getAutoConfirmationStatus(); + if (autoConfStatus == null) { + final String message = String.format("Persisted auto confirmation status is null. " + + "Cannot proceed with giving confirmations for active actions for device %s with initiator %s.", + controllerId, initiator); + LOG.error("message"); + throw new IllegalStateException(message); + } + giveConfirmationForActiveActions(autoConfStatus); + return autoConfStatus; + } + + @Override + public Optional getStatus(final String controllerId) { + return Optional.of(getTargetByControllerIdAndThrowIfNotFound(controllerId)).map(JpaTarget::getAutoConfirmationStatus); + } + + @Override + @Transactional + public List autoConfirmActiveActions(final String controllerId) { + final JpaTarget target = getTargetByControllerIdAndThrowIfNotFound(controllerId); + if (target.getAutoConfirmationStatus() == null) { + // auto-confirmation is not active + return Collections.emptyList(); + } + return giveConfirmationForActiveActions(target.getAutoConfirmationStatus()); + } + + @Override + @Transactional(isolation = Isolation.READ_COMMITTED) + @Retryable(include = { + ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, backoff = @Backoff(delay = Constants.TX_RT_DELAY)) + public Action confirmAction(final long actionId, final Integer code, final Collection deviceMessages) { + LOG.trace("Action with id {} confirm request is triggered.", actionId); + final Action action = getActionAndThrowExceptionIfNotFound(actionId); + assertActionCanAcceptFeedback(action); + final List messages = new ArrayList<>(); + if (deviceMessages != null) { + messages.addAll(deviceMessages); + } + messages.add(RepositoryConstants.SERVER_MESSAGE_PREFIX + "Target confirmed action." + + " Therefore, it will be set to the running state to proceed with the deployment."); + final ActionStatusCreate statusCreate = createConfirmationActionStatus(action.getId(), code, messages) + .status(Status.RUNNING); + return addActionStatus((JpaActionStatusCreate) statusCreate); + } + + @Override + @Transactional(isolation = Isolation.READ_COMMITTED) + @Retryable(include = { + ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, backoff = @Backoff(delay = Constants.TX_RT_DELAY)) + public Action denyAction(final long actionId, final Integer code, final Collection deviceMessages) { + LOG.trace("Action with id {} deny request is triggered.", actionId); + final Action action = getActionAndThrowExceptionIfNotFound(actionId); + assertActionCanAcceptFeedback(action); + final List messages = new ArrayList<>(); + if (deviceMessages != null) { + messages.addAll(deviceMessages); + } + messages.add(RepositoryConstants.SERVER_MESSAGE_PREFIX + "Target rejected action." + + " Action will stay in confirmation pending state."); + final ActionStatusCreate statusCreate = createConfirmationActionStatus(action.getId(), code, messages) + .status(Status.WAIT_FOR_CONFIRMATION); + return addActionStatus((JpaActionStatusCreate) statusCreate); + } + + private ActionStatusCreate createConfirmationActionStatus(final long actionId, final Integer code, + final Collection messages) { + final ActionStatusCreate statusCreate = entityFactory.actionStatus().create(actionId); + if (!CollectionUtils.isEmpty(messages)) { + statusCreate.messages(messages); + } + if (code != null) { + statusCreate.code(code); + statusCreate.message(String.format(CONFIRMATION_CODE_MSG_PREFIX, code)); + } + return statusCreate; + } + + private static void assertActionCanAcceptFeedback(final Action action) { + if (!action.isActive()) { + final String msg = String.format( + "Confirming action %s is not possible since the action is not active anymore.", action.getId()); + LOG.warn(msg); + throw new InvalidConfirmationFeedbackException(InvalidConfirmationFeedbackException.Reason.ACTION_CLOSED, + msg); + } else if (!action.isWaitingConfirmation()) { + LOG.debug("Action is not waiting for confirmation, deny request."); + final String msg = String.format("Action %s is not waiting for confirmation.", action.getId()); + LOG.warn(msg); + throw new InvalidConfirmationFeedbackException( + InvalidConfirmationFeedbackException.Reason.NOT_AWAITING_CONFIRMATION, msg); + } + } + + private List giveConfirmationForActiveActions(final AutoConfirmationStatus autoConfirmationStatus) { + final Target target = autoConfirmationStatus.getTarget(); + return findActiveActionsHavingStatus(target.getControllerId(), Status.WAIT_FOR_CONFIRMATION).stream() + .map(action -> autoConfirmAction(action, autoConfirmationStatus)).collect(Collectors.toList()); + } + + private Action autoConfirmAction(final JpaAction action, final AutoConfirmationStatus autoConfirmationStatus) { + if (!action.isWaitingConfirmation()) { + LOG.debug("Auto-confirming action is not necessary, since action {} is in RUNNING state already.", + action.getId()); + return action; + } + final JpaActionStatus actionStatus = (JpaActionStatus) entityFactory.actionStatus().create(action.getId()) + .status(Status.RUNNING) + .messages(Collections.singletonList(autoConfirmationStatus.constructActionMessage())).build(); + LOG.debug( + "Automatically confirm actionId '{}' due to active auto-confirmation initiated by '{}' and rollouts system user '{}'", + action.getId(), autoConfirmationStatus.getInitiator(), autoConfirmationStatus.getCreatedBy()); + + // do not make use of + // org.eclipse.hawkbit.repository.jpa.JpaActionManagement.handleAddUpdateActionStatus + // to bypass the quota check. Otherwise the action will not be confirmed in case + // of exceeded action status quota. + action.setStatus(Status.RUNNING); + actionStatus.setAction(action); + + actionStatusRepository.save(actionStatus); + return actionRepository.save(action); + } + + @Override + @Transactional + public void deactivateAutoConfirmation(String controllerId) { + LOG.debug("Deactivate auto confirmation for controllerId '{}'", controllerId); + final JpaTarget target = getTargetByControllerIdAndThrowIfNotFound(controllerId); + target.setAutoConfirmationStatus(null); + targetRepository.save(target); + } + + private JpaTarget getTargetByControllerIdAndThrowIfNotFound(final String controllerId) { + return targetRepository.findOne(TargetSpecifications.hasControllerId(controllerId)) + .orElseThrow(() -> new EntityNotFoundException(Target.class, controllerId)); + } + + @Override + protected void onActionStatusUpdate(final Status updatedActionStatus, final JpaAction action) { + if (updatedActionStatus == Status.RUNNING && action.isActive()) { + action.setStatus(Status.RUNNING); + } + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java index 1fb17aea1..650bb3a3e 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaControllerManagement.java @@ -8,7 +8,6 @@ */ package org.eclipse.hawkbit.repository.jpa; -import static org.eclipse.hawkbit.repository.model.Action.ActionType.DOWNLOAD_ONLY; import static org.eclipse.hawkbit.repository.model.Action.Status.DOWNLOADED; import static org.eclipse.hawkbit.repository.model.Action.Status.FINISHED; import static org.eclipse.hawkbit.repository.model.Target.CONTROLLER_ATTRIBUTE_KEY_SIZE; @@ -39,6 +38,7 @@ import javax.persistence.criteria.Root; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; @@ -72,6 +72,7 @@ import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.Status; import org.eclipse.hawkbit.repository.model.ActionStatus; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.SoftwareModuleMetadata; @@ -125,12 +126,6 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont @Autowired private SoftwareModuleRepository softwareModuleRepository; - @Autowired - private ActionStatusRepository actionStatusRepository; - - @Autowired - private QuotaManagement quotaManagement; - @Autowired private TenantConfigurationManagement tenantConfigurationManagement; @@ -155,9 +150,13 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont @Autowired private TenantAware tenantAware; + @Autowired + private ConfirmationManagement confirmationManagement; + public JpaControllerManagement(final ScheduledExecutorService executorService, - final RepositoryProperties repositoryProperties, final ActionRepository actionRepository) { - super(actionRepository, repositoryProperties); + final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, + final QuotaManagement quotaManagement, final RepositoryProperties repositoryProperties) { + super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties); if (!repositoryProperties.isEagerPollPersistence()) { executorService.scheduleWithFixedDelay(this::flushUpdateQueue, @@ -565,11 +564,6 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont return action; } - private void assertActionStatusMessageQuota(final JpaActionStatus actionStatus) { - QuotaHelper.assertAssignmentQuota(actionStatus.getId(), actionStatus.getMessages().size(), - quotaManagement.getMaxMessagesPerActionStatus(), "Message", ActionStatus.class.getSimpleName(), null); - } - private void handleFinishedCancelation(final JpaActionStatus actionStatus, final JpaAction action) { // in case of successful cancellation we also report the success at // the canceled action itself. @@ -582,88 +576,41 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont @Transactional(isolation = Isolation.READ_COMMITTED) @Retryable(include = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, backoff = @Backoff(delay = Constants.TX_RT_DELAY)) - public Action addUpdateActionStatus(final ActionStatusCreate c) { - final JpaActionStatusCreate create = (JpaActionStatusCreate) c; - final JpaAction action = getActionAndThrowExceptionIfNotFound(create.getActionId()); - final JpaActionStatus actionStatus = create.build(); - - if (isUpdatingActionStatusAllowed(action, actionStatus)) { - return handleAddUpdateActionStatus(actionStatus, action); - } - - LOG.debug("Update of actionStatus {} for action {} not possible since action not active anymore.", - actionStatus.getStatus(), action.getId()); - return action; + public Action addUpdateActionStatus(final ActionStatusCreate statusCreate) { + return addActionStatus((JpaActionStatusCreate) statusCreate); } - /** - * ActionStatus updates are allowed mainly if the action is active. If the - * action is not active we accept further status updates if permitted so by - * repository configuration. In this case, only the values: Status.ERROR and - * Status.FINISHED are allowed. In the case of a DOWNLOAD_ONLY action, we accept - * status updates only once. - */ - private boolean isUpdatingActionStatusAllowed(final JpaAction action, final JpaActionStatus actionStatus) { - - final boolean isIntermediateFeedback = (FINISHED != actionStatus.getStatus()) - && (Status.ERROR != actionStatus.getStatus()); - - final boolean isAllowedByRepositoryConfiguration = !repositoryProperties.isRejectActionStatusForClosedAction() - && isIntermediateFeedback; - - final boolean isAllowedForDownloadOnlyActions = isDownloadOnly(action) && !isIntermediateFeedback; - - return action.isActive() || isAllowedByRepositoryConfiguration || isAllowedForDownloadOnlyActions; - } - - private static boolean isDownloadOnly(final JpaAction action) { - return DOWNLOAD_ONLY == action.getActionType(); - } - - /** - * Sets {@link TargetUpdateStatus} based on given {@link ActionStatus}. - */ - private Action handleAddUpdateActionStatus(final JpaActionStatus actionStatus, final JpaAction action) { - - String controllerId = null; - LOG.debug("handleAddUpdateActionStatus for action {}", action.getId()); - - // information status entry - check for a potential DOS attack - assertActionStatusQuota(action); - assertActionStatusMessageQuota(actionStatus); - - switch (actionStatus.getStatus()) { + @Override + protected void onActionStatusUpdate(final Action.Status updatedActionStatus, final JpaAction action) { + switch (updatedActionStatus) { case ERROR: final JpaTarget target = (JpaTarget) action.getTarget(); target.setUpdateStatus(TargetUpdateStatus.ERROR); handleErrorOnAction(action, target); break; case FINISHED: - controllerId = handleFinishedAndStoreInTargetStatus(action); + handleFinishedAndStoreInTargetStatus(action).ifPresent(this::requestControllerAttributes); break; case DOWNLOADED: - controllerId = handleDownloadedActionStatus(action); + handleDownloadedActionStatus(action).ifPresent(this::requestControllerAttributes); break; default: break; } - - actionStatus.setAction(action); - actionStatusRepository.save(actionStatus); - - action.setLastActionStatusCode(actionStatus.getCode().orElse(null)); - final Action savedAction = actionRepository.save(action); - - if (controllerId != null) { - requestControllerAttributes(controllerId); - } - - return savedAction; } - private String handleDownloadedActionStatus(final JpaAction action) { + /** + * Handles the case where the {@link Action.Status#DOWNLOADED} status is + * reported by the device. In case the update is finished, a controllerId will + * be returned to trigger a request for attributes. + * + * @param action + * updated action + * @return a present controllerId in case the attributes needs to be requested. + */ + private Optional handleDownloadedActionStatus(final JpaAction action) { if (!isDownloadOnly(action)) { - return null; + return Optional.empty(); } final JpaTarget target = (JpaTarget) action.getTarget(); @@ -672,7 +619,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont target.setUpdateStatus(TargetUpdateStatus.IN_SYNC); targetRepository.save(target); - return target.getControllerId(); + return Optional.of(target.getControllerId()); } private void requestControllerAttributes(final String controllerId) { @@ -695,12 +642,16 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont targetRepository.save(mergedTarget); } - private void assertActionStatusQuota(final JpaAction action) { - QuotaHelper.assertAssignmentQuota(action.getId(), 1, quotaManagement.getMaxStatusEntriesPerAction(), - ActionStatus.class, Action.class, actionStatusRepository::countByActionId); - } - - private String handleFinishedAndStoreInTargetStatus(final JpaAction action) { + /** + * Handles the case where the {@link Action.Status#FINISHED} status is + * reported by the device. In case the update is finished, a controllerId will + * be returned to trigger a request for attributes. + * + * @param action + * updated action + * @return a present controllerId in case the attributes needs to be requested. + */ + private Optional handleFinishedAndStoreInTargetStatus(final JpaAction action) { final JpaTarget target = (JpaTarget) action.getTarget(); action.setActive(false); action.setStatus(Status.FINISHED); @@ -727,7 +678,7 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont targetRepository.save(target); entityManager.detach(ds); - return target.getControllerId(); + return Optional.of(target.getControllerId()); } @Override @@ -884,11 +835,6 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont return actionStatusRepository.save(statusMessage); } - private JpaAction getActionAndThrowExceptionIfNotFound(final Long actionId) { - return actionRepository.findById(actionId) - .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)); - } - @Override public Optional getByControllerId(final String controllerId) { return targetRepository.findOne(TargetSpecifications.hasControllerId(controllerId)).map(Target.class::cast); @@ -1076,6 +1022,17 @@ public class JpaControllerManagement extends JpaActionManagement implements Cont } } + @Override + public AutoConfirmationStatus activateAutoConfirmation(final String controllerId, final String initiator, + final String remark) { + return confirmationManagement.activateAutoConfirmation(controllerId, initiator, remark); + } + + @Override + public void deactivateAutoConfirmation(final String controllerId) { + confirmationManagement.deactivateAutoConfirmation(controllerId); + } + private void cancelAssignDistributionSetEvent(final Action action) { afterCommit.afterCommit(() -> eventPublisherHolder.getEventPublisher().publishEvent( new CancelTargetAssignmentEvent(action, eventPublisherHolder.getApplicationId()))); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDeploymentManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDeploymentManagement.java index c7c959d20..0c13cf848 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDeploymentManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDeploymentManagement.java @@ -16,6 +16,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -61,7 +62,6 @@ import org.eclipse.hawkbit.repository.jpa.rsql.RSQLUtility; import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications; import org.eclipse.hawkbit.repository.jpa.utils.DeploymentHelper; import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper; -import org.eclipse.hawkbit.repository.jpa.utils.TenantConfigHelper; import org.eclipse.hawkbit.repository.jpa.utils.WeightValidationHelper; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.ActionType; @@ -81,6 +81,7 @@ import org.eclipse.hawkbit.repository.model.helper.EventPublisherHolder; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer; import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.tenancy.TenantAware; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.ConcurrencyFailureException; @@ -138,14 +139,12 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl private final DistributionSetManagement distributionSetManagement; private final DistributionSetRepository distributionSetRepository; private final TargetRepository targetRepository; - private final ActionStatusRepository actionStatusRepository; private final AuditorAware auditorProvider; private final VirtualPropertyReplacer virtualPropertyReplacer; private final PlatformTransactionManager txManager; private final OnlineDsAssignmentStrategy onlineDsAssignmentStrategy; private final OfflineDsAssignmentStrategy offlineDsAssignmentStrategy; private final TenantConfigurationManagement tenantConfigurationManagement; - private final QuotaManagement quotaManagement; private final SystemSecurityContext systemSecurityContext; private final TenantAware tenantAware; private final Database database; @@ -160,26 +159,25 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl final TenantConfigurationManagement tenantConfigurationManagement, final QuotaManagement quotaManagement, final SystemSecurityContext systemSecurityContext, final TenantAware tenantAware, final Database database, final RepositoryProperties repositoryProperties) { - super(actionRepository, repositoryProperties); + super(actionRepository, actionStatusRepository, quotaManagement, repositoryProperties); this.entityManager = entityManager; this.distributionSetRepository = distributionSetRepository; this.distributionSetManagement = distributionSetManagement; this.targetRepository = targetRepository; - this.actionStatusRepository = actionStatusRepository; this.auditorProvider = auditorProvider; this.virtualPropertyReplacer = virtualPropertyReplacer; this.txManager = txManager; onlineDsAssignmentStrategy = new OnlineDsAssignmentStrategy(targetRepository, afterCommit, eventPublisherHolder, - actionRepository, actionStatusRepository, quotaManagement, this::isMultiAssignmentsEnabled); + actionRepository, actionStatusRepository, quotaManagement, this::isMultiAssignmentsEnabled, + this::isConfirmationFlowEnabled); offlineDsAssignmentStrategy = new OfflineDsAssignmentStrategy(targetRepository, afterCommit, eventPublisherHolder, actionRepository, actionStatusRepository, quotaManagement, - this::isMultiAssignmentsEnabled); + this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled); this.tenantConfigurationManagement = tenantConfigurationManagement; - this.quotaManagement = quotaManagement; this.systemSecurityContext = systemSecurityContext; this.tenantAware = tenantAware; this.database = database; - retryTemplate = createRetryTemplate(); + this.retryTemplate = createRetryTemplate(); } @Override @@ -393,7 +391,7 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl targetEntitiesIdsChunks.forEach(this::cancelInactiveScheduledActionsForTargets); setAssignedDistributionSetAndTargetUpdateStatus(assignmentStrategy, distributionSetEntity, targetEntitiesIdsChunks); - final List assignedActions = createActions(initiatedBy, targetsWithActionType, targetEntities, + final Map assignedActions = createActions(initiatedBy, targetsWithActionType, targetEntities, assignmentStrategy, distributionSetEntity); // create initial action status when action is created so we remember // the initial running status because we will change the status @@ -402,7 +400,7 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl createActionsStatus(assignedActions, assignmentStrategy, actionMessage); detachEntitiesAndSendTargetUpdatedEvents(distributionSetEntity, targetEntities, assignmentStrategy); - return assignedActions; + return new ArrayList<>(assignedActions.values()); } /** @@ -473,20 +471,62 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl assignmentStrategy.setAssignedDistributionSetAndTargetStatus(set, targetIdsChunks, currentUser); } - private List createActions(final String initiatedBy, + private Map createActions(final String initiatedBy, final Collection targetsWithActionType, final List targets, final AbstractDsAssignmentStrategy assignmentStrategy, final JpaDistributionSet set) { - return targetsWithActionType.stream() - .map(twt -> assignmentStrategy.createTargetAction(initiatedBy, twt, targets, set)) - .filter(Objects::nonNull).map(actionRepository::save).collect(Collectors.toList()); + final Map persistedActions = new LinkedHashMap<>(); + + for (final TargetWithActionType twt : targetsWithActionType) { + final JpaAction targetAction = assignmentStrategy.createTargetAction(initiatedBy, twt, targets, set); + if (targetAction != null) { + persistedActions.put(twt, actionRepository.save(targetAction)); + } + } + return persistedActions; } - private void createActionsStatus(final Collection actions, + private void createActionsStatus(final Map actions, final AbstractDsAssignmentStrategy assignmentStrategy, final String actionMessage) { - actionStatusRepository - .saveAll(actions.stream().map(action -> assignmentStrategy.createActionStatus(action, actionMessage)) - .collect(Collectors.toList())); + actionStatusRepository.saveAll(actions.entrySet().stream().map(entry -> { + final JpaAction action = entry.getValue(); + final JpaActionStatus actionStatus = assignmentStrategy.createActionStatus(action, actionMessage); + verifyAndAddConfirmationStatus(action, actionStatus, entry.getKey().isConfirmationRequired()); + return actionStatus; + }).collect(Collectors.toList())); + } + + private void setInitialActionStatusOfRolloutGroup(final List actions) { + final List statusList = new ArrayList<>(); + for (final JpaAction action : actions) { + final JpaActionStatus actionStatus = onlineDsAssignmentStrategy.createActionStatus(action, null); + verifyAndAddConfirmationStatus(action, actionStatus, action.getRolloutGroup().isConfirmationRequired()); + statusList.add(actionStatus); + } + actionStatusRepository.saveAll(statusList); + } + + private void verifyAndAddConfirmationStatus(final JpaAction action, final JpaActionStatus actionStatus, + final boolean isConfirmationRequired) { + if (actionStatus.getStatus() == Status.WAIT_FOR_CONFIRMATION) { + if (action.getStatus().equals(Status.RUNNING)) { + // action is in RUNNING state only if it's confirmed during assignment already + if (!isConfirmationRequired) { + // confirmation given on assignment dialog + actionStatus.addMessage( + String.format("Assignment confirmed by initiator [%s].", action.getInitiatedBy())); + } else if (action.getTarget().getAutoConfirmationStatus() != null) { + // auto-confirmation is configured + actionStatus.addMessage(action.getTarget().getAutoConfirmationStatus().constructActionMessage()); + } else { + throw new IllegalStateException("Action in RUNNING state without given confirmation."); + } + + } else { + actionStatus + .addMessage("Waiting for the confirmation by the device before processing with the deployment"); + } + } } private void detachEntitiesAndSendTargetUpdatedEvents(final JpaDistributionSet set, final List targets, @@ -643,15 +683,15 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl if (!isMultiAssignmentsEnabled()) { closeOrCancelOpenDeviceActions(actions); } - final List savedActions = activateActions(actions); - setInitialActionStatus(savedActions); + final List savedActions = activateActionsOfRolloutGroup(actions); + setInitialActionStatusOfRolloutGroup(savedActions); setAssignmentOnTargets(savedActions); return Collections.unmodifiableList(savedActions); } - private void closeOrCancelOpenDeviceActions(final List actions) { + private void closeOrCancelOpenDeviceActions(final List actions){ final List targetIds = actions.stream().map(JpaAction::getTarget).map(Target::getId) - .collect(Collectors.toList()); + .collect(Collectors.toList()); if (isActionsAutocloseEnabled()) { onlineDsAssignmentStrategy.closeObsoleteUpdateActions(targetIds); } else { @@ -659,9 +699,15 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl } } - private List activateActions(final List actions) { + private List activateActionsOfRolloutGroup(final List actions) { actions.forEach(action -> { action.setActive(true); + final boolean confirmationRequired = action.getRolloutGroup().isConfirmationRequired() + && action.getTarget().getAutoConfirmationStatus() == null; + if (isConfirmationFlowEnabled() && confirmationRequired) { + action.setStatus(Status.WAIT_FOR_CONFIRMATION); + return; + } action.setStatus(Status.RUNNING); }); return actionRepository.saveAll(actions); @@ -678,14 +724,6 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl targetRepository.saveAll(assignedDsTargets); } - private void setInitialActionStatus(final List actions) { - final List statusList = new ArrayList<>(); - for (final JpaAction action : actions) { - statusList.add(onlineDsAssignmentStrategy.createActionStatus(action, null)); - } - actionStatusRepository.saveAll(statusList); - } - private void setSkipActionStatus(final JpaAction action) { final JpaActionStatus actionStatus = new JpaActionStatus(); actionStatus.setAction(action); @@ -745,7 +783,6 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl public List findActiveActionsWithHighestWeight(final String controllerId, final int maxActionCount) { return findActiveActionsWithHighestWeightConsideringDefault(controllerId, maxActionCount); } - @Override public int getWeightConsideringDefault(final Action action) { return super.getWeightConsideringDefault(action); @@ -967,6 +1004,11 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl .isMultiAssignmentsEnabled(); } + private boolean isConfirmationFlowEnabled() { + return TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement) + .isConfirmationFlowEnabled(); + } + private T getConfigValue(final String key, final Class valueType) { return systemSecurityContext .runAsSystem(() -> tenantConfigurationManagement.getConfigurationValue(key, valueType).getValue()); @@ -1002,5 +1044,4 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl }); } } - } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java index 1bba54f77..be3065ae2 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java @@ -71,6 +71,7 @@ import org.eclipse.hawkbit.repository.model.helper.EventPublisherHolder; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer; import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.tenancy.TenantAware; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -193,10 +194,11 @@ public class JpaRolloutManagement implements RolloutManagement { @Transactional @Retryable(include = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, backoff = @Backoff(delay = Constants.TX_RT_DELAY)) - public Rollout create(final RolloutCreate rollout, final int amountGroup, final RolloutGroupConditions conditions) { + public Rollout create(final RolloutCreate rollout, final int amountGroup, final boolean confirmationRequired, + final RolloutGroupConditions conditions) { RolloutHelper.verifyRolloutGroupParameter(amountGroup, quotaManagement); final JpaRollout savedRollout = createRollout((JpaRollout) rollout.build()); - return createRolloutGroups(amountGroup, conditions, savedRollout); + return createRolloutGroups(amountGroup, conditions, savedRollout, confirmationRequired); } @Override @@ -222,7 +224,7 @@ public class JpaRolloutManagement implements RolloutManagement { } private Rollout createRolloutGroups(final int amountOfGroups, final RolloutGroupConditions conditions, - final JpaRollout rollout) { + final JpaRollout rollout, final boolean isConfirmationRequired) { RolloutHelper.verifyRolloutInStatus(rollout, RolloutStatus.CREATING); RolloutHelper.verifyRolloutGroupConditions(conditions); @@ -241,6 +243,7 @@ public class JpaRolloutManagement implements RolloutManagement { group.setRollout(savedRollout); group.setParent(lastSavedGroup); group.setStatus(RolloutGroupStatus.CREATING); + group.setConfirmationRequired(isConfirmationRequired); addSuccessAndErrorConditionsAndActions(group, conditions); @@ -283,6 +286,7 @@ public class JpaRolloutManagement implements RolloutManagement { group.setRollout(savedRollout); group.setParent(lastSavedGroup); group.setStatus(RolloutGroupStatus.CREATING); + group.setConfirmationRequired(srcGroup.isConfirmationRequired()); group.setTargetPercentage(srcGroup.getTargetPercentage()); if (srcGroup.getTargetFilterQuery() != null) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java index a21692913..ae155ddb8 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java @@ -43,6 +43,7 @@ import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer; import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.tenancy.TenantAware; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.ConcurrencyFailureException; @@ -249,6 +250,7 @@ public class JpaTargetFilterQueryManagement implements TargetFilterQueryManageme // set the new query targetFilterQuery.setQuery(query); }); + update.getConfirmationRequired().ifPresent(targetFilterQuery::setConfirmationRequired); return targetFilterQueryRepository.save(targetFilterQuery); } @@ -263,6 +265,7 @@ public class JpaTargetFilterQueryManagement implements TargetFilterQueryManageme targetFilterQuery.setAutoAssignActionType(null); targetFilterQuery.setAutoAssignWeight(null); targetFilterQuery.setAutoAssignInitiatedBy(null); + targetFilterQuery.setConfirmationRequired(false); } else { WeightValidationHelper.usingContext(systemSecurityContext, tenantConfigurationManagement).validate(update); // we cannot be sure that the quota was enforced at creation time @@ -277,10 +280,18 @@ public class JpaTargetFilterQueryManagement implements TargetFilterQueryManageme targetFilterQuery.setAutoAssignInitiatedBy(tenantAware.getCurrentUsername()); targetFilterQuery.setAutoAssignActionType(sanitizeAutoAssignActionType(update.getActionType())); targetFilterQuery.setAutoAssignWeight(update.getWeight()); + final boolean confirmationRequired = update.isConfirmationRequired() == null ? isConfirmationFlowEnabled() + : update.isConfirmationRequired(); + targetFilterQuery.setConfirmationRequired(confirmationRequired); } return targetFilterQueryRepository.save(targetFilterQuery); } + private boolean isConfirmationFlowEnabled() { + return TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement) + .isConfirmationFlowEnabled(); + } + private static void verifyDistributionSetAndThrowExceptionIfDeleted(final DistributionSet distributionSet) { if (distributionSet.isDeleted()) { throw new EntityNotFoundException(DistributionSet.class, distributionSet.getId()); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java index cffb0f5df..0d13a2f91 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java @@ -40,12 +40,14 @@ import org.eclipse.hawkbit.repository.builder.TargetUpdate; import org.eclipse.hawkbit.repository.event.remote.TargetAttributesRequestedEvent; import org.eclipse.hawkbit.repository.event.remote.TargetDeletedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent; +import org.eclipse.hawkbit.repository.exception.AutoConfirmationAlreadyActiveException; import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetCreate; import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetUpdate; import org.eclipse.hawkbit.repository.jpa.configuration.Constants; import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor; +import org.eclipse.hawkbit.repository.jpa.model.JpaAutoConfirmationStatus; import org.eclipse.hawkbit.repository.jpa.model.JpaTarget; import org.eclipse.hawkbit.repository.jpa.model.JpaTargetMetadata; import org.eclipse.hawkbit.repository.jpa.model.JpaTargetMetadata_; @@ -57,6 +59,7 @@ import org.eclipse.hawkbit.repository.jpa.rsql.RSQLUtility; import org.eclipse.hawkbit.repository.jpa.specifications.SpecificationsBuilder; import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications; import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSetType; import org.eclipse.hawkbit.repository.model.MetaData; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OfflineDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OfflineDsAssignmentStrategy.java index cc6566a7f..0bc456fbb 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OfflineDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OfflineDsAssignmentStrategy.java @@ -45,9 +45,10 @@ public class OfflineDsAssignmentStrategy extends AbstractDsAssignmentStrategy { OfflineDsAssignmentStrategy(final TargetRepository targetRepository, final AfterTransactionCommitExecutor afterCommit, final EventPublisherHolder eventPublisherHolder, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, - final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig) { + final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, + final BooleanSupplier confirmationFlowConfig) { super(targetRepository, afterCommit, eventPublisherHolder, actionRepository, actionStatusRepository, - quotaManagement, multiAssignmentsConfig); + quotaManagement, multiAssignmentsConfig, confirmationFlowConfig); } @Override diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OnlineDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OnlineDsAssignmentStrategy.java index ee4892fc0..63a1d3e1f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OnlineDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/OnlineDsAssignmentStrategy.java @@ -48,9 +48,10 @@ public class OnlineDsAssignmentStrategy extends AbstractDsAssignmentStrategy { OnlineDsAssignmentStrategy(final TargetRepository targetRepository, final AfterTransactionCommitExecutor afterCommit, final EventPublisherHolder eventPublisherHolder, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, - final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig) { + final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, + final BooleanSupplier confirmationFlowConfig) { super(targetRepository, afterCommit, eventPublisherHolder, actionRepository, actionStatusRepository, - quotaManagement, multiAssignmentsConfig); + quotaManagement, multiAssignmentsConfig, confirmationFlowConfig); } @Override @@ -130,15 +131,28 @@ public class OnlineDsAssignmentStrategy extends AbstractDsAssignmentStrategy { final List targets, final JpaDistributionSet set) { final JpaAction result = super.createTargetAction(initiatedBy, targetWithActionType, targets, set); if (result != null) { - result.setStatus(Status.RUNNING); + final boolean confirmationRequired = targetWithActionType.isConfirmationRequired() + && result.getTarget().getAutoConfirmationStatus() == null; + if (isConfirmationFlowEnabled() && confirmationRequired) { + result.setStatus(Status.WAIT_FOR_CONFIRMATION); + } else { + result.setStatus(Status.RUNNING); + } } return result; } + /** + * Will be called to create the initial action status for an action + */ @Override public JpaActionStatus createActionStatus(final JpaAction action, final String actionMessage) { final JpaActionStatus result = super.createActionStatus(action, actionMessage); - result.setStatus(Status.RUNNING); + if (isConfirmationFlowEnabled()) { + result.setStatus(Status.WAIT_FOR_CONFIRMATION); + } else { + result.setStatus(Status.RUNNING); + } return result; } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/RepositoryApplicationConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/RepositoryApplicationConfiguration.java index 6745972b4..96d96f4c3 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/RepositoryApplicationConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/RepositoryApplicationConfiguration.java @@ -22,6 +22,7 @@ import org.eclipse.hawkbit.repository.ArtifactEncryptionSecretsStore; import org.eclipse.hawkbit.repository.ArtifactEncryptionService; import org.eclipse.hawkbit.repository.ArtifactManagement; import org.eclipse.hawkbit.repository.BaseRepositoryTypeProvider; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; @@ -742,6 +743,16 @@ public class RepositoryApplicationConfiguration extends JpaBaseConfiguration { quotaManagement, systemSecurityContext, tenantAware, properties.getDatabase(), repositoryProperties); } + @Bean + @ConditionalOnMissingBean + ConfirmationManagement confirmationManagement(final TargetRepository targetRepository, + final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, + final RepositoryProperties repositoryProperties, final QuotaManagement quotaManagement, + final EntityFactory entityFactory) { + return new JpaConfirmationManagement(targetRepository, actionRepository, actionStatusRepository, + repositoryProperties, quotaManagement, entityFactory); + } + /** * {@link JpaControllerManagement} bean. * @@ -750,8 +761,10 @@ public class RepositoryApplicationConfiguration extends JpaBaseConfiguration { @Bean @ConditionalOnMissingBean ControllerManagement controllerManagement(final ScheduledExecutorService executorService, - final RepositoryProperties repositoryProperties, final ActionRepository actionRepository) { - return new JpaControllerManagement(executorService, repositoryProperties, actionRepository); + final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, + final QuotaManagement quotaManagement, final RepositoryProperties repositoryProperties) { + return new JpaControllerManagement(executorService, actionRepository, actionStatusRepository, quotaManagement, + repositoryProperties); } @Bean diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AbstractAutoAssignExecutor.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AbstractAutoAssignExecutor.java index caa7ac364..a6a6a61ae 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AbstractAutoAssignExecutor.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AbstractAutoAssignExecutor.java @@ -167,7 +167,7 @@ public abstract class AbstractAutoAssignExecutor implements AutoAssignExecutor { .map(controllerId -> DeploymentManagement .deploymentRequest(controllerId, filterQuery.getAutoAssignDistributionSet().getId()) .setActionType(autoAssignActionType).setWeight(filterQuery.getAutoAssignWeight().orElse(null)) - .build()) + .setConfirmationRequired(filterQuery.isConfirmationRequired()).build()) .collect(Collectors.toList()); } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaRolloutGroupCreate.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaRolloutGroupCreate.java index 50c861731..810e386cc 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaRolloutGroupCreate.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaRolloutGroupCreate.java @@ -34,6 +34,7 @@ public class JpaRolloutGroupCreate extends AbstractRolloutGroupCreate metadata; + @OneToOne(fetch = FetchType.LAZY, mappedBy = "target", orphanRemoval = true) + @PrimaryKeyJoinColumn + private JpaAutoConfirmationStatus autoConfirmationStatus; + /** * Constructor. * @@ -372,6 +379,15 @@ public class JpaTarget extends AbstractJpaNamedEntity implements Target, EventAw }); } + @Override + public AutoConfirmationStatus getAutoConfirmationStatus() { + return autoConfirmationStatus; + } + + public void setAutoConfirmationStatus(final JpaAutoConfirmationStatus autoConfirmationStatus) { + this.autoConfirmationStatus = autoConfirmationStatus; + } + @Override public Long getLastTargetQuery() { return lastTargetQuery; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java index 2a26f73f5..a464f77f3 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java @@ -80,6 +80,9 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity @Column(name = "auto_assign_initiated_by", nullable = true, length = USERNAME_FIELD_LENGTH) private String autoAssignInitiatedBy; + @Column(name = "confirmation_required") + private boolean confirmationRequired; + public JpaTargetFilterQuery() { // Default constructor for JPA. } @@ -97,14 +100,17 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity * of the {@link TargetFilterQuery}. * @param autoAssignWeight * of the {@link TargetFilterQuery}. + * @param confirmationRequired + * of the {@link TargetFilterQuery}. */ public JpaTargetFilterQuery(final String name, final String query, final DistributionSet autoAssignDistributionSet, - final ActionType autoAssignActionType, final Integer autoAssignWeight) { + final ActionType autoAssignActionType, final Integer autoAssignWeight, final boolean confirmationRequired) { this.name = name; this.query = query; this.autoAssignDistributionSet = (JpaDistributionSet) autoAssignDistributionSet; this.autoAssignActionType = autoAssignActionType; this.autoAssignWeight = autoAssignWeight; + this.confirmationRequired = confirmationRequired; } @Override @@ -160,6 +166,15 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity this.autoAssignInitiatedBy = autoAssignInitiatedBy; } + @Override + public boolean isConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } + @Override public void fireCreateEvent(final DescriptorEvent descriptorEvent) { EventPublisherHolder.getInstance().getEventPublisher().publishEvent( @@ -177,4 +192,5 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity EventPublisherHolder.getInstance().getEventPublisher().publishEvent(new TargetFilterQueryDeletedEvent( getTenant(), getId(), getClass(), EventPublisherHolder.getInstance().getApplicationId())); } + } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/WeightValidationHelper.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/WeightValidationHelper.java index 3e333b391..39a8473e0 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/WeightValidationHelper.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/WeightValidationHelper.java @@ -18,6 +18,7 @@ import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetFilterQueryCreate; import org.eclipse.hawkbit.repository.model.DeploymentRequest; import org.eclipse.hawkbit.repository.model.Rollout; import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; /** * Utility class to handle weight validation in Rollout, Auto Assignments, and diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_25__add_confirmation_flag___DB2.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_25__add_confirmation_flag___DB2.sql new file mode 100644 index 000000000..5c16b5d34 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_25__add_confirmation_flag___DB2.sql @@ -0,0 +1,22 @@ +ALTER TABLE sp_rolloutgroup ADD COLUMN confirmation_required BOOLEAN; +UPDATE sp_rolloutgroup SET confirmation_required = 0; + +ALTER TABLE sp_target_filter_query ADD COLUMN confirmation_required BOOLEAN; +UPDATE sp_target_filter_query SET confirmation_required = 0; + +CREATE TABLE sp_target_conf_status +( + id BIGINT GENERATED always AS IDENTITY NOT NULL, + target_id bigint not null, + initiator VARCHAR(64), + remark VARCHAR(512), + created_at BIGINT, + created_by VARCHAR(64), + last_modified_at BIGINT, + last_modified_by VARCHAR(64), + optlock_revision BIGINT, + tenant VARCHAR(40) not null, + primary key (id) +); +ALTER TABLE sp_target_conf_status + ADD CONSTRAINT fk_target_auto_conf FOREIGN KEY (target_id) REFERENCES sp_target (id) ON DELETE CASCADE; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_25__add_confirmation_flag___H2.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_25__add_confirmation_flag___H2.sql new file mode 100644 index 000000000..8249c85d4 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_25__add_confirmation_flag___H2.sql @@ -0,0 +1,22 @@ +ALTER TABLE sp_rolloutgroup ADD column confirmation_required BOOLEAN; +UPDATE sp_rolloutgroup SET confirmation_required = 0; + +ALTER TABLE sp_target_filter_query ADD column confirmation_required BOOLEAN; +UPDATE sp_target_filter_query SET confirmation_required = 0; + +create table sp_target_conf_status +( + id bigint not null auto_increment, + target_id bigint not null, + initiator varchar(64), + remark VARCHAR(512), + created_at bigint, + created_by varchar(64), + last_modified_at bigint, + last_modified_by varchar(64), + optlock_revision bigint, + tenant varchar(40) not null, + primary key (id) +); +ALTER TABLE sp_target_conf_status + ADD CONSTRAINT fk_target_auto_conf FOREIGN KEY (target_id) REFERENCES sp_target (id) ON DELETE CASCADE; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_25__add_confirmation_flag___MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_25__add_confirmation_flag___MYSQL.sql new file mode 100644 index 000000000..e8a7594c7 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_25__add_confirmation_flag___MYSQL.sql @@ -0,0 +1,22 @@ +ALTER TABLE sp_rolloutgroup ADD column confirmation_required BOOLEAN; +UPDATE sp_rolloutgroup SET confirmation_required = 0; + +ALTER TABLE sp_target_filter_query ADD column confirmation_required BOOLEAN; +UPDATE sp_target_filter_query SET confirmation_required = 0; + +create table sp_target_conf_status +( + id bigint not null auto_increment, + target_id bigint not null, + initiator varchar(64), + remark varchar(512), + created_at bigint, + created_by varchar(64), + last_modified_at bigint, + last_modified_by varchar(64), + optlock_revision bigint, + tenant varchar(40) not null, + primary key (id) +); +ALTER TABLE sp_target_conf_status + ADD CONSTRAINT fk_target_auto_conf FOREIGN KEY (target_id) REFERENCES sp_target (id) ON DELETE CASCADE; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_19__add_valid_flag_to_ds___POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_19__add_valid_flag_to_ds___POSTGRESQL.sql index 3ab0e7502..6d2f70e74 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_19__add_valid_flag_to_ds___POSTGRESQL.sql +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_19__add_valid_flag_to_ds___POSTGRESQL.sql @@ -1,3 +1,3 @@ ALTER TABLE sp_distribution_set ADD COLUMN valid BOOLEAN; -UPDATE sp_distribution_set SET valid = 1; \ No newline at end of file +UPDATE sp_distribution_set SET valid = TRUE; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_20__add_encryption_flag_to_sm___POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_20__add_encryption_flag_to_sm___POSTGRESQL.sql index 9249d14a1..00ae94f2d 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_20__add_encryption_flag_to_sm___POSTGRESQL.sql +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_20__add_encryption_flag_to_sm___POSTGRESQL.sql @@ -1,3 +1,3 @@ ALTER TABLE sp_base_software_module ADD COLUMN encrypted BOOLEAN; -UPDATE sp_base_software_module SET encrypted = 0; \ No newline at end of file +UPDATE sp_base_software_module SET encrypted = FALSE; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_22__change_target_type_name_length___POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_22__change_target_type_name_length___POSTGRESQL.sql index 6516a6b7a..c43d99f1f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_22__change_target_type_name_length___POSTGRESQL.sql +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_22__change_target_type_name_length___POSTGRESQL.sql @@ -1 +1 @@ -ALTER TABLE sp_target_type ALTER COLUMN name VARCHAR (128); \ No newline at end of file +ALTER TABLE sp_target_type ALTER COLUMN name TYPE VARCHAR (128); \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_23__add_action_status_code___POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_23__add_action_status_code___POSTGRESQL.sql index 305794b70..623bc3141 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_23__add_action_status_code___POSTGRESQL.sql +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_23__add_action_status_code___POSTGRESQL.sql @@ -1,4 +1,4 @@ -ALTER TABLE sp_action_status ADD code INTEGER; +ALTER TABLE sp_action_status ADD COLUMN code INTEGER; CREATE INDEX sp_idx_action_status_03 ON sp_action_status USING BTREE (tenant, code); \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_25__add_confirmation_flag___POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_25__add_confirmation_flag___POSTGRESQL.sql new file mode 100644 index 000000000..265771afe --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/POSTGRESQL/V1_12_25__add_confirmation_flag___POSTGRESQL.sql @@ -0,0 +1,26 @@ +ALTER TABLE sp_rolloutgroup + ADD COLUMN confirmation_required BOOLEAN; +ALTER TABLE sp_target_filter_query + ADD COLUMN confirmation_required BOOLEAN; + +UPDATE sp_rolloutgroup SET confirmation_required = FALSE; +UPDATE sp_target_filter_query SET confirmation_required = FALSE; + +CREATE TABLE sp_target_conf_status +( + id BIGSERIAL, + target_id BIGINT NOT NULL, + initiator VARCHAR(64), + remark VARCHAR(512), + tenant VARCHAR(40) NOT NULL, + created_at BIGINT NOT NULL, + created_by VARCHAR(64) NOT NULL, + last_modified_at BIGINT NOT NULL, + last_modified_by VARCHAR(64) NOT NULL, + optlock_revision BIGINT NULL +); + + +ALTER TABLE sp_target_conf_status + ADD CONSTRAINT pk_sp_target_conf_status PRIMARY KEY (id); + diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_25__add_confirmation_flag___SQL_SERVER.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_25__add_confirmation_flag___SQL_SERVER.sql new file mode 100644 index 000000000..0cd9e2fef --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_25__add_confirmation_flag___SQL_SERVER.sql @@ -0,0 +1,19 @@ +ALTER TABLE sp_rolloutgroup ADD confirmation_required BIT DEFAULT 0; +ALTER TABLE sp_target_filter_query ADD confirmation_required BIT DEFAULT 0; + +CREATE TABLE sp_target_conf_status +( + id NUMERIC(19) IDENTITY NOT NULL, + target_id NUMERIC(19) NOT NULL, + initiator VARCHAR(64), + remark VARCHAR(512), + tenant VARCHAR(40) NOT NULL, + created_at NUMERIC(19) NOT NULL, + created_by VARCHAR(64) NOT NULL, + last_modified_at NUMERIC(19) NOT NULL, + last_modified_by VARCHAR(64) NOT NULL, + optlock_revision INTEGER NULL, + PRIMARY KEY (id) +); +ALTER TABLE sp_target_conf_status + ADD CONSTRAINT fk_target_auto_conf FOREIGN KEY (target_id) REFERENCES sp_target (id) ON DELETE CASCADE; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutEventTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutEventTest.java index da7115038..b02138a23 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutEventTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutEventTest.java @@ -44,8 +44,8 @@ public class RolloutEventTest extends AbstractRemoteEntityEventTest { .type("os").modules(Collections.singletonList(module.getId()))); return rolloutManagement.create( - entityFactory.rollout().create().name("exampleRollout").targetFilterQuery("controllerId==*").set(ds), - 5, new RolloutGroupConditionBuilder().withDefaults() + entityFactory.rollout().create().name("exampleRollout").targetFilterQuery("controllerId==*").set(ds), 5, + false, new RolloutGroupConditionBuilder().withDefaults() .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "10").build()); } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutGroupEventTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutGroupEventTest.java index 4ab125bcf..9e473e87f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutGroupEventTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/event/remote/entity/RolloutGroupEventTest.java @@ -84,8 +84,8 @@ public class RolloutGroupEventTest extends AbstractRemoteEntityEventTest actions = assignDistributionSet(dsId, controllerId).getAssignedEntity(); + assertThat(actions).hasSize(1); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + final Long dsId2 = testdataFactory.createDistributionSet().getId(); + // ds1 will be in canceling state afterwards + assignDistributionSet(dsId2, controllerId); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + } + + @Test + @Description("Verify 'findActiveActionsWaitingConfirmation' method is filtering like expected with multi assignment active") + void retrieveActionsWithConfirmationStateInMultiAssignment() { + enableMultiAssignments(); + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + final List actions = assignDistributionSet(dsId, controllerId).getAssignedEntity(); + assertThat(actions).hasSize(1); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + final Long dsId2 = testdataFactory.createDistributionSet().getId(); + assignDistributionSet(dsId2, controllerId); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(2) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + confirmationManagement.confirmAction(actions.get(0).getId(), null, null); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION + && Objects.equals(action.getDistributionSet().getId(), dsId2)); + + } + + @Test + @Description("Verify confirming an action will put it to the running state") + void confirmedActionWillSwitchToRunningState() { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + final List actions = assignDistributionSet(dsId, controllerId).getAssignedEntity(); + assertThat(actions).hasSize(1).allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + assertThat(controllerManagement.findActionStatusByAction(PAGE, actions.get(0).getId())).hasSize(1) + .allMatch(status -> status.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + final Action newAction = confirmationManagement.confirmAction(actions.get(0).getId(), null, null); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).isEmpty(); + + // verify action in RUNNING state + assertThat(newAction.getStatus()).isEqualTo(Status.RUNNING); + + // status entry RUNNING should be present in status history + assertThat(controllerManagement.findActionStatusByAction(PAGE, newAction.getId())).hasSize(2) + .anyMatch(status -> status.getStatus() == Status.RUNNING); + } + + @Test + @Description("Verify confirming an confirmed action will lead to a specific failure") + void confirmedActionCannotBeConfirmedAgain() { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + final List actions = assignDistributionSet(dsId, controllerId).getAssignedEntity(); + assertThat(actions).hasSize(1).allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + final Action newAction = confirmationManagement.confirmAction(actions.get(0).getId(), null, null); + // verify action in RUNNING state + assertThat(newAction.getStatus()).isEqualTo(Status.RUNNING); + + assertThatThrownBy(() -> confirmationManagement.confirmAction(actions.get(0).getId(), null, null)) + .isInstanceOf(InvalidConfirmationFeedbackException.class) + .matches(e -> ((InvalidConfirmationFeedbackException) e) + .getReason() == InvalidConfirmationFeedbackException.Reason.NOT_AWAITING_CONFIRMATION); + } + + @Test + @Description("Verify confirming a closed action will lead to a specific failure") + void confirmedActionCannotBeGivenOnFinishedAction() { + enableConfirmationFlow(); + final Action action = prepareFinishedUpdate(); + + assertThatThrownBy(() -> confirmationManagement.confirmAction(action.getId(), null, null)) + .isInstanceOf(InvalidConfirmationFeedbackException.class) + .matches(e -> ((InvalidConfirmationFeedbackException) e) + .getReason() == InvalidConfirmationFeedbackException.Reason.ACTION_CLOSED); + } + + @Test + @Description("Verify denying an action will leave it in WFC state") + void deniedActionWillStayInWfcState() { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + final List actions = assignDistributionSet(dsId, controllerId).getAssignedEntity(); + assertThat(actions).hasSize(1).allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + assertThat(controllerManagement.findActionStatusByAction(PAGE, actions.get(0).getId())).hasSize(1) + .allMatch(status -> status.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + final Action newAction = confirmationManagement.denyAction(actions.get(0).getId(), null, null); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + // verify action still in WFC state + assertThat(newAction.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + + // no status entry RUNNING should be present in status history + assertThat(controllerManagement.findActionStatusByAction(PAGE, newAction.getId())).hasSize(2) + .noneMatch(status -> status.getStatus() == Status.RUNNING); + } + + @Test + @Description("Verify multiple actions in WFC state will be transferred in RUNNING state in case auto-confirmation is activated.") + void activateAutoConfirmationInMultiAssignment() { + enableMultiAssignments(); + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + final Long dsId2 = testdataFactory.createDistributionSet().getId(); + + final List actions = assignDistributionSets( + Arrays.asList(toDeploymentRequest(controllerId, dsId), toDeploymentRequest(controllerId, dsId2))) + .stream().flatMap(s -> s.getAssignedEntity().stream()).collect(Collectors.toList()); + assertThat(actions).hasSize(2); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(2) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + confirmationManagement.activateAutoConfirmation(controllerId, null, null); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).isEmpty(); + + assertThat(deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent()).hasSize(2) + .allMatch(action -> action.getStatus() == Status.RUNNING); + } + + @Test + @Description("Verify action in WFC state will be transferred in RUNNING state in case auto-confirmation is activated.") + void activateAutoConfirmationOnActiveAction() { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + // do assignment and verify + assertThat(assignDistributionSet(dsId, controllerId).getAssignedEntity()).hasSize(1); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).hasSize(1) + .allMatch(action -> action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + confirmationManagement.activateAutoConfirmation(controllerId, null, null); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).isEmpty(); + + assertThat(deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent()).hasSize(1) + .allMatch(action -> action.getStatus() == Status.RUNNING); + } + + @Test + @Description("Verify created action after activating auto confirmation is directly in running state.") + void activateAutoConfirmationAndCreateAction() { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget().getControllerId(); + final Long dsId = testdataFactory.createDistributionSet().getId(); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).isEmpty(); + + confirmationManagement.activateAutoConfirmation(controllerId, null, null); + + // do assignment and verify + assertThat(assignDistributionSet(dsId, controllerId).getAssignedEntity()).hasSize(1); + + assertThat(confirmationManagement.findActiveActionsWaitingConfirmation(controllerId)).isEmpty(); + + assertThat(deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent()).hasSize(1) + .allMatch(action -> action.getStatus() == Status.RUNNING); + } + + @ParameterizedTest + @MethodSource("getAutoConfirmationArguments") + @Description("Verify activating auto confirmation with different parameters") + void verifyAutoConfirmationActivationValues(final String initiator, final String remark) { + final String controllerId = testdataFactory.createTarget().getControllerId(); + confirmationManagement.activateAutoConfirmation(controllerId, initiator, remark); + + assertThat(targetManagement.getByControllerID(controllerId)).hasValueSatisfying(target -> { + assertThat(target.getAutoConfirmationStatus()).isNotNull() + .matches(status -> status.getTarget().getControllerId().equals(controllerId)) + .matches(status -> Objects.equals(status.getInitiator(), initiator)) + .matches(status -> Objects.equals(status.getCreatedBy(), "bumlux")) + .matches(status -> Objects.equals(status.getRemark(), remark)).satisfies(status -> { + final Instant activationTime = Instant.ofEpochMilli(status.getActivatedAt()); + assertThat(activationTime).isAfterOrEqualTo(activationTime.minusSeconds(3L)); + }); + }); + + confirmationManagement.deactivateAutoConfirmation(controllerId); + verifyAutoConfirmationIsDisabled(controllerId); + } + + private static Stream getAutoConfirmationArguments() { + return Stream.of(Arguments.of("TestUser", "TestRemark"), Arguments.of("TestUser", null), + Arguments.of(null, "TestRemark"), Arguments.of(null, null)); + } + + @Test + @Description("Verify activating already active auto confirmation will throw exception.") + void verifyActivateAlreadyActiveAutoConfirmationThrowException() { + final String controllerId = testdataFactory.createTarget().getControllerId(); + + confirmationManagement.activateAutoConfirmation(controllerId, "any", "any"); + assertThat(targetManagement.getByControllerID(controllerId)) + .hasValueSatisfying(target -> assertThat(target.getAutoConfirmationStatus()).isNotNull()); + + assertThatThrownBy(() -> confirmationManagement.activateAutoConfirmation(controllerId, "any", "any")) + .isInstanceOf(AutoConfirmationAlreadyActiveException.class) + .hasMessage("Auto confirmation is already active for device " + controllerId); + } + + @Test + @Description("Verify disabling already disabled auto confirmation will not have any affect.") + void disableAlreadyDisabledAutoConfirmationHaveNoAffect() { + final String controllerId = testdataFactory.createTarget().getControllerId(); + + verifyAutoConfirmationIsDisabled(controllerId); + confirmationManagement.deactivateAutoConfirmation(controllerId); + verifyAutoConfirmationIsDisabled(controllerId); + } + + private void verifyAutoConfirmationIsDisabled(final String controllerId) { + assertThat(targetManagement.getByControllerID(controllerId)) + .hasValueSatisfying(target -> assertThat(target.getAutoConfirmationStatus()).isNull()); + } + + private static DeploymentRequest toDeploymentRequest(final String controllerId, final Long distributionSetId) { + return new DeploymentRequestBuilder(controllerId, distributionSetId).setConfirmationRequired(true).build(); + } + +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java index c25a01760..79aca027a 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java @@ -796,6 +796,8 @@ class ControllerManagementTest extends AbstractJpaIntegrationTest { assertThat(targetManagement.getByControllerID(DEFAULT_CONTROLLER_ID).get().getUpdateStatus()) .isEqualTo(TargetUpdateStatus.IN_SYNC); + assertThat(actionRepository.findById(action.getId())) + .hasValueSatisfying(a -> assertThat(a.getStatus()).isEqualTo(Status.FINISHED)); assertThat(actionStatusRepository.count()).isEqualTo(3); assertThat(controllerManagement.findActionStatusByAction(PAGE, action.getId()).getNumberOfElements()) .isEqualTo(3); @@ -806,7 +808,8 @@ class ControllerManagementTest extends AbstractJpaIntegrationTest { + "configured to accept them.") @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), @Expect(type = DistributionSetCreatedEvent.class, count = 1), - @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), @Expect(type = TargetUpdatedEvent.class, count = 2), @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), @Expect(type = TargetAttributesRequestedEvent.class, count = 1), diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DeploymentManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DeploymentManagementTest.java index 7de902710..f9336c45c 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DeploymentManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DeploymentManagementTest.java @@ -10,6 +10,8 @@ package org.eclipse.hawkbit.repository.jpa; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.eclipse.hawkbit.repository.model.Action.Status.RUNNING; +import static org.eclipse.hawkbit.repository.model.Action.Status.WAIT_FOR_CONFIRMATION; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.AbstractMap.SimpleEntry; @@ -19,8 +21,10 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map.Entry; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.validation.ConstraintViolationException; @@ -58,6 +62,7 @@ import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.Action.Status; import org.eclipse.hawkbit.repository.model.ActionStatus; import org.eclipse.hawkbit.repository.model.DeploymentRequest; +import org.eclipse.hawkbit.repository.model.DeploymentRequestBuilder; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.DistributionSetTag; @@ -70,6 +75,8 @@ import org.eclipse.hawkbit.repository.test.matcher.Expect; import org.eclipse.hawkbit.repository.test.matcher.ExpectEvents; import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -284,7 +291,8 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { assignDistributionSet(cancelDs, targets).getAssignedEntity(); assertThat(deploymentManagement.countActionsAll()).isEqualTo(quotaManagement.getMaxTargetsPerAutoAssignment()); assignDistributionSet(cancelDs2, targets).getAssignedEntity(); - assertThat(deploymentManagement.countActionsAll()).isEqualTo(2L * quotaManagement.getMaxTargetsPerAutoAssignment()); + assertThat(deploymentManagement.countActionsAll()) + .isEqualTo(2L * quotaManagement.getMaxTargetsPerAutoAssignment()); } @Test @@ -546,13 +554,13 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { final DistributionSet ds1 = testdataFactory.createDistributionSet("1"); assignDistributionSet(ds1, targets); - assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, Status.RUNNING); + assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, RUNNING); // Second assignment final DistributionSet ds2 = testdataFactory.createDistributionSet("2"); assignDistributionSet(ds2, targets); - assertDsExclusivelyAssignedToTargets(targets, ds2.getId(), STATE_ACTIVE, Status.RUNNING); + assertDsExclusivelyAssignedToTargets(targets, ds2.getId(), STATE_ACTIVE, RUNNING); assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_INACTIVE, Status.CANCELED); assertThat(targetManagement.findByAssignedDistributionSet(PAGE, ds2.getId()).getContent()).hasSize(10) @@ -583,14 +591,14 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { final DistributionSet ds1 = testdataFactory.createDistributionSet("Multi-assign-1"); assignDistributionSet(ds1.getId(), targetIds, 77); - assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, Status.RUNNING); + assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, RUNNING); // Second assignment final DistributionSet ds2 = testdataFactory.createDistributionSet("Multi-assign-2"); assignDistributionSet(ds2.getId(), targetIds, 45); - assertDsExclusivelyAssignedToTargets(targets, ds2.getId(), STATE_ACTIVE, Status.RUNNING); - assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, Status.RUNNING); + assertDsExclusivelyAssignedToTargets(targets, ds2.getId(), STATE_ACTIVE, RUNNING); + assertDsExclusivelyAssignedToTargets(targets, ds1.getId(), STATE_ACTIVE, RUNNING); } private void assertDsExclusivelyAssignedToTargets(final List targets, final long dsId, final boolean active, @@ -651,7 +659,8 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { void cancelMultiAssignmentActions() { final List targets = testdataFactory.createTargets(1); final List distributionSets = testdataFactory.createDistributionSets(4); - final List deploymentRequests = createAssignmentRequests(distributionSets, targets, 34); + final List deploymentRequests = createAssignmentRequests(distributionSets, targets, 34, + false); enableMultiAssignments(); final List results = deploymentManagement @@ -672,9 +681,15 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { protected List createAssignmentRequests(final Collection distributionSets, final Collection targets, final int weight) { + return createAssignmentRequests(distributionSets, targets, weight, false); + } + + protected List createAssignmentRequests(final Collection distributionSets, + final Collection targets, final int weight, final boolean confirmationRequired) { final List deploymentRequests = new ArrayList<>(); - distributionSets.forEach(ds -> targets.forEach(target -> deploymentRequests.add(DeploymentManagement - .deploymentRequest(target.getControllerId(), ds.getId()).setWeight(weight).build()))); + distributionSets.forEach(ds -> targets.forEach(target -> deploymentRequests + .add(DeploymentManagement.deploymentRequest(target.getControllerId(), ds.getId()).setWeight(weight) + .setConfirmationRequired(confirmationRequired).build()))); return deploymentRequests; } @@ -713,7 +728,7 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { knownTargetIds.add(notExistingId); final List assignDistributionSetsResults = assignDistributionSetToTargets( - createdDs, knownTargetIds); + createdDs, knownTargetIds, false); for (final DistributionSetAssignmentResult assignDistributionSetsResult : assignDistributionSetsResults) { assertThat(assignDistributionSetsResult.getAlreadyAssigned()).isZero(); @@ -722,12 +737,152 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest { } } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @Description("Assignments with confirmation flow active will result in actions in 'WAIT_FOR_CONFIRMATION' state") + void assignmentWithConfirmationFlowActive(final boolean confirmationRequired) { + final List controllerIds = testdataFactory.createTargets(1).stream().map(Target::getControllerId) + .collect(Collectors.toList()); + final DistributionSet distributionSet = testdataFactory.createDistributionSet(); + + enableConfirmationFlow(); + List results = assignDistributionSetToTargets(distributionSet, controllerIds, + confirmationRequired); + + assertThat(getResultingActionCount(results)).isEqualTo(controllerIds.size()); + + controllerIds.forEach(controllerId -> { + actionRepository.findByTargetControllerId(PAGE, controllerId).forEach(action -> { + assertThat(action.getDistributionSet().getId()).isIn(distributionSet.getId()); + assertThat(action.getInitiatedBy()).as("Should be Initiated by current user") + .isEqualTo(tenantAware.getCurrentUsername()); + if (confirmationRequired) { + assertThat(action.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + } else { + assertThat(action.getStatus()).isEqualTo(RUNNING); + } + }); + }); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @Description("Verify auto confirmation assignments and check action status with messages") + void assignmentWithAutoConfirmationWillBeHandledCorrectly(final boolean confirmationRequired) { + enableConfirmationFlow(); + + final Target target = testdataFactory.createTarget(); + assertThat(target.getAutoConfirmationStatus()).isNull(); + + confirmationManagement.activateAutoConfirmation(target.getControllerId(), "not_bumlux", "my personal remark"); + + assertThat(targetManagement.getByControllerID(target.getControllerId())) + .hasValueSatisfying(t -> assertThat(t.getAutoConfirmationStatus()).isNotNull()); + + final DistributionSet distributionSet = testdataFactory.createDistributionSet(); + + assignDistributionSets(Collections + .singletonList(new DeploymentRequestBuilder(target.getControllerId(), distributionSet.getId()) + .setConfirmationRequired(confirmationRequired).build())); + + assertThat(deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent()).hasSize(1) + .allSatisfy(action -> { + assertThat(action.getStatus()).isEqualTo(RUNNING); + assertThat(actionStatusRepository.getByActionId(PAGE, action.getId())).hasSize(1) + .allSatisfy(status -> { + final JpaActionStatus actionStatus = (JpaActionStatus) status; + assertThat(actionStatus.getStatus()).isEqualTo(WAIT_FOR_CONFIRMATION); + if (confirmationRequired) { + // confirmation of assignment is basically required, but active + // auto-confirmation will perform the confirmation + assertThat(actionStatus.getMessages()) + .contains("Assignment initiated by user 'bumlux'") + .contains("Assignment automatically confirmed by initiator 'not_bumlux'. \n" + + "\n" + "Auto confirmation activated by system user: 'bumlux' \n" + + "\n" + "Remark: my personal remark"); + } else { + // assignment never required confirmation, auto-confirmation will not be + // applied. + // assignment initiator has confirmed the action already + assertThat(actionStatus.getMessages()) + .contains("Assignment initiated by user 'bumlux'") + .contains("Assignment confirmed by initiator [bumlux]."); + } + }); + }); + } + + @Test + @Description("Multiple assignments with confirmation flow active will result in correct cancel behaviour") + void multipleAssignmentWithConfirmationFlowActiveVerifyCancelBehaviour() { + final Target target = testdataFactory.createTarget("firstDevice"); + final DistributionSet firstDs = testdataFactory.createDistributionSet(); + final DistributionSet secondDs = testdataFactory.createDistributionSet(); + + enableConfirmationFlow(); + final List resultActions = assignDistributionSet(firstDs.getId(), target.getControllerId()) + .getAssignedEntity(); + assertThat(resultActions).hasSize(1); + + assertThat(resultActions.get(0)).satisfies(action -> { + assertThat(action.getDistributionSet().getId()).isEqualTo(firstDs.getId()); + assertThat(action.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + }); + + final List resultActions2 = assignDistributionSet(secondDs.getId(), target.getControllerId()) + .getAssignedEntity(); + + assertThat(resultActions2).hasSize(1); + assertThat(resultActions2.get(0)).satisfies(action -> { + assertThat(action.getDistributionSet().getId()).isEqualTo(secondDs.getId()); + assertThat(action.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + }); + + final List actions = actionRepository.findByTargetControllerId(PAGE, target.getControllerId()) + .getContent(); + assertThat(actions).hasSize(2) + .anyMatch(action -> Objects.equals(action.getDistributionSet().getId(), firstDs.getId()) + && action.getStatus() == Status.CANCELING) + .anyMatch(action -> Objects.equals(action.getDistributionSet().getId(), secondDs.getId()) + && action.getStatus() == Status.WAIT_FOR_CONFIRMATION); + + } + + @Test + @Description("Assignments with confirmation flow deactivated will result in actions in only in 'RUNNING' state") + void verifyConfirmationRequiredFlagHaveNoInfluenceIfFlowIsDeactivated() { + final List targets1 = testdataFactory.createTargets("group1", 1).stream().map(Target::getControllerId) + .collect(Collectors.toList()); + final List targets2 = testdataFactory.createTargets("group2", 1).stream().map(Target::getControllerId) + .collect(Collectors.toList()); + final DistributionSet distributionSet = testdataFactory.createDistributionSet(); + + final List results = Stream + .concat(assignDistributionSetToTargets(distributionSet, targets1, true).stream(), // + assignDistributionSetToTargets(distributionSet, targets2, false).stream()) // + .collect(Collectors.toList()); + + final List controllerIds = Stream.concat(targets1.stream(), targets2.stream()) + .collect(Collectors.toList()); + + assertThat(getResultingActionCount(results)).isEqualTo(controllerIds.size()); + + controllerIds.forEach(controllerId -> { + actionRepository.findByTargetControllerId(PAGE, controllerId).forEach(action -> { + assertThat(action.getDistributionSet().getId()).isIn(distributionSet.getId()); + assertThat(action.getInitiatedBy()).as("Should be Initiated by current user") + .isEqualTo(tenantAware.getCurrentUsername()); + assertThat(action.getStatus()).isEqualTo(RUNNING); + }); + }); + } + private List assignDistributionSetToTargets(final DistributionSet distributionSet, - final Iterable targetIds) { + final Iterable targetIds, final boolean confirmationRequired) { final List deploymentRequests = new ArrayList<>(); for (final String controllerId : targetIds) { deploymentRequests.add(new DeploymentRequest(controllerId, distributionSet.getId(), ActionType.FORCED, 0, - null, null, null, null)); + null, null, null, null, confirmationRequired)); } return deploymentManagement.assignDistributionSets(deploymentRequests); } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/RolloutManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/RolloutManagementTest.java index d4000f800..9924c6ab9 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/RolloutManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/RolloutManagementTest.java @@ -22,6 +22,7 @@ import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.validation.ConstraintViolationException; import javax.validation.ValidationException; @@ -31,6 +32,7 @@ import org.assertj.core.api.Assertions; import org.assertj.core.api.Condition; import org.awaitility.Awaitility; import org.awaitility.Duration; +import org.eclipse.hawkbit.repository.Identifiable; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.builder.RolloutCreate; import org.eclipse.hawkbit.repository.builder.RolloutGroupCreate; @@ -81,6 +83,9 @@ import org.eclipse.hawkbit.repository.test.util.WithSpringAuthorityRule; import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -139,6 +144,42 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { assertThat(rolloutCreatedAction.getStatus()).isEqualTo(Status.FINISHED); } + @ParameterizedTest + @MethodSource("simpleRolloutsPossibilities") + @Description("Verifies that action states are correctly initialized after starting a rollout with different options in regard to the confirmation.") + void runRolloutWithConfirmationFlagAndCoonfirmationFlowOptions(final boolean confirmationFlowActive, + final boolean confirmationRequired, final Status expectedStatus) { + // manually assign distribution set to target + final String knownControllerId = "controller12345"; + final DistributionSet knownDistributionSet = testdataFactory.createDistributionSet(); + testdataFactory.createTarget(knownControllerId); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + // create rollout with the same distribution set already assigned + // start rollout + final Rollout rollout = testdataFactory.createRolloutByVariables("rolloutNotCancelRunningAction", "description", + 1, "name==*", knownDistributionSet, "50", "5", confirmationRequired); + rolloutManagement.start(rollout.getId()); + rolloutManagement.handleRollouts(); + + // verify that manually created action is still running and action + // created from rollout is finished + final List actionsByKnownTarget = deploymentManagement.findActionsByTarget(knownControllerId, PAGE) + .getContent(); + assertThat(actionsByKnownTarget).hasSize(1); + assertThat(actionsByKnownTarget.get(0).getStatus()).isEqualTo(expectedStatus); + } + + private static Stream simpleRolloutsPossibilities() { + return Stream.of(Arguments.of(true, true, Status.WAIT_FOR_CONFIRMATION), // + Arguments.of(true, false, Status.RUNNING), // + Arguments.of(false, true, Status.RUNNING), // + Arguments.of(false, false, Status.RUNNING));// + } + @Test @Description("Verifies that a running action is auto canceled by a rollout which assigns another distribution-set.") void rolloutAssignsNewDistributionSetAndAutoCloseActiveActions() { @@ -1369,7 +1410,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { .targetFilterQuery("controllerId==" + targetPrefixName + "-*").set(distributionSet); assertThatExceptionOfType(AssignmentQuotaExceededException.class) - .isThrownBy(() -> rolloutManagement.create(rollout, amountGroups, conditions)); + .isThrownBy(() -> rolloutManagement.create(rollout, amountGroups, false, conditions)); } @Test @@ -1540,6 +1581,117 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { } + @Test + @Description("Verify rollout execution with advanced group definition and confirmation flow active.") + void createRolloutWithGroupDefinitionAndConfirmationFlowActive() { + final String rolloutName = "rolloutTest4"; + + final int amountTargetsInGroup1 = 10; + final int amountTargetsInGroup2 = 20; + + final RolloutGroupConditions conditions = new RolloutGroupConditionBuilder().withDefaults().build(); + // Generate Targets for group 2 + final RolloutCreate rolloutcreate = generateTargetsAndRollout(rolloutName, amountTargetsInGroup2); + + // Generate Targets for group 1 + testdataFactory.createTargets(amountTargetsInGroup1, rolloutName + "-gr1-", rolloutName); + + final List rolloutGroups = new ArrayList<>(3); + rolloutGroups.add(generateRolloutGroup(0, 100, "id==" + rolloutName + "-gr1-*", true)); + rolloutGroups.add(generateRolloutGroup(1, 100, null, false)); + + // enable confirmation flow + enableConfirmationFlow(); + + final Long rolloutId = rolloutManagement.create(rolloutcreate, rolloutGroups, conditions).getId(); + + assertThat(getRollout(rolloutId)).satisfies(rollout -> { + assertThat(rollout.getStatus()).isEqualTo(RolloutStatus.CREATING); + for (final RolloutGroup group : rolloutGroupManagement.findByRollout(PAGE, rollout.getId()).getContent()) { + assertThat(group.getStatus()).isEqualTo(RolloutGroupStatus.CREATING); + } + }); + + // first handle iteration will put rollout in ready state + rolloutManagement.handleRollouts(); + + assertThat(getRollout(rolloutId)).satisfies(rollout -> { + assertThat(rollout.getStatus()).isEqualTo(RolloutStatus.READY); + assertThat(rollout.getTotalTargets()).isEqualTo(amountTargetsInGroup1 + amountTargetsInGroup2); + }); + + // verify created rollout groups + final List rolloutGroupIds = rolloutGroupManagement.findByRollout(PAGE, rolloutId).getContent().stream() + .map(Identifiable::getId).collect(Collectors.toList()); + assertThat(rolloutGroupIds).hasSize(2); + assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.READY, true, amountTargetsInGroup1, null); + assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.READY, false, amountTargetsInGroup2, null); + + // start rollout + rolloutManagement.start(rolloutId); + rolloutManagement.handleRollouts(); + + // verify rollout started. Check groups are in right state. + // Group 1 should be in WFC state, since confirmation is required here. + assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.RUNNING, true, amountTargetsInGroup1, + Status.WAIT_FOR_CONFIRMATION); + assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.SCHEDULED, false, amountTargetsInGroup2, + Status.SCHEDULED); + + // cancel execution of all action of group 1 to trigger second group + forceQuitAllActionsOfRolloutGroup(rolloutGroupIds.get(0)); + + rolloutManagement.handleRollouts(); + assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.FINISHED, true, amountTargetsInGroup1, + Status.CANCELED); + assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.SCHEDULED, false, amountTargetsInGroup2, + Status.SCHEDULED); + + // verify actions of second rule are directly in RUNNING state, since + // confirmation is not required for this group + rolloutManagement.handleRollouts(); + assertRolloutGroup(rolloutGroupIds.get(0), RolloutGroupStatus.FINISHED, true, amountTargetsInGroup1, + Status.CANCELED); + assertRolloutGroup(rolloutGroupIds.get(1), RolloutGroupStatus.RUNNING, false, amountTargetsInGroup2, + Status.RUNNING); + + } + + private void assertRolloutGroup(final long rolloutGroupId, final RolloutGroupStatus status, + final boolean isConfirmationRequired, final long totalTargets, final Status actionStatusToCheck) { + assertThat(rolloutGroupManagement.get(rolloutGroupId)).hasValueSatisfying(rolloutGroup -> { + assertThat(rolloutGroup.getStatus()).isEqualTo(status); + assertThat(rolloutGroup.isConfirmationRequired()).isEqualTo(isConfirmationRequired); + assertThat(rolloutGroup.getTotalTargets()).isEqualTo(totalTargets); + if (actionStatusToCheck != null) { + assertAllActionOfRolloutGroupHavingStatus(rolloutGroup.getId(), actionStatusToCheck); + } + }); + } + + private void assertAllActionOfRolloutGroupHavingStatus(final long rolloutGroupId, final Status status) { + final List targets = rolloutGroupManagement.findTargetsOfRolloutGroup(PAGE, rolloutGroupId) + .getContent(); + targets.forEach(target -> { + final List activeActions = deploymentManagement + .findActionsByTarget(target.getControllerId(), PAGE).getContent(); + assertThat(activeActions).hasSize(1); + assertThat(activeActions.get(0).getStatus()).isEqualTo(status); + }); + } + + private void forceQuitAllActionsOfRolloutGroup(final long rolloutGroupId) { + final List targets = rolloutGroupManagement.findTargetsOfRolloutGroup(PAGE, rolloutGroupId) + .getContent(); + targets.forEach(target -> { + deploymentManagement.findActiveActionsByTarget(PAGE, target.getControllerId()).getContent().stream().map(Identifiable::getId) + .forEach(actionId -> { + deploymentManagement.cancelAction(actionId); + deploymentManagement.forceQuitAction(actionId); + }); + }); + } + @Test @Description("Verify rollout creation fails if group definition does not address all targets") void createRolloutWithGroupsNotMatchingTargets() { @@ -1593,7 +1745,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { final RolloutCreate rollout = generateTargetsAndRollout(rolloutName, targets); assertThatExceptionOfType(AssignmentQuotaExceededException.class) - .isThrownBy(() -> rolloutManagement.create(rollout, maxGroups + 1, conditions)) + .isThrownBy(() -> rolloutManagement.create(rollout, maxGroups + 1, false, conditions)) .withMessageContaining("not be greater than " + maxGroups); } @@ -1615,7 +1767,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { final RolloutCreate rolloutToCreate = entityFactory.rollout().create().name(rolloutName) .description("some description").targetFilterQuery("id==" + rolloutName + "-*").set(distributionSet); - Rollout myRollout = rolloutManagement.create(rolloutToCreate, amountGroups, conditions); + Rollout myRollout = rolloutManagement.create(rolloutToCreate, amountGroups, false, conditions); myRollout = getRollout(myRollout.getId()); assertThat(myRollout.getStatus()).isEqualTo(RolloutStatus.CREATING); @@ -1787,7 +1939,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { .name(prefixRolloutRunning + "-testRollout").targetFilterQuery("name==" + randomString + "*") .set(testDs); - Rollout rolloutRunning = rolloutManagement.create(rolloutRunningCreate, 1, conditions); + Rollout rolloutRunning = rolloutManagement.create(rolloutRunningCreate, 1, false, conditions); // Let the executor handle created Rollout rolloutManagement.handleRollouts(); // start the rollout, so it has active running actions and a group which @@ -1799,7 +1951,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { final String prefixRolloutReady = randomString + "2"; final RolloutCreate rolloutReadyCreate = entityFactory.rollout().create() .name(prefixRolloutReady + "-testRollout").targetFilterQuery("name==" + randomString + "*").set(testDs); - Rollout rolloutReady = rolloutManagement.create(rolloutReadyCreate, 1, conditions); + Rollout rolloutReady = rolloutManagement.create(rolloutReadyCreate, 1, false, conditions); // Let the executor handle created Rollout rolloutManagement.handleRollouts(); rolloutReady = reloadRollout(rolloutReady); @@ -1959,7 +2111,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { final RolloutCreate rolloutToCreate = entityFactory.rollout().create().name(rolloutName) .targetFilterQuery("name==*").set(testDs); - final Rollout createdRollout = rolloutManagement.create(rolloutToCreate, 1, conditions); + final Rollout createdRollout = rolloutManagement.create(rolloutToCreate, 1, false, conditions); // Let the executor handle created Rollout rolloutManagement.handleRollouts(); @@ -1982,8 +2134,14 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { private RolloutGroupCreate generateRolloutGroup(final int index, final Integer percentage, final String targetFilter) { + return generateRolloutGroup(index, percentage, targetFilter, false); + } + + private RolloutGroupCreate generateRolloutGroup(final int index, final Integer percentage, + final String targetFilter, final boolean confirmationRequired) { return entityFactory.rolloutGroup().create().name("Group" + index).description("Group" + index + "desc") - .targetPercentage(Float.valueOf(percentage)).targetFilterQuery(targetFilter); + .targetPercentage(Float.valueOf(percentage)).targetFilterQuery(targetFilter) + .confirmationRequired(confirmationRequired); } private RolloutCreate generateTargetsAndRollout(final String rolloutName, final int amountTargetsForRollout) { @@ -2033,7 +2191,7 @@ class RolloutManagementTest extends AbstractJpaIntegrationTest { testdataFactory.createTargets(amountTargetsForRollout, targetPrefixName + "-", targetPrefixName); return testdataFactory.createRolloutByVariables(rolloutName, rolloutName + "description", groupSize, "controllerId==" + targetPrefixName + "-*", dsForRolloutTwo, successCondition, errorCondition, - Action.ActionType.FORCED, weight); + Action.ActionType.FORCED, weight, false); } private int changeStatusForAllRunningActions(final Rollout rollout, final Status status) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerIntTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerIntTest.java index 4068d9b3f..27e017668 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerIntTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerIntTest.java @@ -33,6 +33,9 @@ import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.repository.model.TargetType; import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties.TenantConfigurationKey; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -174,6 +177,69 @@ class AutoAssignCheckerIntTest extends AbstractJpaIntegrationTest { verifyThatTargetsNotHaveDistributionSetAssignment(toAssignDs, targets.subList(1, 25)); } + @ParameterizedTest + @MethodSource("confirmationOptions") + @Description("Test auto assignment of a DS to filtered targets with different confirmation options") + void checkAutoAssignWithConfirmationOptions(final boolean confirmationFlowActive, final boolean confirmationRequired, + final Action.Status expectedStatus) { + + final DistributionSet distributionSet = testdataFactory.createDistributionSet("dsA"); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + final TargetFilterQuery targetFilterQuery = targetFilterQueryManagement.updateAutoAssignDS(entityFactory + .targetFilterQuery() + .updateAutoAssign(targetFilterQueryManagement + .create(entityFactory.targetFilterQuery().create().name("filterA").query("name==*")).getId()) + .ds(distributionSet.getId()).confirmationRequired(confirmationRequired)); + + final String targetDsAIdPref = "targ"; + final List targets = testdataFactory.createTargets(20, targetDsAIdPref, + targetDsAIdPref.concat(" description")); + + // Run the check + autoAssignChecker.checkAllTargets(); + + verifyThatTargetsHaveDistributionSetAssignedAndActionStatus(distributionSet, targets, expectedStatus); + } + + @ParameterizedTest + @MethodSource("confirmationOptions") + @Description("Test auto assignment of a DS for a specific device with different confirmation options") + void checkAutoAssignmentForDeviceWithConfirmationRequired(final boolean confirmationFlowActive, + final boolean confirmationRequired, final Action.Status expectedStatus) { + + final DistributionSet toAssignDs = testdataFactory.createDistributionSet(); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + // target filter query that matches all targets + targetFilterQueryManagement.updateAutoAssignDS(entityFactory.targetFilterQuery() + .updateAutoAssign(targetFilterQueryManagement + .create(entityFactory.targetFilterQuery().create().name("filterA").query("name==*")).getId()) + .ds(toAssignDs.getId()).confirmationRequired(confirmationRequired)); + + final List targets = testdataFactory.createTargets(25); + + // Run the check + autoAssignChecker.checkSingleTarget(targets.get(0).getControllerId()); + verifyThatTargetsHaveDistributionSetAssignedAndActionStatus(toAssignDs, targets.subList(0, 1), expectedStatus); + + verifyThatTargetsNotHaveDistributionSetAssignment(toAssignDs, targets.subList(1, 25)); + } + + private static Stream confirmationOptions() { + return Stream.of( // + Arguments.of(true, true, Status.WAIT_FOR_CONFIRMATION), // + Arguments.of(true, false, Status.RUNNING), // + Arguments.of(false, true, Status.RUNNING), // + Arguments.of(false, false, Status.RUNNING)); + } + @Test @Description("Test auto assignment of an incomplete DS to filtered targets, that causes failures") void checkAutoAssignWithFailures() { @@ -246,6 +312,22 @@ class AutoAssignCheckerIntTest extends AbstractJpaIntegrationTest { } + @Step + private void verifyThatTargetsHaveDistributionSetAssignedAndActionStatus(final DistributionSet set, + final List targets, final Action.Status status) { + final List targetIds = targets.stream().map(Target::getControllerId).collect(Collectors.toList()); + final List targetsWithAssignedDS = targetManagement.findByAssignedDistributionSet(PAGE, set.getId()) + .getContent(); + assertThat(targetsWithAssignedDS).isNotEmpty(); + assertThat(targetsWithAssignedDS).allMatch(target -> targetIds.contains(target.getControllerId())); + + final List actionsByDs = deploymentManagement.findActionsByDistributionSet(PAGE, set.getId()) + .getContent(); + + assertThat(actionsByDs).hasSize(targets.size()); + assertThat(actionsByDs).allMatch(action -> action.getStatus() == status); + } + @Step private void verifyThatTargetsNotHaveDistributionSetAssignment(final DistributionSet set, final List targets) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLRolloutGroupFieldTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLRolloutGroupFieldTest.java index 8e203fe76..bfa88d98e 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLRolloutGroupFieldTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLRolloutGroupFieldTest.java @@ -99,7 +99,7 @@ public class RSQLRolloutGroupFieldTest extends AbstractJpaIntegrationTest { return rolloutManagement.create( entityFactory.rollout().create().set(distributionSetManagement.get(distributionSetId).get()).name(name) .targetFilterQuery(targetFilterQuery), - amountGroups, new RolloutGroupConditionBuilder().withDefaults() + amountGroups, false, new RolloutGroupConditionBuilder().withDefaults() .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); } } 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 dcb2ac0a9..df453f609 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 @@ -28,6 +28,7 @@ import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.hawkbit.artifact.repository.ArtifactRepository; import org.eclipse.hawkbit.cache.TenantAwareCacheManager; import org.eclipse.hawkbit.repository.ArtifactManagement; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetInvalidationManagement; @@ -158,6 +159,8 @@ public abstract class AbstractIntegrationTest { @Autowired protected DeploymentManagement deploymentManagement; + @Autowired + protected ConfirmationManagement confirmationManagement; @Autowired protected DistributionSetInvalidationManagement distributionSetInvalidationManagement; @@ -235,9 +238,12 @@ public abstract class AbstractIntegrationTest { protected DistributionSetAssignmentResult assignDistributionSet(final long dsID, final List controllerIds, final ActionType actionType, final long forcedTime, final Integer weight) { + final boolean confirmationFlowActive = isConfirmationFlowActive(); + final List deploymentRequests = controllerIds.stream() .map(id -> DeploymentManagement.deploymentRequest(id, dsID).setActionType(actionType) - .setForceTime(forcedTime).setWeight(weight).build()) + .setForceTime(forcedTime).setWeight(weight).setConfirmationRequired(confirmationFlowActive) + .build()) .collect(Collectors.toList()); final List results = deploymentManagement .assignDistributionSets(deploymentRequests); @@ -245,6 +251,13 @@ public abstract class AbstractIntegrationTest { return results.get(0); } + protected List assignDistributionSets(final List requests) { + final List distributionSetAssignmentResults = deploymentManagement + .assignDistributionSets(requests); + assertThat(distributionSetAssignmentResults).hasSize(requests.size()); + return distributionSetAssignmentResults; + } + protected DistributionSetAssignmentResult assignDistributionSet(final DistributionSet ds, final List targets) { final List targetIds = targets.stream().map(Target::getControllerId).collect(Collectors.toList()); @@ -296,7 +309,7 @@ public abstract class AbstractIntegrationTest { return makeAssignment(DeploymentManagement.deploymentRequest(controllerId, dsID) .setMaintenance(maintenanceWindowSchedule, maintenanceWindowDuration, maintenanceWindowTimeZone) - .build()); + .setConfirmationRequired(true).build()); } protected DistributionSetAssignmentResult assignDistributionSet(final DistributionSet pset, final Target target) { @@ -312,6 +325,19 @@ public abstract class AbstractIntegrationTest { tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.MULTI_ASSIGNMENTS_ENABLED, true); } + protected void enableConfirmationFlow() { + tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, true); + } + + protected void disableConfirmationFlow() { + tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, false); + } + + protected boolean isConfirmationFlowActive() { + return tenantConfigurationManagement.getConfigurationValue(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, + Boolean.class).getValue(); + } + protected DistributionSetMetadata createDistributionSetMetadata(final long dsId, final MetaData md) { return createDistributionSetMetadata(dsId, Collections.singletonList(md)).get(0); } @@ -345,6 +371,10 @@ public abstract class AbstractIntegrationTest { Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) .getContent().get(0); + if (savedAction.getStatus() == Action.Status.WAIT_FOR_CONFIRMATION) { + confirmationManagement.confirmAction(savedAction.getId(), null, null); + } + savedAction = controllerManagement.addUpdateActionStatus( entityFactory.actionStatus().create(savedAction.getId()).status(Action.Status.RUNNING)); @@ -473,4 +503,10 @@ public abstract class AbstractIntegrationTest { protected void disableBatchAssignments() { tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.BATCH_ASSIGNMENTS_ENABLED, false); } + + protected boolean isConfirmationFlowEnabled() { + return tenantConfigurationManagement + .getConfigurationValue(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, Boolean.class).getValue(); + } + } diff --git a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/TestdataFactory.java b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/TestdataFactory.java index 55f059397..77efb3507 100644 --- a/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/TestdataFactory.java +++ b/hawkbit-repository/hawkbit-repository-test/src/main/java/org/eclipse/hawkbit/repository/test/util/TestdataFactory.java @@ -877,10 +877,14 @@ public class TestdataFactory { * @return {@link List} of {@link Target} entities */ public List createTargets(final int number) { + return createTargets(DEFAULT_CONTROLLER_ID, number); + } + + public List createTargets(final String prefix, final int number) { final List targets = Lists.newArrayListWithExpectedSize(number); for (int i = 0; i < number; i++) { - targets.add(entityFactory.target().create().controllerId(DEFAULT_CONTROLLER_ID + i)); + targets.add(entityFactory.target().create().controllerId(prefix + i)); } return createTargets(targets); @@ -1140,7 +1144,14 @@ public class TestdataFactory { final int groupSize, final String filterQuery, final DistributionSet distributionSet, final String successCondition, final String errorCondition) { return createRolloutByVariables(rolloutName, rolloutDescription, groupSize, filterQuery, distributionSet, - successCondition, errorCondition, Action.ActionType.FORCED, null); + successCondition, errorCondition, Action.ActionType.FORCED, null, false); + } + + public Rollout createRolloutByVariables(final String rolloutName, final String rolloutDescription, + final int groupSize, final String filterQuery, final DistributionSet distributionSet, + final String successCondition, final String errorCondition, final boolean confirmationRequired) { + return createRolloutByVariables(rolloutName, rolloutDescription, groupSize, filterQuery, distributionSet, + successCondition, errorCondition, Action.ActionType.FORCED, null, confirmationRequired); } /** @@ -1164,12 +1175,15 @@ public class TestdataFactory { * the type of the Rollout * @param weight * weight of the Rollout + * @param confirmationRequired + * if the confirmation is required (considered with confirmation flow + * active) * @return created {@link Rollout} */ public Rollout createRolloutByVariables(final String rolloutName, final String rolloutDescription, final int groupSize, final String filterQuery, final DistributionSet distributionSet, final String successCondition, final String errorCondition, final Action.ActionType actionType, - final Integer weight) { + final Integer weight, final boolean confirmationRequired) { final RolloutGroupConditions conditions = new RolloutGroupConditionBuilder().withDefaults() .successCondition(RolloutGroupSuccessCondition.THRESHOLD, successCondition) .errorCondition(RolloutGroupErrorCondition.THRESHOLD, errorCondition) @@ -1178,7 +1192,7 @@ public class TestdataFactory { final Rollout rollout = rolloutManagement.create( entityFactory.rollout().create().name(rolloutName).description(rolloutDescription) .targetFilterQuery(filterQuery).set(distributionSet).actionType(actionType).weight(weight), - groupSize, conditions); + groupSize, confirmationRequired, conditions); // Run here, because Scheduler is disabled during tests rolloutManagement.handleRollouts(); @@ -1314,7 +1328,7 @@ public class TestdataFactory { createTargets(amountOtherTargets, "others-" + suffix + "-", "rollout"); final String filterQuery = "controllerId==rollout-" + suffix + "-*"; return createRolloutByVariables("rollout-" + suffix, "test-rollout-description", amountOfGroups, - filterQuery, rolloutDS, successCondition, errorCondition, actionType, weight); + filterQuery, rolloutDS, successCondition, errorCondition, actionType, weight, false); } /** diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java index 10be7abfb..3d802ff0f 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java @@ -19,8 +19,9 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder; /** * Provide action history information to the controller as part of response to - * {@link DdiRootControllerRestApi#getControllerBasedeploymentAction}: 1. - * Current action status at the server; 2. List of messages from action history + * {@link DdiRootControllerRestApi#getControllerBasedeploymentAction} and + * {@link DdiRootControllerRestApi#getControllerBaseconfirmationAction}: + * 1. Current action status at the server; 2. List of messages from action history * that were sent to server earlier by the controller using * {@link DdiActionFeedback}. */ diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActivateAutoConfirmation.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActivateAutoConfirmation.java new file mode 100644 index 000000000..f9987d4be --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActivateAutoConfirmation.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class DdiActivateAutoConfirmation { + + @JsonProperty(required = false) + private final String initiator; + + @JsonProperty(required = false) + private final String remark; + + /** + * Constructor. + * + * @param initiator + * can be null + * @param remark + * can be null + */ + @JsonCreator + public DdiActivateAutoConfirmation(@JsonProperty(value = "initiator") final String initiator, + @JsonProperty(value = "remark") final String remark) { + this.initiator = initiator; + this.remark = remark; + } + + @Override + public String toString() { + return "DdiActivateAutoConfirmation [initiator=" + initiator + ", remark=" + remark + ", toString()=" + + super.toString() + "]"; + } + + public String getInitiator() { + return initiator; + } + + public String getRemark() { + return remark; + } +} diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiAutoConfirmationState.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiAutoConfirmationState.java new file mode 100644 index 000000000..bcd490344 --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiAutoConfirmationState.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.springframework.hateoas.RepresentationModel; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({ "active", "initiator", "remark", "activatedAt" }) +public class DdiAutoConfirmationState extends RepresentationModel { + + @NotNull + private boolean active; + private String initiator; + private String remark; + + private Long activatedAt; + + /** + * Constructor. + */ + public DdiAutoConfirmationState() { + // needed for json create. + } + + public static DdiAutoConfirmationState active(final long activatedAt) { + final DdiAutoConfirmationState state = new DdiAutoConfirmationState(); + state.setActive(true); + state.setActivatedAt(activatedAt); + return state; + } + + public static DdiAutoConfirmationState disabled() { + return new DdiAutoConfirmationState(); + } + + public boolean isActive() { + return active; + } + + public void setActive(final boolean active) { + this.active = active; + } + + public Long getActivatedAt() { + return activatedAt; + } + + public void setActivatedAt(final long activatedAt) { + this.activatedAt = activatedAt; + } + + public String getInitiator() { + return initiator; + } + + public void setInitiator(final String initiator) { + this.initiator = initiator; + } + + public String getRemark() { + return remark; + } + + public void setRemark(final String remark) { + this.remark = remark; + } + +} diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBase.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBase.java new file mode 100644 index 000000000..b57aed798 --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBase.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.springframework.hateoas.RepresentationModel; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Confirmation base response. + * Set order to place links at last. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({ "autoConfirm" }) +public class DdiConfirmationBase extends RepresentationModel { + + @JsonProperty("autoConfirm") + @NotNull + private DdiAutoConfirmationState autoConfirm; + + /** + * Constructor. + */ + public DdiConfirmationBase() { + // needed for json create. + } + + /** + * Constructor. + * + * @param autoConfirmState + */ + public DdiConfirmationBase(final DdiAutoConfirmationState autoConfirmState) { + this.autoConfirm = autoConfirmState; + } + + public DdiAutoConfirmationState getAutoConfirm() { + return autoConfirm; + } +} diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseAction.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseAction.java new file mode 100644 index 000000000..c64504e70 --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseAction.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.springframework.hateoas.RepresentationModel; + +import javax.validation.constraints.NotNull; +import java.util.Objects; + +/** + * Update action resource. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({ "id", "confirmation", "actionHistory" }) +public class DdiConfirmationBaseAction extends RepresentationModel { + + @JsonProperty("id") + @NotNull + private String id; + + @JsonProperty("confirmation") + @NotNull + private DdiDeployment confirmation; + + /** + * Action history containing current action status and a list of feedback + * messages received earlier from the controller. + */ + @JsonProperty("actionHistory") + @JsonInclude(JsonInclude.Include.NON_NULL) + private DdiActionHistory actionHistory; + + /** + * Constructor. + */ + public DdiConfirmationBaseAction() { + // needed for json create. + } + + /** + * Constructor. + * + * @param id + * of the update action + * @param confirmation + * chunk details + * @param actionHistory + * containing current action status and a list of feedback messages + * received earlier from the controller. + */ + public DdiConfirmationBaseAction(final String id, final DdiDeployment confirmation, + final DdiActionHistory actionHistory) { + this.id = id; + this.confirmation = confirmation; + this.actionHistory = actionHistory; + } + + /** + * Return the confirmation data containing chunks needed to confirm a action. + * + * @return {@link DdiDeployment} + */ + public DdiDeployment getConfirmation() { + return confirmation; + } + + /** + * Returns the action history containing current action status and a list of + * feedback messages received earlier from the controller. + * + * @return {@link DdiActionHistory} + */ + public DdiActionHistory getActionHistory() { + return actionHistory; + } + + @Override + public String toString() { + return "ConfirmationBase [id=" + id + ", confirmation=" + confirmation + " actionHistory=" + actionHistory + + "]"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (this.getClass() != obj.getClass()) { + return false; + } + final DdiConfirmationBaseAction oBase = (DdiConfirmationBaseAction) obj; + return Objects.equals(this.id, oBase.id) && Objects.equals(this.confirmation, oBase.confirmation) + && Objects.equals(this.actionHistory, oBase.actionHistory); + } + +} diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationFeedback.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationFeedback.java new file mode 100644 index 000000000..704350ba1 --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationFeedback.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.Collections; +import java.util.List; + +/** + * New update actions require confirmation when confirmation flow is switched on. This is the feedback channel for + * confirmation messages for DDI API. The confirmation message has a mandatory field confirmation with possible values: + * "confirmed" and "denied". + * + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class DdiConfirmationFeedback { + + @NotNull + @Valid + private final Confirmation confirmation; + + private final Integer code; + + private final List details; + + + /** + * Constructs an confirmation-feedback + * + * @param confirmation confirmation value for the action. Valid values are "Confirmed" and "Denied + * @param code code for confirmation + * @param details messages + */ + @JsonCreator public DdiConfirmationFeedback( + @JsonProperty(value = "confirmation", required = true) final Confirmation confirmation, + @JsonProperty(value = "code") final Integer code, + @JsonProperty(value = "details") final List details) { + this.confirmation = confirmation; + this.code = code; + this.details = details; + } + + public Confirmation getConfirmation() { + return confirmation; + } + + public Integer getCode() { + return code; + } + + public List getDetails() { + if (details == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(details); + } + + public enum Confirmation { + /** + * Confirm the action. + */ + CONFIRMED("confirmed"), + + /** + * Deny the action. + */ + DENIED("denied"); + + + private final String name; + + Confirmation(final String name) { + this.name = name; + } + + @JsonValue + public String getName() { + return name; + } + } + + @Override + public String toString() { + return "DdiConfirmationFeedback [confirmation=" + confirmation + ", code=" + code + ", details=" + details +"]"; + } +} diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java index 6ec58b761..e3a2cb651 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java @@ -23,6 +23,21 @@ public final class DdiRestConstants { */ public static final String DEPLOYMENT_BASE_ACTION = "deploymentBase"; + /** + * Confirmation base resource. + */ + public static final String CONFIRMATION_BASE = "confirmationBase"; + + /** + * Activate auto-confirm + */ + public static final String AUTO_CONFIRM_ACTIVATE = "activateAutoConfirm"; + + /** + * Deactivate auto-confirm + */ + public static final String AUTO_CONFIRM_DEACTIVATE = "deactivateAutoConfirm"; + /** * Installed action resources. */ @@ -52,6 +67,7 @@ public final class DdiRestConstants { * Default value specifying that no action history to be sent as part of * response to deploymentBase * {@link DdiRootControllerRestApi#getControllerBasedeploymentAction}. + * {@link DdiRootControllerRestApi#getConfirmationBaseAction}. */ public static final String NO_ACTION_HISTORY = "0"; diff --git a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java index f7da399f7..7419997c1 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java @@ -16,9 +16,14 @@ import javax.validation.Valid; import javax.validation.constraints.NotEmpty; import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +import org.eclipse.hawkbit.ddi.json.model.DdiActivateAutoConfirmation; import org.eclipse.hawkbit.ddi.json.model.DdiArtifact; +import org.eclipse.hawkbit.ddi.json.model.DdiAutoConfirmationState; import org.eclipse.hawkbit.ddi.json.model.DdiCancel; import org.eclipse.hawkbit.ddi.json.model.DdiConfigData; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationBase; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationBaseAction; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase; import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase; import org.springframework.hateoas.MediaTypes; @@ -29,6 +34,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -123,17 +129,16 @@ public interface DdiRootControllerRestApi { * @param controllerId * of the target * @param actionId - * of the {@link DdiDeploymentBase} that matches to active - * actions. + * of the {@link DdiDeploymentBase} that matches to active actions. * @param resource - * an hashcode of the resource which indicates if the action has - * been changed, e.g. from 'soft' to 'force' and the eTag needs - * to be re-generated + * an hashcode of the resource which indicates if the action has been + * changed, e.g. from 'soft' to 'force' and the eTag needs to be + * re-generated * @param actionHistoryMessageCount * specifies the number of messages to be returned from action * history. Regardless of the passed value, in order to restrict - * resource utilization by controllers, maximum number of - * messages that are retrieved from database is limited by + * resource utilization by controllers, maximum number of messages + * that are retrieved from database is limited by * {@link RepositoryConstants#MAX_ACTION_HISTORY_MSG_COUNT}. * * actionHistoryMessageCount less than zero: retrieves the maximum @@ -212,8 +217,8 @@ public interface DdiRootControllerRestApi { @PathVariable("actionId") @NotEmpty final Long actionId); /** - * RequestMethod.POST method receiving the {@link DdiActionFeedback} from - * the target. + * RequestMethod.POST method receiving the {@link DdiActionFeedback} from the + * target. * * @param feedback * the {@link DdiActionFeedback} from the target. @@ -270,4 +275,119 @@ public interface DdiRootControllerRestApi { @PathVariable("actionId") @NotEmpty final Long actionId, @RequestParam(value = "actionHistory", defaultValue = DdiRestConstants.NO_ACTION_HISTORY) final Integer actionHistoryMessageCount); + /** + * Returns the confirmation base with the current auto-confirmation state for a + * given controllerId and toggle links. In case there are actions present where + * the confirmation is required, a reference to it will be returned as well. + * + * @param tenant + * the controllerId is corresponding too + * @param controllerId + * to check the state for + * @return the state as {@link DdiAutoConfirmationState} + */ + @GetMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE, produces = { + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) + ResponseEntity getConfirmationBase(@PathVariable("tenant") final String tenant, + @PathVariable("controllerId") @NotEmpty final String controllerId); + + /** + * Resource for confirmation of an action. + * + * @param tenant + * of the request + * @param controllerId + * of the target + * @param actionId + * of the {@link DdiConfirmationBaseAction} that matches to active + * actions in WAITING_FOR_CONFIRMATION status. + * @param resource + * an hashcode of the resource which indicates if the action has been + * changed, e.g. from 'soft' to 'force' and the eTag needs to be + * re-generated + * @param actionHistoryMessageCount + * specifies the number of messages to be returned from action + * history. Regardless of the passed value, in order to restrict + * resource utilization by controllers, maximum number of messages + * that are retrieved from database is limited by + * {@link RepositoryConstants#MAX_ACTION_HISTORY_MSG_COUNT}. + * + * actionHistoryMessageCount less than zero: retrieves the maximum + * allowed number of action status messages from history; + * + * actionHistoryMessageCount equal to zero: does not retrieve any + * message; + * + * actionHistoryMessageCount greater than zero: retrieves the + * specified number of messages, limited by maximum allowed number. + * + * @return the response + */ + @GetMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/{actionId}", produces = { + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE, DdiRestConstants.MEDIA_TYPE_CBOR }) + ResponseEntity getConfirmationBaseAction(@PathVariable("tenant") final String tenant, + @PathVariable("controllerId") @NotEmpty final String controllerId, + @PathVariable("actionId") @NotEmpty final Long actionId, + @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource, + @RequestParam(value = "actionHistory", defaultValue = DdiRestConstants.NO_ACTION_HISTORY) final Integer actionHistoryMessageCount); + + /** + * This is the feedback channel for the {@link DdiConfirmationBaseAction} + * action. + * + * @param tenant + * of the client + * @param feedback + * to provide + * @param controllerId + * of the target that matches to controller id + * @param actionId + * of the action we have feedback for + * + * @return the response + */ + @PostMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/{actionId}/" + + DdiRestConstants.FEEDBACK, consumes = { MediaType.APPLICATION_JSON_VALUE, + DdiRestConstants.MEDIA_TYPE_CBOR }) + ResponseEntity postConfirmationActionFeedback(@Valid final DdiConfirmationFeedback feedback, + @PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId, + @PathVariable("actionId") @NotEmpty final Long actionId); + + /** + * Activate auto confirmation for a given controllerId. Will use the provided + * initiator and remark field from the provided + * {@link DdiActivateAutoConfirmation}. If not present, the values will be + * prefilled with a default remark and the CONTROLLER as initiator. + * + * @param tenant + * the controllerId is corresponding too + * @param controllerId + * to activate auto-confirmation for + * @param body + * as {@link DdiActivateAutoConfirmation} + * @return {@link org.springframework.http.HttpStatus#OK} if successful or + * {@link org.springframework.http.HttpStatus#CONFLICT} in case + * auto-confirmation was active already. + */ + @PostMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/" + + DdiRestConstants.AUTO_CONFIRM_ACTIVATE, consumes = { MediaType.APPLICATION_JSON_VALUE, + DdiRestConstants.MEDIA_TYPE_CBOR }) + ResponseEntity activateAutoConfirmation(@PathVariable("tenant") final String tenant, + @PathVariable("controllerId") @NotEmpty final String controllerId, + @Valid @RequestBody(required = false) final DdiActivateAutoConfirmation body); + + /** + * Deactivate auto confirmation for a given controller id. + * + * @param tenant + * the controllerId is corresponding too + * @param controllerId + * to disable auto-confirmation for + * @return {@link org.springframework.http.HttpStatus#OK} if successfully + * executed + */ + @PostMapping(value = "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/" + + DdiRestConstants.AUTO_CONFIRM_DEACTIVATE) + ResponseEntity deactivateAutoConfirmation(@PathVariable("tenant") final String tenant, + @PathVariable("controllerId") @NotEmpty final String controllerId); } diff --git a/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseTest.java b/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseTest.java new file mode 100644 index 000000000..f9e34596f --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/DdiConfirmationBaseTest.java @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.ddi.json.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import io.qameta.allure.Description; +import io.qameta.allure.Feature; +import io.qameta.allure.Story; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.eclipse.hawkbit.ddi.json.model.DdiDeployment.DdiMaintenanceWindowStatus.AVAILABLE; +import static org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType.ATTEMPT; +import static org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType.FORCED; + +/** + * Test serializability of DDI api model 'DdiConfirmationBase' + */ +@Feature("Unit Tests - Direct Device Integration API") +@Story("CHeck JSON serialization of DDI api confirmation models") +class DdiConfirmationBaseTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + @Description("Verify the correct serialization and deserialization of the model") + void shouldSerializeAndDeserializeObject() throws IOException { + // Setup + final String id = "1234"; + final DdiDeployment ddiDeployment = new DdiDeployment(FORCED, ATTEMPT, Collections.emptyList(), AVAILABLE); + final String actionStatus = "TestAction"; + final DdiActionHistory ddiActionHistory = new DdiActionHistory(actionStatus, + Arrays.asList("Action status message 1", "Action status message 2")); + final DdiConfirmationBaseAction ddiConfirmationBaseAction = new DdiConfirmationBaseAction(id, ddiDeployment, + ddiActionHistory); + + // Test + String serializedDdiConfirmationBase = mapper.writeValueAsString(ddiConfirmationBaseAction); + final DdiConfirmationBaseAction deserializedDdiConfigurationBase = mapper + .readValue(serializedDdiConfirmationBase, DdiConfirmationBaseAction.class); + + assertThat(serializedDdiConfirmationBase).contains(id, FORCED.getName(), ATTEMPT.getName(), + AVAILABLE.getStatus(), actionStatus); + assertThat(deserializedDdiConfigurationBase.getConfirmation().getDownload()) + .isEqualTo(ddiDeployment.getDownload()); + assertThat(deserializedDdiConfigurationBase.getConfirmation().getUpdate()).isEqualTo(ddiDeployment.getUpdate()); + assertThat(deserializedDdiConfigurationBase.getConfirmation().getMaintenanceWindow()) + .isEqualTo(ddiDeployment.getMaintenanceWindow()); + assertThat(deserializedDdiConfigurationBase.getActionHistory().toString()) + .isEqualTo(ddiActionHistory.toString()); + } + + @Test + @Description("Verify the correct deserialization of a model with a additional unknown property") + void shouldDeserializeObjectWithUnknownProperty() throws IOException { + // Setup + String serializedDdiConfirmationBase = "{\"id\":\"1234\",\"confirmation\":{\"download\":\"forced\"," + + "\"update\":\"attempt\",\"maintenanceWindow\":\"available\",\"chunks\":[]}," + + "\"actionHistory\":{\"status\":\"TestAction\",\"messages\":[\"Action status message 1\"," + + "\"Action status message 2\"]},\"links\":[],\"unknownProperty\":\"test\"}"; + + // Test + DdiConfirmationBaseAction ddiConfirmationBaseAction = mapper.readValue(serializedDdiConfirmationBase, + DdiConfirmationBaseAction.class); + + assertThat(ddiConfirmationBaseAction.getConfirmation().getDownload().getName()).isEqualTo(FORCED.getName()); + assertThat(ddiConfirmationBaseAction.getConfirmation().getUpdate().getName()).isEqualTo(ATTEMPT.getName()); + assertThat(ddiConfirmationBaseAction.getConfirmation().getMaintenanceWindow().getStatus()) + .isEqualTo(AVAILABLE.getStatus()); + } + + @Test + @Description("Verify that deserialization fails for known properties with a wrong datatype") + void shouldFailForObjectWithWrongDataTypes() throws IOException { + // Setup + String serializedDdiConfirmationBase = "{\"id\":[\"1234\"],\"confirmation\":{\"download\":\"forced\"," + + "\"update\":\"attempt\",\"maintenanceWindow\":\"available\",\"chunks\":[]}," + + "\"actionHistory\":{\"status\":\"TestAction\",\"messages\":[\"Action status message 1\"," + + "\"Action status message 2\"]},\"links\":[]}"; + + // Test + assertThatExceptionOfType(MismatchedInputException.class) + .isThrownBy(() -> mapper.readValue(serializedDdiConfirmationBase, DdiConfirmationBaseAction.class)); + } +} \ No newline at end of file diff --git a/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/JsonIgnorePropertiesAnnotationTest.java b/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/JsonIgnorePropertiesAnnotationTest.java index add424adf..c2d6ee3d8 100644 --- a/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/JsonIgnorePropertiesAnnotationTest.java +++ b/hawkbit-rest/hawkbit-ddi-api/src/test/java/org/eclipse/hawkbit/ddi/json/model/JsonIgnorePropertiesAnnotationTest.java @@ -31,20 +31,23 @@ import io.qameta.allure.Story; public class JsonIgnorePropertiesAnnotationTest { @Test - @Description( - "This test verifies that all model classes within the 'org.eclipse.hawkbit.ddi.json.model' package are annotated with '@JsonIgnoreProperties(ignoreUnknown = true)'") + @Description("This test verifies that all model classes within the 'org.eclipse.hawkbit.ddi.json.model' package are annotated with '@JsonIgnoreProperties(ignoreUnknown = true)'") public void shouldCheckAnnotationsForAllModelClasses() throws IOException { final ClassLoader loader = Thread.currentThread().getContextClassLoader(); final String packageName = this.getClass().getPackage().getName(); - final ImmutableSet topLevelClasses = ClassPath.from(loader).getTopLevelClasses(packageName); + final ImmutableSet topLevelClasses = ClassPath.from(loader) + .getTopLevelClasses(packageName); for (final ClassPath.ClassInfo classInfo : topLevelClasses) { final Class modelClass = classInfo.load(); if (modelClass.getSimpleName().contains("Test") || modelClass.isEnum()) { continue; } final JsonIgnoreProperties annotation = modelClass.getAnnotation(JsonIgnoreProperties.class); - assertThat(annotation).isNotNull(); + assertThat(annotation) + .describedAs( + "Annotation 'JsonIgnoreProperties' is missing for class: " + modelClass.getSimpleName()) + .isNotNull(); assertThat(annotation.ignoreUnknown()).isTrue(); } } diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java b/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java index 228d9c5c3..7337362f5 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java @@ -18,8 +18,10 @@ import org.eclipse.hawkbit.api.URLPlaceholder; import org.eclipse.hawkbit.api.URLPlaceholder.SoftwareData; import org.eclipse.hawkbit.ddi.json.model.DdiArtifact; import org.eclipse.hawkbit.ddi.json.model.DdiArtifactHash; +import org.eclipse.hawkbit.ddi.json.model.DdiAutoConfirmationState; import org.eclipse.hawkbit.ddi.json.model.DdiChunk; import org.eclipse.hawkbit.ddi.json.model.DdiConfig; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationBase; import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase; import org.eclipse.hawkbit.ddi.json.model.DdiMetadata; import org.eclipse.hawkbit.ddi.json.model.DdiPolling; @@ -108,13 +110,46 @@ public final class DataConversionHelper { } + public static DdiConfirmationBase createConfirmationBase(final Target target, final Action activeAction, + final DdiAutoConfirmationState autoConfirmationState, final TenantAware tenantAware) { + final String controllerId = target.getControllerId(); + final DdiConfirmationBase confirmationBase = new DdiConfirmationBase(autoConfirmationState); + if (autoConfirmationState.isActive()) { + confirmationBase.add(WebMvcLinkBuilder + .linkTo(WebMvcLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) + .deactivateAutoConfirmation(tenantAware.getCurrentTenant(), controllerId)) + .withRel(DdiRestConstants.AUTO_CONFIRM_DEACTIVATE)); + } else { + confirmationBase.add(WebMvcLinkBuilder + .linkTo(WebMvcLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) + .activateAutoConfirmation(tenantAware.getCurrentTenant(), controllerId, null)) + .withRel(DdiRestConstants.AUTO_CONFIRM_ACTIVATE)); + } + if (activeAction != null && activeAction.isWaitingConfirmation()) { + confirmationBase.add(WebMvcLinkBuilder + .linkTo(WebMvcLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) + .getConfirmationBaseAction(tenantAware.getCurrentTenant(), controllerId, + activeAction.getId(), calculateEtag(activeAction), null)) + .withRel(DdiRestConstants.CONFIRMATION_BASE)); + } + + return confirmationBase; + } + public static DdiControllerBase fromTarget(final Target target, final Action installedAction, final Action activeAction, final String defaultControllerPollTime, final TenantAware tenantAware) { final DdiControllerBase result = new DdiControllerBase( new DdiConfig(new DdiPolling(defaultControllerPollTime))); if (activeAction != null) { - if (activeAction.isCancelingOrCanceled()) { + if (activeAction.isWaitingConfirmation()) { + result.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder + .methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) + .getConfirmationBaseAction(tenantAware.getCurrentTenant(), target.getControllerId(), + activeAction.getId(), calculateEtag(activeAction), null)) + .withRel(DdiRestConstants.CONFIRMATION_BASE)); + + } else if (activeAction.isCancelingOrCanceled()) { result.add(WebMvcLinkBuilder .linkTo(WebMvcLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) .getControllerCancelAction(tenantAware.getCurrentTenant(), target.getControllerId(), @@ -126,10 +161,10 @@ public final class DataConversionHelper { // have changed from 'soft' to 'forced' type and we need to // change the payload of the // response because of eTags. - result.add(WebMvcLinkBuilder - .linkTo(WebMvcLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) - .getControllerBasedeploymentAction(tenantAware.getCurrentTenant(), - target.getControllerId(), activeAction.getId(), calculateEtag(activeAction), null)) + result.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder + .methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) + .getControllerBasedeploymentAction(tenantAware.getCurrentTenant(), target.getControllerId(), + activeAction.getId(), calculateEtag(activeAction), null)) .withRel(DdiRestConstants.DEPLOYMENT_BASE_ACTION)); } } @@ -149,6 +184,7 @@ public final class DataConversionHelper { .putConfigData(null, tenantAware.getCurrentTenant(), target.getControllerId())) .withRel(DdiRestConstants.CONFIG_DATA_ACTION)); } + return result; } diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java b/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java index aba695745..30bdd8d38 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java @@ -13,6 +13,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; +import java.util.Optional; import javax.servlet.http.HttpServletRequest; import javax.validation.Valid; @@ -22,11 +23,16 @@ import org.eclipse.hawkbit.api.ArtifactUrlHandler; import org.eclipse.hawkbit.artifact.repository.model.DbArtifact; import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; import org.eclipse.hawkbit.ddi.json.model.DdiActionHistory; +import org.eclipse.hawkbit.ddi.json.model.DdiActivateAutoConfirmation; import org.eclipse.hawkbit.ddi.json.model.DdiArtifact; +import org.eclipse.hawkbit.ddi.json.model.DdiAutoConfirmationState; import org.eclipse.hawkbit.ddi.json.model.DdiCancel; import org.eclipse.hawkbit.ddi.json.model.DdiCancelActionToStop; import org.eclipse.hawkbit.ddi.json.model.DdiChunk; import org.eclipse.hawkbit.ddi.json.model.DdiConfigData; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationBase; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationBaseAction; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase; import org.eclipse.hawkbit.ddi.json.model.DdiDeployment; import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.DdiMaintenanceWindowStatus; @@ -37,6 +43,7 @@ import org.eclipse.hawkbit.ddi.json.model.DdiUpdateMode; import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi; import org.eclipse.hawkbit.repository.ArtifactManagement; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.RepositoryConstants; @@ -47,6 +54,7 @@ import org.eclipse.hawkbit.repository.event.remote.DownloadProgressEvent; import org.eclipse.hawkbit.repository.exception.ArtifactBinaryNotFoundException; import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidConfirmationFeedbackException; import org.eclipse.hawkbit.repository.exception.SoftwareModuleNotAssignedToTargetException; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.Status; @@ -91,6 +99,10 @@ public class DdiRootController implements DdiRootControllerRestApi { private static final Logger LOG = LoggerFactory.getLogger(DdiRootController.class); private static final String GIVEN_ACTION_IS_NOT_ASSIGNED_TO_GIVEN_TARGET = "given action ({}) is not assigned to given target ({})."; + private static final String FALLBACK_REMARK = "Initiated using the Device Direct Integration API without providing a remark."; + + @Autowired + private ConfirmationManagement confirmationManagement; @Autowired private ApplicationEventPublisher eventPublisher; @@ -155,12 +167,11 @@ public class DdiRootController implements DdiRootControllerRestApi { checkAndCancelExpiredAction(activeAction); - return new ResponseEntity<>( - DataConversionHelper.fromTarget(target, installedAction, activeAction, - activeAction == null ? controllerManagement.getPollingTime() - : controllerManagement.getPollingTimeForAction(activeAction.getId()), - tenantAware), - HttpStatus.OK); + // activeAction + return new ResponseEntity<>(DataConversionHelper.fromTarget(target, installedAction, activeAction, + activeAction == null ? controllerManagement.getPollingTime() + : controllerManagement.getPollingTimeForAction(activeAction.getId()), + tenantAware), HttpStatus.OK); } @Override @@ -276,7 +287,7 @@ public class DdiRootController implements DdiRootControllerRestApi { checkAndCancelExpiredAction(action); - if (!action.isCancelingOrCanceled()) { + if (!action.isCancelingOrCanceled() && !action.isWaitingConfirmation()) { final DdiDeploymentBase base = generateDdiDeploymentBase(target, action, actionHistoryMessageCount); @@ -324,6 +335,10 @@ public class DdiRootController implements DdiRootControllerRestApi { final Target target = findTarget(controllerId); final Action action = findActionForTarget(actionId, target); + if (action.isWaitingConfirmation()) { + return ResponseEntity.notFound().build(); + } + if (!action.isActive()) { LOG.warn("Updating action {} with feedback {} not possible since action not active anymore.", action.getId(), feedback.getStatus()); @@ -488,28 +503,6 @@ public class DdiRootController implements DdiRootControllerRestApi { } - private DdiDeploymentBase generateDdiDeploymentBase(final Target target, final Action action, - final Integer actionHistoryMessageCount) { - final List chunks = DataConversionHelper.createChunks(target, action, artifactUrlHandler, - systemManagement, new ServletServerHttpRequest(requestResponseContextHolder.getHttpServletRequest()), - controllerManagement); - - final List actionHistoryMsgs = controllerManagement.getActionHistoryMessages(action.getId(), - actionHistoryMessageCount == null ? Integer.parseInt(DdiRestConstants.NO_ACTION_HISTORY) - : actionHistoryMessageCount); - - final DdiActionHistory actionHistory = actionHistoryMsgs.isEmpty() ? null - : new DdiActionHistory(action.getStatus().name(), actionHistoryMsgs); - - final HandlingType downloadType = calculateDownloadType(action); - final HandlingType updateType = calculateUpdateType(action, downloadType); - - final DdiMaintenanceWindowStatus maintenanceWindow = calculateMaintenanceWindow(action); - - return new DdiDeploymentBase(Long.toString(action.getId()), - new DdiDeployment(downloadType, updateType, chunks, maintenanceWindow), actionHistory); - } - private static ActionStatusCreate generateActionCancelStatus(final DdiActionFeedback feedback, final Target target, final Long actionId, final EntityFactory entityFactory) { @@ -621,4 +614,150 @@ public class DdiRootController implements DdiRootControllerRestApi { return null; } + + @Override + public ResponseEntity getConfirmationBaseAction( + @PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId, + @PathVariable("actionId") final Long actionId, + @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource, + @RequestParam(value = "actionHistory", defaultValue = DdiRestConstants.NO_ACTION_HISTORY) final Integer actionHistoryMessageCount) { + LOG.debug("getConfirmationBaseAction({},{})", controllerId, resource); + + final Target target = findTarget(controllerId); + final Action action = findActionForTarget(actionId, target); + + checkAndCancelExpiredAction(action); + + if (!action.isCancelingOrCanceled() && action.isWaitingConfirmation()) { + + final DdiConfirmationBaseAction base = generateDdiConfirmationBase(target, action, + actionHistoryMessageCount); + + LOG.debug("Found an active UpdateAction for target {}. Returning confirmation: {}", controllerId, base); + + return new ResponseEntity<>(base, HttpStatus.OK); + } + + return ResponseEntity.notFound().build(); + } + + private DdiDeploymentBase generateDdiDeploymentBase(final Target target, final Action action, + final Integer actionHistoryMessageCount) { + final DdiActionHistory actionHistory = generateDdiActionHistory(action, actionHistoryMessageCount).orElse(null); + final DdiDeployment ddiDeployment = generateDdiDeployment(target, action); + return new DdiDeploymentBase(Long.toString(action.getId()), ddiDeployment, actionHistory); + } + + private DdiConfirmationBaseAction generateDdiConfirmationBase(final Target target, final Action action, + final Integer actionHistoryMessageCount) { + final DdiActionHistory actionHistory = generateDdiActionHistory(action, actionHistoryMessageCount).orElse(null); + final DdiDeployment ddiDeployment = generateDdiDeployment(target, action); + return new DdiConfirmationBaseAction(Long.toString(action.getId()), ddiDeployment, actionHistory); + } + + private DdiDeployment generateDdiDeployment(final Target target, final Action action) { + final List chunks = DataConversionHelper.createChunks(target, action, artifactUrlHandler, + systemManagement, new ServletServerHttpRequest(requestResponseContextHolder.getHttpServletRequest()), + controllerManagement); + final HandlingType downloadType = calculateDownloadType(action); + final HandlingType updateType = calculateUpdateType(action, downloadType); + final DdiMaintenanceWindowStatus maintenanceWindow = calculateMaintenanceWindow(action); + return new DdiDeployment(downloadType, updateType, chunks, maintenanceWindow); + } + + private Optional generateDdiActionHistory(final Action action, + final Integer actionHistoryMessageCount) { + final List actionHistoryMsgs = controllerManagement.getActionHistoryMessages(action.getId(), + actionHistoryMessageCount == null ? Integer.parseInt(DdiRestConstants.NO_ACTION_HISTORY) + : actionHistoryMessageCount); + return actionHistoryMsgs.isEmpty() ? Optional.empty() + : Optional.of(new DdiActionHistory(action.getStatus().name(), actionHistoryMsgs)); + } + + @Override + public ResponseEntity postConfirmationActionFeedback( + @Valid @RequestBody final DdiConfirmationFeedback feedback, @PathVariable("tenant") final String tenant, + @PathVariable("controllerId") final String controllerId, + @PathVariable("actionId") @NotEmpty final Long actionId) { + LOG.debug("provideConfirmationActionFeedback with feedback [controllerId={}, actionId={}]: {}", controllerId, + actionId, feedback); + + final Target target = findTarget(controllerId); + final Action action = findActionForTarget(actionId, target); + + try { + + switch (feedback.getConfirmation()) { + case CONFIRMED: + LOG.info("Controller confirmed the action (actionId: {}, controllerId: {}) as we got {} report.", + actionId, controllerId, feedback.getConfirmation()); + confirmationManagement.confirmAction(actionId, feedback.getCode(), feedback.getDetails()); + break; + case DENIED: + default: + LOG.debug("Controller denied the action (actionId: {}, controllerId: {}) as we got {} report.", + actionId, controllerId, feedback.getConfirmation()); + confirmationManagement.denyAction(actionId, feedback.getCode(), feedback.getDetails()); + break; + } + } catch (final InvalidConfirmationFeedbackException e) { + if (e.getReason() == InvalidConfirmationFeedbackException.Reason.ACTION_CLOSED) { + LOG.warn("Updating action {} with confirmation {} not possible since action not active anymore.", + action.getId(), feedback.getConfirmation()); + return new ResponseEntity<>(HttpStatus.GONE); + } else if (e.getReason() == InvalidConfirmationFeedbackException.Reason.NOT_AWAITING_CONFIRMATION) { + LOG.debug("Action is not waiting for confirmation, deny request."); + return ResponseEntity.notFound().build(); + } + } + + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity getConfirmationBase(final String tenant, final String controllerId) { + LOG.debug("getConfirmationBase is called [controllerId={}].", controllerId); + final Target target = controllerManagement.findOrRegisterTargetIfItDoesNotExist(controllerId, IpUtil + .getClientIpFromRequest(requestResponseContextHolder.getHttpServletRequest(), securityProperties)); + final Action activeAction = controllerManagement.findActiveActionWithHighestWeight(controllerId).orElse(null); + + final DdiAutoConfirmationState autoConfirmationState = getAutoConfirmationState(controllerId); + + final DdiConfirmationBase confirmationBase = DataConversionHelper.createConfirmationBase(target, activeAction, + autoConfirmationState, tenantAware); + return new ResponseEntity<>(confirmationBase, HttpStatus.OK); + } + + private DdiAutoConfirmationState getAutoConfirmationState(final String controllerId) { + return confirmationManagement.getStatus(controllerId).map(status -> { + final DdiAutoConfirmationState state = DdiAutoConfirmationState.active(status.getActivatedAt()); + state.setInitiator(status.getInitiator()); + state.setRemark(status.getRemark()); + LOG.trace("Returning state auto-conf state active [initiator='{}' | activatedAt={}] for device {}", + controllerId, status.getInitiator(), status.getActivatedAt()); + return state; + }).orElseGet(() -> { + LOG.trace("Returning state auto-conf state disabled for device {}", controllerId); + return DdiAutoConfirmationState.disabled(); + }); + } + + @Override + public ResponseEntity activateAutoConfirmation(final String tenant, final String controllerId, + final DdiActivateAutoConfirmation body) { + final String initiator = body == null ? null : body.getInitiator(); + final String remark = body == null ? FALLBACK_REMARK : body.getRemark(); + LOG.debug("Activate auto-confirmation request for device '{}' with payload: [initiator='{}' | remark='{}'", + controllerId, initiator, remark); + confirmationManagement.activateAutoConfirmation(controllerId, initiator, remark); + return new ResponseEntity<>(HttpStatus.OK); + } + + @Override + public ResponseEntity deactivateAutoConfirmation(final String tenant, final String controllerId) { + LOG.debug("Deactivate auto-confirmation request for device ‘{}‘", controllerId); + confirmationManagement.deactivateAutoConfirmation(controllerId); + return new ResponseEntity<>(HttpStatus.OK); + } + } diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java index ce025a865..d14985f27 100644 --- a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/AbstractDDiApiIntegrationTest.java @@ -24,6 +24,7 @@ import java.util.List; import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; import org.eclipse.hawkbit.ddi.json.model.DdiProgress; import org.eclipse.hawkbit.ddi.json.model.DdiResult; import org.eclipse.hawkbit.ddi.json.model.DdiStatus; @@ -68,6 +69,13 @@ public abstract class AbstractDDiApiIntegrationTest extends AbstractRestIntegrat protected static final String DEPLOYMENT_FEEDBACK = DEPLOYMENT_BASE + "/feedback"; protected static final String CANCEL_FEEDBACK = CANCEL_ACTION + "/feedback"; + protected static final String CONFIRMATION_BASE = CONTROLLER_BASE + "/confirmationBase"; + protected static final String ACTIVATE_AUTO_CONFIRM = CONFIRMATION_BASE + "/activateAutoConfirm"; + protected static final String DEACTIVATE_AUTO_CONFIRM = CONFIRMATION_BASE + "/deactivateAutoConfirm"; + protected static final String CONFIRMATION_BASE_ACTION = CONTROLLER_BASE + "/confirmationBase/{actionId}"; + + protected static final String CONFIRMATION_FEEDBACK = CONFIRMATION_BASE_ACTION + "/feedback"; + protected static final int ARTIFACT_SIZE = 5 * 1024; private static final ObjectMapper objectMapper = new ObjectMapper(); @@ -155,7 +163,7 @@ public abstract class AbstractDDiApiIntegrationTest extends AbstractRestIntegrat final Long osModuleId, final String downloadType, final String updateType) throws Exception { final ResultActions resultActions = performGet(DEPLOYMENT_BASE, mediaType, status().isOk(), tenantAware.getCurrentTenant(), controllerId, actionId.toString()); - return verifyBasePayload(resultActions, controllerId, ds, artifact, artifactSignature, actionId, osModuleId, + return verifyBasePayload("$.deployment", resultActions, controllerId, ds, artifact, artifactSignature, actionId, osModuleId, downloadType, updateType); } @@ -171,60 +179,60 @@ public abstract class AbstractDDiApiIntegrationTest extends AbstractRestIntegrat final Long osModuleId, final Action.ActionType actionType) throws Exception { final ResultActions resultActions = performGet(INSTALLED_BASE, mediaType, status().isOk(), tenantAware.getCurrentTenant(), controllerId, actionId.toString()); - return verifyBasePayload(resultActions, controllerId, ds, artifact, artifactSignature, actionId, osModuleId, + return verifyBasePayload("$.deployment", resultActions, controllerId, ds, artifact, artifactSignature, actionId, osModuleId, getDownloadAndUploadType(actionType), getDownloadAndUploadType(actionType)); } - private ResultActions verifyBasePayload(final ResultActions resultActions, final String controllerId, + private ResultActions verifyBasePayload(final String prefix, final ResultActions resultActions, final String controllerId, final DistributionSet ds, final Artifact artifact, final Artifact artifactSignature, final Long actionId, final Long osModuleId, final String downloadType, final String updateType) throws Exception { return resultActions.andExpect(jsonPath("$.id", equalTo(String.valueOf(actionId)))) - .andExpect(jsonPath("$.deployment.download", equalTo(downloadType))) - .andExpect(jsonPath("$.deployment.update", equalTo(updateType))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='jvm')].name", + .andExpect(jsonPath(prefix + ".download", equalTo(downloadType))) + .andExpect(jsonPath(prefix + ".update", equalTo(updateType))) + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='jvm')].name", contains(ds.findFirstModuleByType(runtimeType).get().getName()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='jvm')].version", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='jvm')].version", contains(ds.findFirstModuleByType(runtimeType).get().getVersion()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].name", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].name", contains(ds.findFirstModuleByType(osType).get().getName()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].version", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].version", contains(ds.findFirstModuleByType(osType).get().getVersion()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0].size", contains(ARTIFACT_SIZE))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0].filename", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0].size", contains(ARTIFACT_SIZE))) + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0].filename", contains(artifact.getFilename()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0].hashes.md5", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0].hashes.md5", contains(artifact.getMd5Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0].hashes.sha1", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0].hashes.sha1", contains(artifact.getSha1Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0].hashes.sha256", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0].hashes.sha256", contains(artifact.getSha256Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0]._links.download-http.href", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0]._links.download-http.href", contains(HTTP_LOCALHOST + tenantAware.getCurrentTenant() + "/controller/v1/" + controllerId + "/softwaremodules/" + osModuleId + "/artifacts/" + artifact.getFilename()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[0]._links.md5sum-http.href", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[0]._links.md5sum-http.href", contains(HTTP_LOCALHOST + tenantAware.getCurrentTenant() + "/controller/v1/" + controllerId + "/softwaremodules/" + osModuleId + "/artifacts/" + artifact.getFilename() + ".MD5SUM"))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1].size", contains(ARTIFACT_SIZE))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1].filename", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1].size", contains(ARTIFACT_SIZE))) + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1].filename", contains(artifactSignature.getFilename()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1].hashes.md5", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1].hashes.md5", contains(artifactSignature.getMd5Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1].hashes.sha1", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1].hashes.sha1", contains(artifactSignature.getSha1Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1].hashes.sha256", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1].hashes.sha256", contains(artifactSignature.getSha256Hash()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1]._links.download-http.href", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1]._links.download-http.href", contains(HTTP_LOCALHOST + tenantAware.getCurrentTenant() + "/controller/v1/" + controllerId + "/softwaremodules/" + osModuleId + "/artifacts/" + artifactSignature.getFilename()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='os')].artifacts[1]._links.md5sum-http.href", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='os')].artifacts[1]._links.md5sum-http.href", contains(HTTP_LOCALHOST + tenantAware.getCurrentTenant() + "/controller/v1/" + controllerId + "/softwaremodules/" + osModuleId + "/artifacts/" + artifactSignature.getFilename() + ".MD5SUM"))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='bApp')].version", + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='bApp')].version", contains(ds.findFirstModuleByType(appType).get().getVersion()))) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='bApp')].metadata").doesNotExist()) - .andExpect(jsonPath("$.deployment.chunks[?(@.part=='bApp')].name") + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='bApp')].metadata").doesNotExist()) + .andExpect(jsonPath(prefix + ".chunks[?(@.part=='bApp')].name") .value(ds.findFirstModuleByType(appType).get().getName())); } @@ -338,4 +346,24 @@ public abstract class AbstractDDiApiIntegrationTest extends AbstractRestIntegrat messages); return objectMapper.writeValueAsString(new DdiActionFeedback(Instant.now().toString(), ddiStatus)); } + + protected String getJsonConfirmationFeedback(final DdiConfirmationFeedback.Confirmation confirmation, + final Integer code, final List messages) throws JsonProcessingException { + return objectMapper.writeValueAsString(new DdiConfirmationFeedback(confirmation, code, messages)); + } + + protected static ObjectMapper getMapper(){ + return objectMapper; + } + + protected ResultActions getAndVerifyConfirmationBasePayload(final String controllerId, final MediaType mediaType, + final DistributionSet ds, final Artifact artifact, final Artifact artifactSignature, final Long actionId, + final Long osModuleId, final String downloadType, final String updateType) throws Exception { + final ResultActions resultActions = performGet(CONFIRMATION_BASE_ACTION, mediaType, status().isOk(), + tenantAware.getCurrentTenant(), controllerId, actionId.toString()); + return verifyBasePayload("$.confirmation", resultActions, controllerId, ds, artifact, artifactSignature, actionId, osModuleId, + downloadType, updateType); + } + + } diff --git a/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java new file mode 100644 index 000000000..1785c3e92 --- /dev/null +++ b/hawkbit-rest/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiConfirmationBaseTest.java @@ -0,0 +1,488 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.rest.resource; + +import io.qameta.allure.Description; +import io.qameta.allure.Feature; +import io.qameta.allure.Step; +import io.qameta.allure.Story; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; +import org.eclipse.hawkbit.ddi.json.model.DdiActivateAutoConfirmation; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; +import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; +import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; +import org.eclipse.hawkbit.repository.event.remote.TargetPollEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.ActionCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.ActionUpdatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.DistributionSetCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.SoftwareModuleCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.TenantConfigurationCreatedEvent; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.ActionStatus; +import org.eclipse.hawkbit.repository.model.Artifact; +import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.Target; +import org.eclipse.hawkbit.repository.test.matcher.Expect; +import org.eclipse.hawkbit.repository.test.matcher.ExpectEvents; +import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.hawkbit.repository.jpa.JpaConfirmationManagement.CONFIRMATION_CODE_MSG_PREFIX; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Test confirmation base from the controller. + */ +@Feature("Component Tests - Direct Device Integration API") +@Story("Confirmation Action Resource") +public class DdiConfirmationBaseTest extends AbstractDDiApiIntegrationTest { + + private static final String DEFAULT_CONTROLLER_ID = "4747"; + + @Test + @Description("Forced deployment to a controller. Checks if the confirmation resource response payload for a given" + + " deployment is as expected.") + public void verifyConfirmationReferencesInControllerBase() throws Exception { + enableConfirmationFlow(); + // Prepare test data + final DistributionSet ds = testdataFactory.createDistributionSet("", true); + final DistributionSet ds2 = testdataFactory.createDistributionSet("2", true); + final Artifact artifact = testdataFactory.createArtifact(RandomUtils.nextBytes(ARTIFACT_SIZE), getOsModule(ds), + "test1", ARTIFACT_SIZE); + final Artifact artifactSignature = testdataFactory.createArtifact(RandomUtils.nextBytes(ARTIFACT_SIZE), + getOsModule(ds), "test1.signature", ARTIFACT_SIZE); + + final Target savedTarget = testdataFactory.createTarget(DdiConfirmationBaseTest.DEFAULT_CONTROLLER_ID); + assertThat(deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId())).isEmpty(); + + final List targetsAssignedToDs = assignDistributionSet(ds.getId(), savedTarget.getControllerId(), + Action.ActionType.FORCED).getAssignedEntity().stream().map(Action::getTarget) + .collect(Collectors.toList()); + + assertThat(deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId())).hasSize(1); + + final Action action = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + assertThat(deploymentManagement.countActionsAll()).isEqualTo(1); + + assignDistributionSet(ds2, targetsAssignedToDs).getAssignedEntity(); + assertThat(deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId())).hasSize(2); + assertThat(deploymentManagement.countActionsAll()).isEqualTo(2); + + final Action uaction = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + assertThat(uaction.getDistributionSet()).isEqualTo(ds); + assertThat(deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId())).hasSize(2); + + // Run test + final long current = System.currentTimeMillis(); + final String expectedConfirmationBaseLink = String.format("/%s/controller/v1/%s/confirmationBase/%d", + tenantAware.getCurrentTenant(), DEFAULT_CONTROLLER_ID, uaction.getId()); + + performGet(CONTROLLER_BASE, MediaTypes.HAL_JSON, status().isOk(), tenantAware.getCurrentTenant(), + DEFAULT_CONTROLLER_ID) + .andExpect(jsonPath("$.config.polling.sleep", equalTo("00:01:00"))) + .andExpect(jsonPath("$._links.confirmationBase.href", + containsString(expectedConfirmationBaseLink))) + .andExpect(jsonPath("$._links.deploymentBase.href").doesNotExist()); + + assertThat(targetManagement.getByControllerID(DEFAULT_CONTROLLER_ID).get().getLastTargetQuery()) + .isGreaterThanOrEqualTo(current); + assertThat(targetManagement.getByControllerID(DEFAULT_CONTROLLER_ID).get().getLastTargetQuery()) + .isLessThanOrEqualTo(System.currentTimeMillis()); + assertThat(deploymentManagement.countActionStatusAll()).isEqualTo(2); + + final DistributionSet findDistributionSetByAction = distributionSetManagement.getByAction(action.getId()).get(); + + getAndVerifyConfirmationBasePayload(DEFAULT_CONTROLLER_ID, MediaType.APPLICATION_JSON, ds, artifact, + artifactSignature, action.getId(), + findDistributionSetByAction.findFirstModuleByType(osType).get().getId(), "forced", "forced"); + + // Retrieved is reported + final Iterable actionStatus = deploymentManagement + .findActionStatusByAction(PageRequest.of(0, 100, Sort.Direction.DESC, "id"), uaction.getId()); + assertThat(actionStatus).hasSize(1) + .allMatch(status -> status.getStatus() == Action.Status.WAIT_FOR_CONFIRMATION); + } + + @Test + @Description("Ensure that the deployment resource is available as CBOR") + public void confirmationResourceCbor() throws Exception { + enableConfirmationFlow(); + final Target target = testdataFactory.createTarget(); + final DistributionSet distributionSet = testdataFactory.createDistributionSet(""); + + assignDistributionSet(distributionSet.getId(), target.getName()); + final Action action = deploymentManagement.findActiveActionsByTarget(PAGE, target.getControllerId()) + .getContent().get(0); + + // get confirmation base + performGet(CONFIRMATION_BASE_ACTION, MediaType.parseMediaType(DdiRestConstants.MEDIA_TYPE_CBOR), + status().isOk(), tenantAware.getCurrentTenant(), target.getControllerId(), action.getId().toString()); + + final Long softwareModuleId = distributionSet.getModules().stream().findAny().get().getId(); + testdataFactory.createArtifacts(softwareModuleId); + // get artifacts + performGet(SOFTWARE_MODULE_ARTIFACTS, MediaType.parseMediaType(DdiRestConstants.MEDIA_TYPE_CBOR), + status().isOk(), tenantAware.getCurrentTenant(), target.getControllerId(), + String.valueOf(softwareModuleId)); + + } + + @Test + @Description("Ensure that the confirmation endpoint is not available.") + public void confirmationEndpointNotExposed() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("988"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + + final String controllerId = savedTarget.getControllerId(); + + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + + mvc.perform( + get(CONTROLLER_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$._links.confirmationBase.href").doesNotExist()); + + mvc.perform(get(CONFIRMATION_BASE_ACTION, tenantAware.getCurrentTenant(), controllerId, savedAction.getId()) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isNotFound()); + } + + @Test + @Description("Ensure that the deploymentBase endpoint is not available for action ins WFC state.") + public void deploymentEndpointNotAccessibleForActionsWFC() throws Exception { + enableConfirmationFlow(); + + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("988"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + + final String controllerId = savedTarget.getControllerId(); + + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + + mvc.perform( + get(CONTROLLER_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$._links.confirmationBase.href").exists()) + .andExpect(jsonPath("$._links.deploymentBase.href").doesNotExist()); + + mvc.perform(get(CONFIRMATION_BASE_ACTION, tenantAware.getCurrentTenant(), controllerId, savedAction.getId()) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(get(DEPLOYMENT_BASE, tenantAware.getCurrentTenant(), controllerId, savedAction.getId()) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isNotFound()); + } + + @Test + @Description("Ensure that the confirmation endpoints are still available after deactivating the confirmation flow.") + public void verifyConfirmationBaseEndpointsArePresentAfterDisablingConfirmationFlow() throws Exception { + enableConfirmationFlow(); + + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("988"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + + final String controllerId = savedTarget.getControllerId(); + + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + + // disable confirmation flow + disableConfirmationFlow(); + + // confirmation base should still be exposed + verifyActionInConfirmationBaseState(savedTarget.getControllerId(), savedAction.getId()); + + // verify confirmation endpoint is still accessible + sendConfirmationFeedback(savedTarget, savedAction, DdiConfirmationFeedback.Confirmation.DENIED, 20, + "Action denied message.").andExpect(status().isOk()); + + // confirmation base should still be exposed + verifyActionInConfirmationBaseState(savedTarget.getControllerId(), savedAction.getId()); + + // verify confirmation endpoint is still accessible + sendConfirmationFeedback(savedTarget, savedAction, DdiConfirmationFeedback.Confirmation.CONFIRMED, 10, + "Action confirmed message.").andExpect(status().isOk()); + + // assert deployment link is exposed to the target + verifyActionInDeploymentBaseState(controllerId, savedAction.getId()); + } + + @Test + @Description("Controller sends a confirmed action state.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = TargetUpdatedEvent.class, count = 1), @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void sendConfirmedActionStateFeedbackTest() throws Exception { + enableConfirmationFlow(); + + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("988"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + + String controllerId = savedTarget.getControllerId(); + + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + + sendConfirmationFeedback(savedTarget, savedAction, DdiConfirmationFeedback.Confirmation.CONFIRMED, 10, + "Action confirmed message.").andExpect(status().isOk()); + + // assert deployment link is exposed to the target + verifyActionInDeploymentBaseState(controllerId, savedAction.getId()); + } + + @Step + private void verifyActionInDeploymentBaseState(final String controllerId, final long actionId) throws Exception { + final String expectedDeploymentBaseLink = String.format("/%s/controller/v1/%s/deploymentBase/%d", + tenantAware.getCurrentTenant(), controllerId, actionId); + + mvc.perform( + get(CONTROLLER_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$._links.deploymentBase.href", containsString(expectedDeploymentBaseLink))) + .andExpect(jsonPath("$._links.confirmationBase.href").doesNotExist()); + + // assert that deployment endpoint is working + mvc.perform(get(expectedDeploymentBaseLink).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + } + + @Step + private void verifyActionInConfirmationBaseState(final String controllerId, final long actionId) throws Exception { + mvc.perform( + get(CONTROLLER_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$._links.confirmationBase.href").exists()) + .andExpect(jsonPath("$._links.deploymentBase.href").doesNotExist()); + + mvc.perform(get(CONFIRMATION_BASE_ACTION, tenantAware.getCurrentTenant(), controllerId, actionId) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(get(DEPLOYMENT_BASE, tenantAware.getCurrentTenant(), controllerId, actionId) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isNotFound()); + } + + @Test + @Description("Confirmation base provides right values if auto-confirm not active.") + void getConfirmationBaseProvidesAutoConfirmStatusNotActive() throws Exception { + enableConfirmationFlow(); + + final String controllerId = testdataFactory.createTarget("989").getControllerId(); + assignDistributionSet(testdataFactory.createDistributionSet("").getId(), controllerId); + final long actionId = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent().get(0) + .getId(); + + final String confirmationBaseActionLink = String.format("/%s/controller/v1/%s/confirmationBase/%d", + tenantAware.getCurrentTenant(), controllerId, actionId); + + final String activateAutoConfLink = String.format("/%s/controller/v1/%s/confirmationBase/activateAutoConfirm", + tenantAware.getCurrentTenant(), controllerId); + + mvc.perform( + get(CONFIRMATION_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("autoConfirm.active", equalTo(Boolean.FALSE))) + .andExpect(jsonPath("$._links.confirmationBase.href", containsString(confirmationBaseActionLink))) + .andExpect(jsonPath("$._links.activateAutoConfirm.href", containsString(activateAutoConfLink))) + .andExpect(jsonPath("$._links.deactivateAutoConfirm").doesNotExist()); + } + + @ParameterizedTest + @MethodSource("possibleActiveStates") + @Description("Confirmation base provides right values if auto-confirm is active.") + void getConfirmationBaseProvidesAutoConfirmStatusActive(final String initiator, final String remark) + throws Exception { + final String controllerId = testdataFactory.createTarget("988").getControllerId(); + + confirmationManagement.activateAutoConfirmation(controllerId, initiator, remark); + + final String deactivateAutoConfLink = String.format( + "/%s/controller/v1/%s/confirmationBase/deactivateAutoConfirm", tenantAware.getCurrentTenant(), + controllerId); + + mvc.perform( + get(CONFIRMATION_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("autoConfirm.active", equalTo(Boolean.TRUE))) + .andExpect(initiator == null ? jsonPath("autoConfirm.initiator").doesNotExist() + : jsonPath("autoConfirm.initiator", equalTo(initiator))) + .andExpect(remark == null ? jsonPath("autoConfirm.remark").doesNotExist() + : jsonPath("autoConfirm.remark", equalTo(remark))) + .andExpect(jsonPath("$._links.deactivateAutoConfirm.href", containsString(deactivateAutoConfLink))) + .andExpect(jsonPath("$._links.activateAutoConfirm").doesNotExist()); + } + + private static Stream possibleActiveStates() { + return Stream.of(Arguments.of("someInitiator", "someRemark"), Arguments.of(null, "someRemark"), + Arguments.of("someInitiator", null), Arguments.of(null, null)); + } + + @ParameterizedTest + @MethodSource("possibleActiveStates") + @Description("Verify auto-confirm activation is handled correctly.") + void activateAutoConfirmation(final String initiator, final String remark) throws Exception { + final String controllerId = testdataFactory.createTarget("988").getControllerId(); + + final DdiActivateAutoConfirmation body = new DdiActivateAutoConfirmation(initiator, remark); + + mvc.perform(post(ACTIVATE_AUTO_CONFIRM, tenantAware.getCurrentTenant(), controllerId) + .content(getMapper().writeValueAsString(body)).contentType(MediaType.APPLICATION_JSON_UTF8)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + assertThat(confirmationManagement.getStatus(controllerId)).hasValueSatisfying(status -> { + assertThat(status.getInitiator()).isEqualTo(initiator); + assertThat(status.getRemark()).isEqualTo(remark); + assertThat(status.getCreatedBy()).isEqualTo("bumlux"); + }); + } + + @Test + @Description("Verify auto-confirm deactivation is handled correctly.") + void deactivateAutoConfirmation() throws Exception { + final String controllerId = testdataFactory.createTarget("988").getControllerId(); + + confirmationManagement.activateAutoConfirmation(controllerId, null, null); + + mvc.perform(post(DEACTIVATE_AUTO_CONFIRM, tenantAware.getCurrentTenant(), controllerId)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + assertThat(confirmationManagement.getStatus(controllerId)).isEmpty(); + } + + @Test + @Description("Controller sends a denied action state.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), + @Expect(type = ActionUpdatedEvent.class, count = 1), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = TargetPollEvent.class, count = 1), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void sendDeniedActionStateFeedbackTest() throws Exception { + enableConfirmationFlow(); + + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("989"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + String controllerId = savedTarget.getControllerId(); + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + + sendConfirmationFeedback(savedTarget, savedAction, DdiConfirmationFeedback.Confirmation.DENIED, 10, + "Action denied message.").andExpect(status().isOk()); + + // asserts that deployment link is not available + final String expectedConfirmationBaseLink = String.format("/%s/controller/v1/%s/confirmationBase/%d", + tenantAware.getCurrentTenant(), controllerId, savedAction.getId()); + + mvc.perform( + get(CONTROLLER_BASE, tenantAware.getCurrentTenant(), controllerId).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$._links.deploymentBase.href").doesNotExist()) + .andExpect(jsonPath("$._links.confirmationBase.href", containsString(expectedConfirmationBaseLink))); + + mvc.perform(get(DEPLOYMENT_BASE, tenantAware.getCurrentTenant(), controllerId, savedAction.getId()) + .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isNotFound()); + } + + private ResultActions sendConfirmationFeedback(final Target target, final Action action, + final DdiConfirmationFeedback.Confirmation confirmation, Integer code, String message) throws Exception { + + if (message == null) { + message = RandomStringUtils.randomAlphanumeric(1000); + } + + final String feedback = getJsonConfirmationFeedback(confirmation, code, Collections.singletonList(message)); + return mvc.perform( + post(CONFIRMATION_FEEDBACK, tenantAware.getCurrentTenant(), target.getControllerId(), action.getId()) + .content(feedback).contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @Description("Test to verify that only a specific count of messages are returned based on the input actionHistory for getControllerDeploymentActionFeedback endpoint.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 1), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3), + @Expect(type = TenantConfigurationCreatedEvent.class, count = 1) }) + void testActionHistoryCount() throws Exception { + enableConfirmationFlow(); + + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("990"); + savedTarget = getFirstAssignedTarget(assignDistributionSet(ds.getId(), savedTarget.getControllerId())); + + String controllerId = savedTarget.getControllerId(); + + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, controllerId).getContent() + .get(0); + final String CONFIRMED_MESSAGE = "Action confirmed message."; + final Integer CONFIRMED_CODE = 10; + sendConfirmationFeedback(savedTarget, savedAction, DdiConfirmationFeedback.Confirmation.CONFIRMED, + CONFIRMED_CODE, CONFIRMED_MESSAGE).andExpect(status().isOk()); + + // confirmationBase not available in RUNNING state anymore + mvc.perform(get(CONFIRMATION_BASE_ACTION, tenantAware.getCurrentTenant(), savedTarget.getControllerId(), + savedAction.getId()).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isNotFound()); + + // assert confirmed message against deploymentBase endpoint + // this call will update the action due to retrieved action status update + mvc.perform( + get(DEPLOYMENT_BASE + "?actionHistory=2", tenantAware.getCurrentTenant(), savedTarget.getControllerId(), + savedAction.getId()).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$.actionHistory.messages", hasItem(containsString(CONFIRMED_MESSAGE)))) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(String.format(CONFIRMATION_CODE_MSG_PREFIX, CONFIRMED_CODE))))); + } + +} diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java index 68c8a2fd6..1ed914f66 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java @@ -26,6 +26,7 @@ public class MgmtTargetAssignmentRequestBody { private MgmtActionType type; private MgmtMaintenanceWindowRequestBody maintenanceWindow; private Integer weight; + private Boolean confirmationRequired; /** * JsonCreator Constructor @@ -77,4 +78,12 @@ public class MgmtTargetAssignmentRequestBody { public void setMaintenanceWindow(final MgmtMaintenanceWindowRequestBody maintenanceWindow) { this.maintenanceWindow = maintenanceWindow; } + + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rollout/MgmtRolloutRestRequestBody.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rollout/MgmtRolloutRestRequestBody.java index 0c98c7561..7ce5ba813 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rollout/MgmtRolloutRestRequestBody.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rollout/MgmtRolloutRestRequestBody.java @@ -9,6 +9,7 @@ package org.eclipse.hawkbit.mgmt.json.model.rollout; import java.util.List; +import java.util.Optional; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; import org.eclipse.hawkbit.mgmt.json.model.rolloutgroup.MgmtRolloutGroup; @@ -38,6 +39,9 @@ public class MgmtRolloutRestRequestBody extends AbstractMgmtRolloutConditionsEnt @JsonProperty(required = false) private Integer weight; + @JsonProperty(required = false) + private Boolean confirmationRequired; + private MgmtActionType type; private List groups; @@ -161,4 +165,21 @@ public class MgmtRolloutRestRequestBody extends AbstractMgmtRolloutConditionsEnt public void setWeight(final Integer weight) { this.weight = weight; } + + /** + * Only considered if confirmation flow active + * + * @return if the confirmation is required for this rollout + */ + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + + /** + * @param confirmationRequired + * if the confirmation is required for this rollout + */ + public void setConfirmationRequired(final boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rolloutgroup/MgmtRolloutGroup.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rolloutgroup/MgmtRolloutGroup.java index 89d1e6084..8a8f48b57 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rolloutgroup/MgmtRolloutGroup.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/rolloutgroup/MgmtRolloutGroup.java @@ -22,6 +22,7 @@ public class MgmtRolloutGroup extends AbstractMgmtRolloutConditionsEntity { private String targetFilterQuery; private Float targetPercentage; + private Boolean confirmationRequired; public String getTargetFilterQuery() { return targetFilterQuery; @@ -38,4 +39,12 @@ public class MgmtRolloutGroup extends AbstractMgmtRolloutConditionsEntity { public void setTargetPercentage(Float targetPercentage) { this.targetPercentage = targetPercentage; } + + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final Boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssignment.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssignment.java index fff7b3b07..e07621393 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssignment.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssignment.java @@ -19,6 +19,8 @@ public class MgmtDistributionSetAssignment extends MgmtId { private long forcetime; @JsonProperty(required = false) private Integer weight; + @JsonProperty(required = false) + private Boolean confirmationRequired; private MgmtActionType type; private MgmtMaintenanceWindowRequestBody maintenanceWindow; @@ -77,4 +79,12 @@ public class MgmtDistributionSetAssignment extends MgmtId { public void setMaintenanceWindow(final MgmtMaintenanceWindowRequestBody maintenanceWindow) { this.maintenanceWindow = maintenanceWindow; } + + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final Boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java index d6c15b31d..d5e1bcb86 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java @@ -55,6 +55,9 @@ public class MgmtTarget extends MgmtNamedEntity { @JsonProperty private String targetTypeName; + @JsonProperty + private Boolean autoConfirmActive; + /** * @return Target type ID */ @@ -221,4 +224,13 @@ public class MgmtTarget extends MgmtNamedEntity { public void setRequestAttributes(final boolean requestAttributes) { this.requestAttributes = requestAttributes; } + + public Boolean getAutoConfirmActive() { + return autoConfirmActive; + } + + @JsonIgnore + public void setAutoConfirmActive(final boolean autoConfirmActive) { + this.autoConfirmActive = autoConfirmActive; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirm.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirm.java new file mode 100644 index 000000000..2dde12c5a --- /dev/null +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirm.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.mgmt.json.model.target; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import org.springframework.hateoas.RepresentationModel; + +import javax.validation.constraints.NotNull; + +/** + * Response representing the current state of auto-confirmation for a specific target + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonPropertyOrder({ "active", "initiator", "remark", "activatedAt" }) +public class MgmtTargetAutoConfirm extends RepresentationModel { + @NotNull + private boolean active; + private String initiator; + private String remark; + private Long activatedAt; + + /** + * Constructor. + */ + public MgmtTargetAutoConfirm() { + // needed for json create. + } + + public static MgmtTargetAutoConfirm active(final long activatedAt) { + final MgmtTargetAutoConfirm state = new MgmtTargetAutoConfirm(); + state.setActive(true); + state.setActivatedAt(activatedAt); + return state; + } + + public static MgmtTargetAutoConfirm disabled() { + return new MgmtTargetAutoConfirm(); + } + + public boolean isActive() { + return active; + } + + public void setActive(final boolean active) { + this.active = active; + } + + public Long getActivatedAt() { + return activatedAt; + } + + public void setActivatedAt(final long activatedAt) { + this.activatedAt = activatedAt; + } + + public String getInitiator() { + return initiator; + } + + public void setInitiator(final String initiator) { + this.initiator = initiator; + } + + public String getRemark() { + return remark; + } + + public void setRemark(final String remark) { + this.remark = remark; + } +} diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirmUpdate.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirmUpdate.java new file mode 100644 index 000000000..a8dd77b08 --- /dev/null +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetAutoConfirmUpdate.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2022 Bosch.IO GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.mgmt.json.model.target; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Payload to activate the auto-confirmation by given initiator and remark. + */ +public class MgmtTargetAutoConfirmUpdate { + @JsonProperty(required = false) + private final String initiator; + + @JsonProperty(required = false) + private final String remark; + + /** + * Constructor. + * + * @param initiator + * can be null + * @param remark + * can be null + */ + @JsonCreator + public MgmtTargetAutoConfirmUpdate(@JsonProperty(value = "initiator") final String initiator, + @JsonProperty(value = "remark") final String remark) { + this.initiator = initiator; + this.remark = remark; + } + + public String getInitiator() { + return initiator; + } + + public String getRemark() { + return remark; + } + + @Override + public String toString() { + return "MgmtTargetAutoConfirm [initiator=" + initiator + ", remark=" + remark + ", toString()=" + + super.toString() + "]"; + } +} diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java index ce643277c..48f2a726e 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java @@ -25,6 +25,9 @@ public class MgmtDistributionSetAutoAssignment extends MgmtId { @JsonProperty(required = false) private Integer weight; + @JsonProperty(required = false) + private Boolean confirmationRequired; + public MgmtActionType getType() { return type; } @@ -40,4 +43,12 @@ public class MgmtDistributionSetAutoAssignment extends MgmtId { public void setWeight(final Integer weight) { this.weight = weight; } + + public Boolean isConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final Boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java index 33077b53f..ecbd728be 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java @@ -43,6 +43,9 @@ public class MgmtTargetFilterQuery extends MgmtBaseEntity { @JsonProperty private Integer autoAssignWeight; + @JsonProperty + private Boolean confirmationRequired; + public Long getFilterId() { return filterId; } @@ -90,4 +93,12 @@ public class MgmtTargetFilterQuery extends MgmtBaseEntity { public void setAutoAssignWeight(final Integer autoAssignWeight) { this.autoAssignWeight = autoAssignWeight; } + + public Boolean getConfirmationRequired() { + return confirmationRequired; + } + + public void setConfirmationRequired(final Boolean confirmationRequired) { + this.confirmationRequired = confirmationRequired; + } } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java index 698180002..af35a48bf 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java @@ -65,6 +65,21 @@ public final class MgmtRestConstants { */ public static final String TARGET_V1_ASSIGNED_TARGET_TYPE = "targetType"; + /** + * The target URL mapping, href link for autoConfirm state of a target. + */ + public static final String TARGET_V1_AUTO_CONFIRM = "autoConfirm"; + + /** + * The target URL mapping, href link activate auto-confirm on a target. + */ + public static final String TARGET_V1_ACTIVATE_AUTO_CONFIRM = "activate"; + + /** + * The target URL mapping, href link deactivate auto-confirm on a target. + */ + public static final String TARGET_V1_DEACTIVATE_AUTO_CONFIRM = "deactivate"; + /** * The target URL mapping, href link for assigned distribution set. */ diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java index dd1c107ba..c88ae33af 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java @@ -22,6 +22,8 @@ import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtTargetAssignmentR import org.eclipse.hawkbit.mgmt.json.model.target.MgmtDistributionSetAssignments; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAttributes; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirm; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirmUpdate; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody; import org.springframework.hateoas.MediaTypes; import org.springframework.http.MediaType; @@ -31,6 +33,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -396,12 +399,47 @@ public interface MgmtTargetRestApi { * the ID of the targetId to create meta data for * @param metadataRest * the list of meta data entries to create - * @return status created if post request is successful with the value of - * the created meta data + * @return status created if post request is successful with the value of the + * created meta data */ @PostMapping(value = "/{targetId}/metadata", consumes = { MediaType.APPLICATION_JSON_VALUE, MediaTypes.HAL_JSON_VALUE }, produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) ResponseEntity> createMetadata(@PathVariable("targetId") String targetId, List metadataRest); + /** + * Get the current auto-confirm state for a specific target. + * + * @param targetId + * to check the state for + * @return the current state as {@link MgmtTargetAutoConfirm} + */ + @GetMapping(value = "/{targetId}/autoConfirm", produces = { MediaTypes.HAL_JSON_VALUE, + MediaType.APPLICATION_JSON_VALUE }) + ResponseEntity getAutoConfirmStatus(@PathVariable("targetId") String targetId); + + /** + * Activate auto-confirm on a specific target. + * + * @param targetId + * to activate auto-confirm on + * @param update + * properties to update + * @return {@link org.springframework.http.HttpStatus#OK} in case of a success + */ + @PostMapping(value = "/{targetId}/autoConfirm/activate") + ResponseEntity activateAutoConfirm(@PathVariable("targetId") String targetId, + @RequestBody(required = false) MgmtTargetAutoConfirmUpdate update); + + /** + * Deactivate auto-confirm on a specific target. + * + * @param targetId + * to deactivate auto-confirm on + * + * @return {@link org.springframework.http.HttpStatus#OK} in case of a success + */ + @PostMapping(value = "/{targetId}/autoConfirm/deactivate") + ResponseEntity deactivateAutoConfirm(@PathVariable("targetId") String targetId); + } diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDeploymentRequestMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDeploymentRequestMapper.java index 108e0e740..cb3c3741c 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDeploymentRequestMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDeploymentRequestMapper.java @@ -14,7 +14,6 @@ import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtTargetAssignmentR import org.eclipse.hawkbit.mgmt.json.model.target.MgmtDistributionSetAssignment; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; -import org.eclipse.hawkbit.repository.model.DeploymentRequest; import org.eclipse.hawkbit.repository.model.DeploymentRequestBuilder; /** @@ -26,38 +25,37 @@ public final class MgmtDeploymentRequestMapper { } /** - * Convert assignment information to an {@link DeploymentRequest} + * Convert assignment information to an {@link DeploymentRequestBuilder} * * @param dsAssignment * DS assignment information * @param targetId * target to assign the DS to - * @return resulting {@link DeploymentRequest} + * @return resulting {@link DeploymentRequestBuilder} */ - public static DeploymentRequest createAssignmentRequest(final MgmtDistributionSetAssignment dsAssignment, - final String targetId) { + public static DeploymentRequestBuilder createAssignmentRequestBuilder( + final MgmtDistributionSetAssignment dsAssignment, final String targetId) { - return createAssignmentRequest(targetId, dsAssignment.getId(), dsAssignment.getType(), + return createAssignmentRequestBuilder(targetId, dsAssignment.getId(), dsAssignment.getType(), dsAssignment.getForcetime(), dsAssignment.getWeight(), dsAssignment.getMaintenanceWindow()); } /** - * Convert assignment information to an {@link DeploymentRequest} + * Convert assignment information to an {@link DeploymentRequestBuilder} * * @param targetAssignment * target assignment information * @param dsId * DS to assign the target to - * @return resulting {@link DeploymentRequest} + * @return resulting {@link DeploymentRequestBuilder} */ - public static DeploymentRequest createAssignmentRequest(final MgmtTargetAssignmentRequestBody targetAssignment, - final Long dsId) { - - return createAssignmentRequest(targetAssignment.getId(), dsId, targetAssignment.getType(), + public static DeploymentRequestBuilder createAssignmentRequestBuilder( + final MgmtTargetAssignmentRequestBody targetAssignment, final Long dsId) { + return createAssignmentRequestBuilder(targetAssignment.getId(), dsId, targetAssignment.getType(), targetAssignment.getForcetime(), targetAssignment.getWeight(), targetAssignment.getMaintenanceWindow()); } - private static DeploymentRequest createAssignmentRequest(final String targetId, final Long dsId, + private static DeploymentRequestBuilder createAssignmentRequestBuilder(final String targetId, final Long dsId, final MgmtActionType type, final long forcetime, final Integer weight, final MgmtMaintenanceWindowRequestBody maintenanceWindow) { final DeploymentRequestBuilder request = DeploymentManagement.deploymentRequest(targetId, dsId) @@ -69,6 +67,6 @@ public final class MgmtDeploymentRequestMapper { MaintenanceScheduleHelper.validateMaintenanceSchedule(cronSchedule, duration, timezone); request.setMaintenance(cronSchedule, duration, timezone); } - return request.build(); + return request; } } diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java index f5f644774..e8acdb84c 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java @@ -41,6 +41,7 @@ import org.eclipse.hawkbit.repository.SoftwareModuleManagement; import org.eclipse.hawkbit.repository.SystemManagement; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.DeploymentRequest; import org.eclipse.hawkbit.repository.model.DistributionSet; @@ -51,6 +52,7 @@ import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -88,13 +90,16 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { private final SystemSecurityContext systemSecurityContext; private final DistributionSetInvalidationManagement distributionSetInvalidationManagement; + + private final TenantConfigHelper tenantConfigHelper; MgmtDistributionSetResource(final SoftwareModuleManagement softwareModuleManagement, final TargetManagement targetManagement, final TargetFilterQueryManagement targetFilterQueryManagement, final DeploymentManagement deployManagament, final SystemManagement systemManagement, final EntityFactory entityFactory, final DistributionSetManagement distributionSetManagement, final SystemSecurityContext systemSecurityContext, - final DistributionSetInvalidationManagement distributionSetInvalidationManagement) { + final DistributionSetInvalidationManagement distributionSetInvalidationManagement, + final TenantConfigurationManagement tenantConfigurationManagement) { this.softwareModuleManagement = softwareModuleManagement; this.targetManagement = targetManagement; this.targetFilterQueryManagement = targetFilterQueryManagement; @@ -104,6 +109,7 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { this.distributionSetManagement = distributionSetManagement; this.systemSecurityContext = systemSecurityContext; this.distributionSetInvalidationManagement = distributionSetInvalidationManagement; + this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement); } @Override @@ -203,8 +209,9 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { targetsAssignedDS = this.targetManagement.findByAssignedDistributionSet(pageable, distributionSetId); } - return ResponseEntity.ok(new PagedList<>(MgmtTargetMapper.toResponse(targetsAssignedDS.getContent()), - targetsAssignedDS.getTotalElements())); + return ResponseEntity + .ok(new PagedList<>(MgmtTargetMapper.toResponse(targetsAssignedDS.getContent(), tenantConfigHelper), + targetsAssignedDS.getTotalElements())); } @Override @@ -231,8 +238,9 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { targetsInstalledDS = this.targetManagement.findByInstalledDistributionSet(pageable, distributionSetId); } - return ResponseEntity.ok(new PagedList<>(MgmtTargetMapper.toResponse(targetsInstalledDS.getContent()), - targetsInstalledDS.getTotalElements())); + return ResponseEntity + .ok(new PagedList<>(MgmtTargetMapper.toResponse(targetsInstalledDS.getContent(), tenantConfigHelper), + targetsInstalledDS.getTotalElements())); } @Override @@ -251,8 +259,8 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { .findByAutoAssignDSAndRsql(pageable, distributionSetId, rsqlParam); return ResponseEntity - .ok(new PagedList<>(MgmtTargetFilterQueryMapper.toResponse(targetFilterQueries.getContent()), - targetFilterQueries.getTotalElements())); + .ok(new PagedList<>(MgmtTargetFilterQueryMapper.toResponse(targetFilterQueries.getContent(), + tenantConfigHelper.isConfirmationFlowEnabled()), targetFilterQueries.getTotalElements())); } @Override @@ -268,9 +276,13 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { .toResponse(deployManagament.offlineAssignedDistributionSets(offlineAssignments))); } - final List deploymentRequests = assignments.stream() - .map(assignment -> MgmtDeploymentRequestMapper.createAssignmentRequest(assignment, distributionSetId)) - .collect(Collectors.toList()); + final List deploymentRequests = assignments.stream().map(dsAssignment -> { + final boolean isConfirmationRequired = dsAssignment.isConfirmationRequired() == null + ? tenantConfigHelper.isConfirmationFlowEnabled() + : dsAssignment.isConfirmationRequired(); + return MgmtDeploymentRequestMapper.createAssignmentRequestBuilder(dsAssignment, distributionSetId) + .setConfirmationRequired(isConfirmationRequired).build(); + }).collect(Collectors.toList()); final List assignmentResults = deployManagament .assignDistributionSets(deploymentRequests); @@ -302,7 +314,6 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { return ResponseEntity .ok(new PagedList<>(MgmtDistributionSetMapper.toResponseDsMetadata(metaDataPage.getContent()), metaDataPage.getTotalElements())); - } @Override diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java index a2bb9f458..c43c51dfb 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java @@ -157,16 +157,18 @@ final class MgmtRolloutMapper { return conditions.build(); } - static List toResponseRolloutGroup(final List rollouts) { + static List toResponseRolloutGroup(final List rollouts, + final boolean confirmationFlowEnabled) { if (rollouts == null) { return Collections.emptyList(); } - return rollouts.stream().map(group -> toResponseRolloutGroup(group, false)).collect(Collectors.toList()); + return rollouts.stream().map(group -> toResponseRolloutGroup(group, false, confirmationFlowEnabled)) + .collect(Collectors.toList()); } static MgmtRolloutGroupResponseBody toResponseRolloutGroup(final RolloutGroup rolloutGroup, - final boolean withDetailedStatus) { + final boolean withDetailedStatus, final boolean confirmationFlowEnabled) { final MgmtRolloutGroupResponseBody body = new MgmtRolloutGroupResponseBody(); body.setCreatedAt(rolloutGroup.getCreatedAt()); body.setCreatedBy(rolloutGroup.getCreatedBy()); @@ -180,6 +182,10 @@ final class MgmtRolloutMapper { body.setTargetFilterQuery(rolloutGroup.getTargetFilterQuery()); body.setTotalTargets(rolloutGroup.getTotalTargets()); + if (confirmationFlowEnabled) { + body.setConfirmationRequired(rolloutGroup.isConfirmationRequired()); + } + body.setSuccessCondition(new MgmtRolloutCondition(map(rolloutGroup.getSuccessCondition()), rolloutGroup.getSuccessConditionExp())); body.setSuccessAction( diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java index 12fabb1af..f39bd29d2 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java @@ -9,6 +9,7 @@ package org.eclipse.hawkbit.mgmt.rest.resource; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import javax.validation.ValidationException; @@ -16,6 +17,7 @@ import javax.validation.ValidationException; import org.eclipse.hawkbit.mgmt.json.model.PagedList; import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutResponseBody; import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBody; +import org.eclipse.hawkbit.mgmt.json.model.rolloutgroup.MgmtRolloutGroup; import org.eclipse.hawkbit.mgmt.json.model.rolloutgroup.MgmtRolloutGroupResponseBody; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRepresentationMode; @@ -27,6 +29,7 @@ import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.RolloutGroupManagement; import org.eclipse.hawkbit.repository.RolloutManagement; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.builder.RolloutCreate; import org.eclipse.hawkbit.repository.builder.RolloutGroupCreate; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; @@ -38,6 +41,8 @@ import org.eclipse.hawkbit.repository.model.RolloutGroupConditions; import org.eclipse.hawkbit.repository.model.Target; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -66,15 +71,19 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { private final TargetFilterQueryManagement targetFilterQueryManagement; private final EntityFactory entityFactory; + private final TenantConfigHelper tenantConfigHelper; MgmtRolloutResource(final RolloutManagement rolloutManagement, final RolloutGroupManagement rolloutGroupManagement, final DistributionSetManagement distributionSetManagement, - final TargetFilterQueryManagement targetFilterQueryManagement, final EntityFactory entityFactory) { + final TargetFilterQueryManagement targetFilterQueryManagement, final EntityFactory entityFactory, + final SystemSecurityContext systemSecurityContext, + final TenantConfigurationManagement tenantConfigurationManagement) { this.rolloutManagement = rolloutManagement; this.rolloutGroupManagement = rolloutGroupManagement; this.distributionSetManagement = distributionSetManagement; this.targetFilterQueryManagement = targetFilterQueryManagement; this.entityFactory = entityFactory; + this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement); } @Override @@ -132,18 +141,26 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { final DistributionSet distributionSet = distributionSetManagement .getValidAndComplete(rolloutRequestBody.getDistributionSetId()); final RolloutGroupConditions rolloutGroupConditions = MgmtRolloutMapper.fromRequest(rolloutRequestBody, true); - final RolloutCreate create = MgmtRolloutMapper.fromRequest(entityFactory, rolloutRequestBody, distributionSet); + final boolean confirmationFlowActive = tenantConfigHelper.isConfirmationFlowEnabled(); Rollout rollout; if (rolloutRequestBody.getGroups() != null) { final List rolloutGroups = rolloutRequestBody.getGroups().stream() - .map(mgmtRolloutGroup -> MgmtRolloutMapper.fromRequest(entityFactory, mgmtRolloutGroup)) - .collect(Collectors.toList()); + .map(mgmtRolloutGroup -> { + final boolean confirmationRequired = isConfirmationRequiredForGroup(mgmtRolloutGroup, + rolloutRequestBody).orElse(confirmationFlowActive); + return MgmtRolloutMapper.fromRequest(entityFactory, mgmtRolloutGroup) + .confirmationRequired(confirmationRequired); + }).collect(Collectors.toList()); rollout = rolloutManagement.create(create, rolloutGroups, rolloutGroupConditions); } else if (rolloutRequestBody.getAmountGroups() != null) { - rollout = rolloutManagement.create(create, rolloutRequestBody.getAmountGroups(), rolloutGroupConditions); + final boolean confirmationRequired = rolloutRequestBody.isConfirmationRequired() == null + ? confirmationFlowActive + : rolloutRequestBody.isConfirmationRequired(); + rollout = rolloutManagement.create(create, rolloutRequestBody.getAmountGroups(), confirmationRequired, + rolloutGroupConditions); } else { throw new ValidationException("Either 'amountGroups' or 'groups' must be defined in the request"); @@ -152,6 +169,16 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { return ResponseEntity.status(HttpStatus.CREATED).body(MgmtRolloutMapper.toResponseRollout(rollout, true)); } + private Optional isConfirmationRequiredForGroup(final MgmtRolloutGroup group, + final MgmtRolloutRestRequestBody request) { + if (group.isConfirmationRequired() != null) { + return Optional.of(group.isConfirmationRequired()); + } else if (request.isConfirmationRequired() != null) { + return Optional.of(request.isConfirmationRequired()); + } + return Optional.empty(); + } + @Override public ResponseEntity approve(@PathVariable("rolloutId") final Long rolloutId, final String remark) { rolloutManagement.approveOrDeny(rolloutId, Rollout.ApprovalDecision.APPROVED, remark); @@ -209,7 +236,7 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { } final List rest = MgmtRolloutMapper - .toResponseRolloutGroup(findRolloutGroupsAll.getContent()); + .toResponseRolloutGroup(findRolloutGroupsAll.getContent(), tenantConfigHelper.isConfirmationFlowEnabled()); return ResponseEntity.ok(new PagedList<>(rest, findRolloutGroupsAll.getTotalElements())); } @@ -220,7 +247,8 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { final RolloutGroup rolloutGroup = rolloutGroupManagement.getWithDetailedStatus(groupId) .orElseThrow(() -> new EntityNotFoundException(RolloutGroup.class, rolloutId)); - return ResponseEntity.ok(MgmtRolloutMapper.toResponseRolloutGroup(rolloutGroup, true)); + return ResponseEntity.ok(MgmtRolloutMapper.toResponseRolloutGroup(rolloutGroup, true, + tenantConfigHelper.isConfirmationFlowEnabled())); } private void findRolloutOrThrowException(final Long rolloutId) { @@ -251,7 +279,7 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { final Page pageTargets = this.rolloutGroupManagement.findTargetsOfRolloutGroup(pageable, groupId); rolloutGroupTargets = pageTargets; } - final List rest = MgmtTargetMapper.toResponse(rolloutGroupTargets.getContent()); + final List rest = MgmtTargetMapper.toResponse(rolloutGroupTargets.getContent(), tenantConfigHelper); return ResponseEntity.ok(new PagedList<>(rest, rolloutGroupTargets.getTotalElements())); } diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java index 446458cd5..d7ebd4203 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java @@ -38,14 +38,15 @@ public final class MgmtTargetFilterQueryMapper { // Utility class } - static List toResponse(final List filters) { + static List toResponse(final List filters, + final boolean confirmationFlowEnabled) { if (CollectionUtils.isEmpty(filters)) { return Collections.emptyList(); } - return filters.stream().map(MgmtTargetFilterQueryMapper::toResponse).collect(Collectors.toList()); + return filters.stream().map(filter -> toResponse(filter, confirmationFlowEnabled)).collect(Collectors.toList()); } - static MgmtTargetFilterQuery toResponse(final TargetFilterQuery filter) { + static MgmtTargetFilterQuery toResponse(final TargetFilterQuery filter, final boolean confirmationFlowEnabled) { final MgmtTargetFilterQuery targetRest = new MgmtTargetFilterQuery(); targetRest.setFilterId(filter.getId()); targetRest.setName(filter.getName()); @@ -62,6 +63,9 @@ public final class MgmtTargetFilterQueryMapper { targetRest.setAutoAssignDistributionSet(distributionSet.getId()); targetRest.setAutoAssignActionType(MgmtRestModelMapper.convertActionType(filter.getAutoAssignActionType())); filter.getAutoAssignWeight().ifPresent(targetRest::setAutoAssignWeight); + if (confirmationFlowEnabled) { + targetRest.setConfirmationRequired(filter.isConfirmationRequired()); + } } targetRest.add(linkTo(methodOn(MgmtTargetFilterQueryRestApi.class).getFilter(filter.getId())).withSelfRel()); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java index 082b19fff..ce4fb3f04 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java @@ -20,9 +20,13 @@ import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetFilterQueryRestApi; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; +import org.eclipse.hawkbit.repository.builder.AutoAssignDistributionSetUpdate; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; +import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -46,18 +50,23 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA private final TargetFilterQueryManagement filterManagement; private final EntityFactory entityFactory; + + private final TenantConfigHelper tenantConfigHelper; - MgmtTargetFilterQueryResource(final TargetFilterQueryManagement filterManagement, - final EntityFactory entityFactory) { + MgmtTargetFilterQueryResource(final TargetFilterQueryManagement filterManagement, final EntityFactory entityFactory, + final SystemSecurityContext systemSecurityContext, + final TenantConfigurationManagement tenantConfigurationManagement) { this.filterManagement = filterManagement; this.entityFactory = entityFactory; + this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement); } @Override public ResponseEntity getFilter(@PathVariable("filterId") final Long filterId) { final TargetFilterQuery findTarget = findFilterWithExceptionIfNotFound(filterId); // to single response include poll status - final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(findTarget); + final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(findTarget, + tenantConfigHelper.isConfirmationFlowEnabled()); MgmtTargetFilterQueryMapper.addLinks(response); return ResponseEntity.ok(response); @@ -87,7 +96,7 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA } final List rest = MgmtTargetFilterQueryMapper - .toResponse(findTargetFiltersAll.getContent()); + .toResponse(findTargetFiltersAll.getContent(), tenantConfigHelper.isConfirmationFlowEnabled()); return ResponseEntity.ok(new PagedList<>(rest, countTargetsAll)); } @@ -97,7 +106,8 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA final TargetFilterQuery createdTarget = filterManagement .create(MgmtTargetFilterQueryMapper.fromRequest(entityFactory, filter)); - final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(createdTarget); + final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(createdTarget, + tenantConfigHelper.isConfirmationFlowEnabled()); MgmtTargetFilterQueryMapper.addLinks(response); return new ResponseEntity<>(response, HttpStatus.CREATED); @@ -112,7 +122,8 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA .update(entityFactory.targetFilterQuery().update(filterId).name(targetFilterRest.getName()) .query(targetFilterRest.getQuery())); - final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(updateFilter); + final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(updateFilter, + tenantConfigHelper.isConfirmationFlowEnabled()); MgmtTargetFilterQueryMapper.addLinks(response); return ResponseEntity.ok(response); @@ -130,10 +141,17 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA @PathVariable("filterId") final Long filterId, @RequestBody final MgmtDistributionSetAutoAssignment autoAssignRequest) { - final TargetFilterQuery updateFilter = filterManagement.updateAutoAssignDS( - MgmtTargetFilterQueryMapper.fromRequest(entityFactory, filterId, autoAssignRequest)); + final boolean confirmationRequired = autoAssignRequest.isConfirmationRequired() == null + ? tenantConfigHelper.isConfirmationFlowEnabled() + : autoAssignRequest.isConfirmationRequired(); - final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(updateFilter); + final AutoAssignDistributionSetUpdate update = MgmtTargetFilterQueryMapper + .fromRequest(entityFactory, filterId, autoAssignRequest).confirmationRequired(confirmationRequired); + + final TargetFilterQuery updateFilter = filterManagement.updateAutoAssignDS(update); + + final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(updateFilter, + tenantConfigHelper.isConfirmationFlowEnabled()); MgmtTargetFilterQueryMapper.addLinks(response); return ResponseEntity.ok(response); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetMapper.java index 75c7b851f..def5ca4ea 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetMapper.java @@ -25,6 +25,7 @@ import org.eclipse.hawkbit.mgmt.json.model.MgmtPollStatus; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionStatus; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirm; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody; import org.eclipse.hawkbit.mgmt.rest.api.MgmtDistributionSetRestApi; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; @@ -40,6 +41,7 @@ import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.ActionStatus; import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.AutoConfirmationStatus; import org.eclipse.hawkbit.repository.model.MetaData; import org.eclipse.hawkbit.repository.model.PollStatus; import org.eclipse.hawkbit.repository.model.Rollout; @@ -48,6 +50,7 @@ import org.eclipse.hawkbit.repository.model.TargetMetadata; import org.eclipse.hawkbit.rest.data.ResponseList; import org.eclipse.hawkbit.rest.data.SortDirection; import org.eclipse.hawkbit.util.IpUtil; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.springframework.data.domain.PageRequest; /** @@ -85,6 +88,27 @@ public final class MgmtTargetMapper { response.add(linkTo(methodOn(MgmtTargetTypeRestApi.class).getTargetType(response.getTargetType())) .withRel(MgmtRestConstants.TARGET_V1_ASSIGNED_TARGET_TYPE)); } + if (response.getAutoConfirmActive() != null) { + response.add(linkTo(methodOn(MgmtTargetRestApi.class).getAutoConfirmStatus(response.getControllerId())) + .withRel(MgmtRestConstants.TARGET_V1_AUTO_CONFIRM)); + } + } + + public static MgmtTargetAutoConfirm getTargetAutoConfirmResponse(final Target target) { + final AutoConfirmationStatus status = target.getAutoConfirmationStatus(); + final MgmtTargetAutoConfirm response; + if (status != null) { + response = MgmtTargetAutoConfirm.active(status.getActivatedAt()); + response.setInitiator(status.getInitiator()); + response.setRemark(status.getRemark()); + response.add(linkTo(methodOn(MgmtTargetRestApi.class).deactivateAutoConfirm(target.getControllerId())) + .withRel(MgmtRestConstants.TARGET_V1_DEACTIVATE_AUTO_CONFIRM)); + } else { + response = MgmtTargetAutoConfirm.disabled(); + response.add(linkTo(methodOn(MgmtTargetRestApi.class).activateAutoConfirm(target.getControllerId(), null)) + .withRel(MgmtRestConstants.TARGET_V1_ACTIVATE_AUTO_CONFIRM)); + } + return response; } static void addPollStatus(final Target target, final MgmtTarget targetRest) { @@ -107,12 +131,13 @@ public final class MgmtTargetMapper { * list of targets * @return the response */ - public static List toResponse(final Collection targets) { + public static List toResponse(final Collection targets, final TenantConfigHelper configHelper) { if (targets == null) { return Collections.emptyList(); } - return new ResponseList<>(targets.stream().map(MgmtTargetMapper::toResponse).collect(Collectors.toList())); + return new ResponseList<>( + targets.stream().map(target -> toResponse(target, configHelper)).collect(Collectors.toList())); } /** @@ -122,7 +147,7 @@ public final class MgmtTargetMapper { * the target * @return the response */ - public static MgmtTarget toResponse(final Target target) { + public static MgmtTarget toResponse(final Target target, final TenantConfigHelper configHelper) { if (target == null) { return null; } @@ -163,6 +188,9 @@ public final class MgmtTargetMapper { targetRest.setTargetType(target.getTargetType().getId()); targetRest.setTargetTypeName(target.getTargetType().getName()); } + if (configHelper.isConfirmationFlowEnabled()) { + targetRest.setAutoConfirmActive(target.getAutoConfirmationStatus() != null); + } targetRest.add(linkTo(methodOn(MgmtTargetRestApi.class).getTarget(target.getControllerId())).withSelfRel()); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java index 19a37ec5a..2363661a5 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java @@ -13,6 +13,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.function.Function; import java.util.stream.Collectors; import javax.validation.Valid; @@ -31,13 +32,17 @@ import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtTargetAssignmentR import org.eclipse.hawkbit.mgmt.json.model.target.MgmtDistributionSetAssignments; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAttributes; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirm; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirmUpdate; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi; +import org.eclipse.hawkbit.repository.ConfirmationManagement; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.ActionStatus; @@ -45,6 +50,8 @@ import org.eclipse.hawkbit.repository.model.DeploymentRequest; import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetMetadata; +import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -69,22 +76,30 @@ public class MgmtTargetResource implements MgmtTargetRestApi { private final TargetManagement targetManagement; + private final ConfirmationManagement confirmationManagement; + private final DeploymentManagement deploymentManagement; private final EntityFactory entityFactory; + private final TenantConfigHelper tenantConfigHelper; + MgmtTargetResource(final TargetManagement targetManagement, final DeploymentManagement deploymentManagement, - final EntityFactory entityFactory) { + final ConfirmationManagement confirmationManagement, final EntityFactory entityFactory, + final SystemSecurityContext systemSecurityContext, + final TenantConfigurationManagement tenantConfigurationManagement) { this.targetManagement = targetManagement; this.deploymentManagement = deploymentManagement; + this.confirmationManagement = confirmationManagement; this.entityFactory = entityFactory; + this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement); } @Override public ResponseEntity getTarget(@PathVariable("targetId") final String targetId) { final Target findTarget = findTargetWithExceptionIfNotFound(targetId); // to single response include poll status - final MgmtTarget response = MgmtTargetMapper.toResponse(findTarget); + final MgmtTarget response = MgmtTargetMapper.toResponse(findTarget, tenantConfigHelper); MgmtTargetMapper.addPollStatus(findTarget, response); MgmtTargetMapper.addTargetLinks(response); @@ -113,7 +128,7 @@ public class MgmtTargetResource implements MgmtTargetRestApi { countTargetsAll = targetManagement.count(); } - final List rest = MgmtTargetMapper.toResponse(findTargetsAll.getContent()); + final List rest = MgmtTargetMapper.toResponse(findTargetsAll.getContent(), tenantConfigHelper); return ResponseEntity.ok(new PagedList<>(rest, countTargetsAll)); } @@ -123,7 +138,8 @@ public class MgmtTargetResource implements MgmtTargetRestApi { final Collection createdTargets = this.targetManagement .create(MgmtTargetMapper.fromRequest(entityFactory, targets)); LOG.debug("{} targets created, return status {}", targets.size(), HttpStatus.CREATED); - return new ResponseEntity<>(MgmtTargetMapper.toResponse(createdTargets), HttpStatus.CREATED); + return new ResponseEntity<>(MgmtTargetMapper.toResponse(createdTargets, tenantConfigHelper), + HttpStatus.CREATED); } @Override @@ -143,7 +159,7 @@ public class MgmtTargetResource implements MgmtTargetRestApi { .targetType(targetRest.getTargetType()).securityToken(targetRest.getSecurityToken()) .requestAttributes(targetRest.isRequestAttributes())); - final MgmtTarget response = MgmtTargetMapper.toResponse(updateTarget); + final MgmtTarget response = MgmtTargetMapper.toResponse(updateTarget, tenantConfigHelper); MgmtTargetMapper.addPollStatus(updateTarget, response); MgmtTargetMapper.addTargetLinks(response); @@ -309,9 +325,13 @@ public class MgmtTargetResource implements MgmtTargetRestApi { } findTargetWithExceptionIfNotFound(targetId); - final List deploymentRequests = dsAssignments.stream() - .map(dsAssignment -> MgmtDeploymentRequestMapper.createAssignmentRequest(dsAssignment, targetId)) - .collect(Collectors.toList()); + final List deploymentRequests = dsAssignments.stream().map(dsAssignment -> { + final boolean isConfirmationRequired = dsAssignment.isConfirmationRequired() == null + ? tenantConfigHelper.isConfirmationFlowEnabled() + : dsAssignment.isConfirmationRequired(); + return MgmtDeploymentRequestMapper.createAssignmentRequestBuilder(dsAssignment, targetId) + .setConfirmationRequired(isConfirmationRequired).build(); + }).collect(Collectors.toList()); final List assignmentResults = deploymentManagement .assignDistributionSets(deploymentRequests); @@ -416,4 +436,30 @@ public class MgmtTargetResource implements MgmtTargetRestApi { return new ResponseEntity<>(MgmtTargetMapper.toResponseTargetMetadata(created), HttpStatus.CREATED); } + @Override + public ResponseEntity getAutoConfirmStatus(@PathVariable("targetId") final String targetId) { + final Target findTarget = findTargetWithExceptionIfNotFound(targetId); + final MgmtTargetAutoConfirm state = MgmtTargetMapper.getTargetAutoConfirmResponse(findTarget); + return ResponseEntity.ok(state); + } + + @Override + public ResponseEntity activateAutoConfirm(@PathVariable("targetId") final String targetId, + @RequestBody(required = false) final MgmtTargetAutoConfirmUpdate update) { + final String initiator = getNullIfEmpty(update, MgmtTargetAutoConfirmUpdate::getInitiator); + final String remark = getNullIfEmpty(update, MgmtTargetAutoConfirmUpdate::getRemark); + confirmationManagement.activateAutoConfirmation(targetId, initiator, remark); + return new ResponseEntity<>(HttpStatus.OK); + } + + private R getNullIfEmpty(final T object, final Function extractMethod) { + return object == null ? null : extractMethod.apply(object); + } + + @Override + public ResponseEntity deactivateAutoConfirm(@PathVariable("targetId") final String targetId) { + confirmationManagement.deactivateAutoConfirmation(targetId); + return new ResponseEntity<>(HttpStatus.OK); + } + } diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResource.java index 17b4f3ea0..cdf8cd442 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetTagResource.java @@ -23,10 +23,13 @@ import org.eclipse.hawkbit.repository.EntityFactory; import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.TargetTagManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetTag; import org.eclipse.hawkbit.repository.model.TargetTagAssignmentResult; +import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -54,11 +57,15 @@ public class MgmtTargetTagResource implements MgmtTargetTagRestApi { private final EntityFactory entityFactory; + private final TenantConfigHelper tenantConfigHelper; + MgmtTargetTagResource(final TargetTagManagement tagManagement, final TargetManagement targetManagement, - final EntityFactory entityFactory) { + final EntityFactory entityFactory, final SystemSecurityContext securityContext, + final TenantConfigurationManagement configurationManagement) { this.tagManagement = tagManagement; this.targetManagement = targetManagement; this.entityFactory = entityFactory; + this.tenantConfigHelper = TenantConfigHelper.usingContext(securityContext, configurationManagement); } @Override @@ -149,7 +156,7 @@ public class MgmtTargetTagResource implements MgmtTargetTagRestApi { findTargetsAll = targetManagement.findByRsqlAndTag(pageable, rsqlParam, targetTagId); } - final List rest = MgmtTargetMapper.toResponse(findTargetsAll.getContent()); + final List rest = MgmtTargetMapper.toResponse(findTargetsAll.getContent(), tenantConfigHelper); return ResponseEntity.ok(new PagedList<>(rest, findTargetsAll.getTotalElements())); } @@ -164,8 +171,10 @@ public class MgmtTargetTagResource implements MgmtTargetTagRestApi { .toggleTagAssignment(findTargetControllerIds(assignedTargetRequestBodies), targetTag.getName()); final MgmtTargetTagAssigmentResult tagAssigmentResultRest = new MgmtTargetTagAssigmentResult(); - tagAssigmentResultRest.setAssignedTargets(MgmtTargetMapper.toResponse(assigmentResult.getAssignedEntity())); - tagAssigmentResultRest.setUnassignedTargets(MgmtTargetMapper.toResponse(assigmentResult.getUnassignedEntity())); + tagAssigmentResultRest.setAssignedTargets( + MgmtTargetMapper.toResponse(assigmentResult.getAssignedEntity(), tenantConfigHelper)); + tagAssigmentResultRest.setUnassignedTargets( + MgmtTargetMapper.toResponse(assigmentResult.getUnassignedEntity(), tenantConfigHelper)); return ResponseEntity.ok(tagAssigmentResultRest); } @@ -175,7 +184,7 @@ public class MgmtTargetTagResource implements MgmtTargetTagRestApi { LOG.debug("Assign Targets {} for target tag {}", assignedTargetRequestBodies.size(), targetTagId); final List assignedTarget = this.targetManagement .assignTag(findTargetControllerIds(assignedTargetRequestBodies), targetTagId); - return ResponseEntity.ok(MgmtTargetMapper.toResponse(assignedTarget)); + return ResponseEntity.ok(MgmtTargetMapper.toResponse(assignedTarget, tenantConfigHelper)); } @Override diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java index ace193371..3a0663854 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.hawkbit.exception.SpServerError; @@ -54,6 +55,9 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; @@ -143,7 +147,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr // create Targets final String[] knownTargetIds = new String[] { "1", "2" }; - final JSONArray list = createTargetAndJsonArray(null, null, null, null, knownTargetIds); + final JSONArray list = createTargetAndJsonArray(null, null, null, null, null, knownTargetIds); // assign DisSet to target and test assignment assignDistributionSet(disSet.getId(), knownTargetIds[0]); mvc.perform( @@ -257,7 +261,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; - final JSONArray list = createTargetAndJsonArray(null, null, null, null, knownTargetIds); + final JSONArray list = createTargetAndJsonArray(null, null, null, null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0]); @@ -378,7 +382,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr final DistributionSet createdDs = testdataFactory.createDistributionSet(); // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; - final JSONArray list = createTargetAndJsonArray(getTestSchedule(0), null, null, null, knownTargetIds); + final JSONArray list = createTargetAndJsonArray(getTestSchedule(0), null, null, null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0]); @@ -395,7 +399,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr final DistributionSet createdDs = testdataFactory.createDistributionSet(); // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; - final JSONArray list = createTargetAndJsonArray(null, getTestDuration(10), null, null, knownTargetIds); + final JSONArray list = createTargetAndJsonArray(null, getTestDuration(10), null, null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0]); @@ -413,7 +417,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; final JSONArray list = createTargetAndJsonArray(getTestSchedule(10), getTestDuration(10), getTestTimeZone(), - null, knownTargetIds); + null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0]); @@ -431,7 +435,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; final JSONArray list = createTargetAndJsonArray(getTestSchedule(-30), getTestDuration(5), getTestTimeZone(), - null, knownTargetIds); + null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0]); @@ -474,7 +478,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr final DistributionSet createdDs = testdataFactory.createDistributionSet(); final String[] knownTargetIds = new String[] { "1", "2", "3" }; - final JSONArray assignTargetJson = createTargetAndJsonArray(null, null, null, "forced", knownTargetIds); + final JSONArray assignTargetJson = createTargetAndJsonArray(null, null, null, "forced", null, knownTargetIds); assignDistributionSet(createdDs.getId(), knownTargetIds[0]); assignTargetJson.put(new JSONObject().put("id", "notexistingtarget").put("type", "forced")); @@ -487,7 +491,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr } private JSONArray createTargetAndJsonArray(final String schedule, final String duration, final String timezone, - final String type, final String... targetIds) throws Exception { + final String type, final Boolean confirmationRequired, final String... targetIds) throws Exception { final JSONArray result = new JSONArray(); for (final String targetId : targetIds) { testdataFactory.createTarget(targetId); @@ -501,7 +505,6 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr if (schedule != null || duration != null || timezone != null) { final JSONObject maintenanceJsonObject = new JSONObject(); - targetJsonObject.put("maintenanceWindow", maintenanceJsonObject); if (schedule != null) { maintenanceJsonObject.put("schedule", schedule); } @@ -511,6 +514,10 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr if (timezone != null) { maintenanceJsonObject.put("timezone", timezone); } + targetJsonObject.put("maintenanceWindow", maintenanceJsonObject); + } + if (confirmationRequired != null) { + targetJsonObject.put("confirmationRequired", confirmationRequired); } } return result; @@ -1263,7 +1270,7 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr // prepare targets final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; - final JSONArray list = createTargetAndJsonArray(null, null, null, null, knownTargetIds); + final JSONArray list = createTargetAndJsonArray(null, null, null, null, null, knownTargetIds); // assign already one target to DS assignDistributionSet(createdDs.getId(), knownTargetIds[0], Action.ActionType.DOWNLOAD_ONLY); @@ -1277,6 +1284,41 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr .as("Five targets in repository have DS assigned").hasSize(5); } + @ParameterizedTest + @MethodSource("confirmationOptions") + @Description("Ensures that confirmation option is considered in assignment request.") + public void assignTargetsToDistributionSetWithConfirmationOptions(final boolean confirmationFlowActive, + final Boolean confirmationRequired) throws Exception { + final DistributionSet createdDs = testdataFactory.createDistributionSet(); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + // prepare targets + final String targetId = "1"; + final JSONArray list = createTargetAndJsonArray(null, null, null, null, confirmationRequired, targetId); + + mvc.perform(post("/rest/v1/distributionsets/{ds}/assignedTargets", createdDs.getId()) + .contentType(MediaType.APPLICATION_JSON).content(list.toString())).andExpect(status().isOk()) + .andExpect(jsonPath("$.assigned", equalTo(1))).andExpect(jsonPath("$.alreadyAssigned", equalTo(0))) + .andExpect(jsonPath("$.total", equalTo(1))); + + assertThat(deploymentManagement.findActionsByDistributionSet(PAGE, createdDs.getId()).getContent()).hasSize(1) + .allMatch(action -> { + if (!confirmationFlowActive) { + return !action.isWaitingConfirmation(); + } + return confirmationRequired == null ? action.isWaitingConfirmation() + : confirmationRequired == action.isWaitingConfirmation(); + }); + } + + private static Stream confirmationOptions() { + return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true), + Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null)); + } + @Test @Description("A request for assigning a target multiple times results in a Bad Request when multiassignment is disabled.") public void multiassignmentRequestNotAllowedIfDisabled() throws Exception { diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java index 61f888fa2..b09c8c761 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.stream.Stream; import java.util.stream.Collectors; import org.awaitility.Awaitility; @@ -52,6 +53,10 @@ import org.eclipse.hawkbit.repository.test.util.WithUser; import org.eclipse.hawkbit.rest.util.JsonBuilder; import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort.Direction; @@ -209,7 +214,7 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final RolloutGroupConditions rolloutGroupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); mvc.perform(post("/rest/v1/rollouts") - .content(JsonBuilder.rollout("rollout2", "desc", null, dsA.getId(), "id==ro-target*", + .content(JsonBuilder.rolloutWithGroups("rollout2", "desc", null, dsA.getId(), "id==ro-target*", rolloutGroupConditions, rolloutGroups)) .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()).andReturn(); @@ -239,7 +244,7 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final RolloutGroupConditions rolloutGroupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); mvc.perform(post("/rest/v1/rollouts") - .content(JsonBuilder.rollout("rollout4", "desc", null, dsA.getId(), "id==ro-target*", + .content(JsonBuilder.rolloutWithGroups("rollout4", "desc", null, dsA.getId(), "id==ro-target*", rolloutGroupConditions, rolloutGroups)) .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()) @@ -270,7 +275,7 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final RolloutGroupConditions rolloutGroupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); mvc.perform(post("/rest/v1/rollouts") - .content(JsonBuilder.rollout("rollout4", "desc", null, dsA.getId(), "id==ro-target*", + .content(JsonBuilder.rolloutWithGroups("rollout4", "desc", null, dsA.getId(), "id==ro-target*", rolloutGroupConditions, rolloutGroups)) .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()) @@ -296,13 +301,11 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final DistributionSet dsA = testdataFactory.createDistributionSet(""); // create rollout including the created targets with prefix 'rollout' - final Rollout rollout = rolloutManagement.create(entityFactory.rollout() - .create() - .name("rollout1") - .set(dsA.getId()) - .targetFilterQuery("controllerId==rollout*"), 4, new RolloutGroupConditionBuilder().withDefaults() - .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100") - .build()); + final Rollout rollout = rolloutManagement.create( + entityFactory.rollout().create().name("rollout1").set(dsA.getId()) + .targetFilterQuery("controllerId==rollout*"), + 4, false, new RolloutGroupConditionBuilder().withDefaults() + .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); retrieveAndVerifyRolloutInCreating(dsA, rollout); retrieveAndVerifyRolloutInReady(rollout); @@ -310,6 +313,134 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { retrieveAndVerifyRolloutInRunning(rollout); } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @Description("Verify the confirmation required flag is not part of the rollout parent entity") + void verifyConfirmationFlagIsNeverPartOfRolloutEntity(final boolean confirmationFlowActive) throws Exception { + testdataFactory.createTargets(20, "rollout", "rollout"); + final DistributionSet dsA = testdataFactory.createDistributionSet(""); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + // create rollout including the created targets with prefix 'rollout' + final Rollout rollout = rolloutManagement.create( + entityFactory.rollout().create().name("rollout1").set(dsA.getId()) + .targetFilterQuery("controllerId==rollout*"), + 4, false, new RolloutGroupConditionBuilder().withDefaults() + .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); + + mvc.perform(get("/rest/v1/rollouts/" + rollout.getId()).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.id", equalTo(rollout.getId().intValue()))) + .andExpect(jsonPath("$.confirmationRequired").doesNotExist()); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @Description("Verify the confirmation required flag will be set based on the feature state") + void verifyConfirmationStateIfNotProvided(final boolean confirmationFlowActive) throws Exception { + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + testdataFactory.createTargets(20, "target", "rollout"); + + final DistributionSet dsA = testdataFactory.createDistributionSet(""); + postRollout("rollout1", 5, dsA.getId(), "id==target*", 20, Action.ActionType.FORCED); + + final List content = rolloutManagement.findAll(PAGE, false).getContent(); + assertThat(content).hasSizeGreaterThan(0).allSatisfy(rollout -> { + assertThat(rolloutGroupManagement.findByRollout(PAGE, rollout.getId())) + .describedAs("Confirmation required flag depends on feature active.") + .allMatch(group -> group.isConfirmationRequired() == confirmationFlowActive); + }); + } + + @Test + @Description("Confirmation required flag will be read from the Rollout, if specified.") + void verifyRolloutGroupWillUseRolloutPropertyFirst() throws Exception { + enableConfirmationFlow(); + + final DistributionSet dsA = testdataFactory.createDistributionSet("ro"); + + final int amountTargets = 10; + testdataFactory.createTargets(amountTargets, "ro-target", "rollout"); + + final float percentTargetsInGroup1 = 20; + final float percentTargetsInGroup2 = 100; + + final RolloutGroupConditions rolloutGroupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); + + final List rolloutGroups = Arrays.asList( + JsonBuilder.rolloutGroup("Group1", "Group1desc", null, percentTargetsInGroup1, true, + rolloutGroupConditions), + JsonBuilder.rolloutGroup("Group2", "Group1desc", null, percentTargetsInGroup2, null, + rolloutGroupConditions)); + + mvc.perform(post("/rest/v1/rollouts") + .content(JsonBuilder.rollout("rollout2", "desc", null, dsA.getId(), "id==ro-target*", + rolloutGroupConditions, rolloutGroups, null, null, false)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()).andReturn(); + + final List content = rolloutManagement.findAll(PAGE, false).getContent(); + assertThat(content).hasSize(1).allSatisfy(rollout -> { + final List groups = rolloutGroupManagement.findByRollout(PAGE, rollout.getId()).getContent(); + assertThat(groups).hasSize(2).allMatch(group -> { + if (group.getName().equals("Group1")) { + return group.isConfirmationRequired(); + } else if (group.getName().equals("Group2")) { + return !group.isConfirmationRequired(); + } + return false; + }); + }); + } + + @Test + @Description("Confirmation required flag will be read from the tenant config (confirmation flow state), if never specified.") + void verifyRolloutGroupWillUseConfigIfNotProvidedWithRollout() throws Exception { + enableConfirmationFlow(); + + final DistributionSet dsA = testdataFactory.createDistributionSet("ro"); + + final int amountTargets = 10; + testdataFactory.createTargets(amountTargets, "ro-target", "rollout"); + + final float percentTargetsInGroup1 = 20; + final float percentTargetsInGroup2 = 100; + + final RolloutGroupConditions rolloutGroupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); + + final List rolloutGroups = Arrays.asList( + JsonBuilder.rolloutGroup("Group1", "Group1desc", null, percentTargetsInGroup1, false, + rolloutGroupConditions), + JsonBuilder.rolloutGroup("Group2", "Group1desc", null, percentTargetsInGroup2, null, + rolloutGroupConditions)); + + mvc.perform(post("/rest/v1/rollouts") + .content(JsonBuilder.rollout("rollout2", "desc", null, dsA.getId(), "id==ro-target*", + rolloutGroupConditions, rolloutGroups, null, null, null)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()).andReturn(); + + final List content = rolloutManagement.findAll(PAGE, false).getContent(); + assertThat(content).hasSize(1).allSatisfy(rollout -> { + final List groups = rolloutGroupManagement.findByRollout(PAGE, rollout.getId()).getContent(); + assertThat(groups).hasSize(2).allMatch(group -> { + if (group.getName().equals("Group1")) { + return !group.isConfirmationRequired(); + } else if (group.getName().equals("Group2")) { + return group.isConfirmationRequired(); + } + return false; + }); + }); + } + @Step private void retrieveAndVerifyRolloutInRunning(final Rollout rollout) throws Exception { rolloutManagement.handleRollouts(); @@ -459,16 +590,22 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.content", hasSize(1))).andExpect(jsonPath("$.total", equalTo(2))); } - @Test + @ParameterizedTest + @MethodSource("confirmationOptions") @Description("Testing that rollout paged list is limited by the query param limit") - void retrieveRolloutGroupsForSpecificRollout() throws Exception { + void retrieveRolloutGroupsForSpecificRollout(final boolean confirmationFlowEnabled, final boolean confirmationRequired) throws Exception { // setup final int amountTargets = 20; testdataFactory.createTargets(amountTargets, "rollout", "rollout"); final DistributionSet dsA = testdataFactory.createDistributionSet(""); + if (confirmationFlowEnabled) { + enableConfirmationFlow(); + } + // create rollout including the created targets with prefix 'rollout' - final Rollout rollout = createRollout("rollout1", 4, dsA.getId(), "controllerId==rollout*"); + final Rollout rollout = createRollout("rollout1", 4, dsA.getId(), "controllerId==rollout*", + confirmationRequired); // retrieve rollout groups from created rollout mvc.perform( @@ -479,7 +616,19 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.content[0].status", equalTo("ready"))) .andExpect(jsonPath("$.content[1].status", equalTo("ready"))) .andExpect(jsonPath("$.content[2].status", equalTo("ready"))) - .andExpect(jsonPath("$.content[3].status", equalTo("ready"))); + .andExpect(jsonPath("$.content[3].status", equalTo("ready"))) + .andExpect(isConfirmationFlowEnabled() + ? jsonPath("$.content[0].confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()) + .andExpect(isConfirmationFlowEnabled() + ? jsonPath("$.content[1].confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()) + .andExpect(isConfirmationFlowEnabled() + ? jsonPath("$.content[2].confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()) + .andExpect(isConfirmationFlowEnabled() + ? jsonPath("$.content[3].confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()); } @Test @@ -648,20 +797,25 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.content[1].status", equalTo("scheduled"))); } - @Test + @ParameterizedTest + @MethodSource("confirmationOptions") @Description("Testing that a single rollout group can be retrieved") - void retrieveSingleRolloutGroup() throws Exception { + void retrieveSingleRolloutGroup(final boolean confirmationFlowEnabled, final boolean confirmationRequired) + throws Exception { // setup final int amountTargets = 20; testdataFactory.createTargets(amountTargets, "rollout", "rollout"); final DistributionSet dsA = testdataFactory.createDistributionSet(""); + if (confirmationFlowEnabled) { + enableConfirmationFlow(); + } + // create rollout including the created targets with prefix 'rollout' - final Rollout rollout = rolloutManagement.create(entityFactory.rollout() - .create() - .name("rollout1") - .set(dsA.getId()) - .targetFilterQuery("controllerId==rollout*"), 4, new RolloutGroupConditionBuilder().withDefaults() + final Rollout rollout = rolloutManagement.create( + entityFactory.rollout().create().name("rollout1").set(dsA.getId()) + .targetFilterQuery("controllerId==rollout*"), + 4, confirmationRequired, new RolloutGroupConditionBuilder().withDefaults() .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); final RolloutGroup firstGroup = rolloutGroupManagement @@ -671,12 +825,18 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { retrieveAndVerifyRolloutGroupInCreating(rollout, firstGroup); retrieveAndVerifyRolloutGroupInReady(rollout, firstGroup); - retrieveAndVerifyRolloutGroupInRunningAndScheduled(rollout, firstGroup, secondGroup); + retrieveAndVerifyRolloutGroupInRunningAndScheduled(rollout, firstGroup, secondGroup, confirmationFlowEnabled, + confirmationRequired); + } + + private static Stream confirmationOptions() { + return Stream.of(Arguments.of(true, false), Arguments.of(true, true), Arguments.of(false, true)); } @Step private void retrieveAndVerifyRolloutGroupInRunningAndScheduled(final Rollout rollout, - final RolloutGroup firstGroup, final RolloutGroup secondGroup) throws Exception { + final RolloutGroup firstGroup, final RolloutGroup secondGroup, final boolean confirmationFlowEnabled, + final boolean confirmationRequired) throws Exception { rolloutManagement.start(rollout.getId()); rolloutManagement.handleRollouts(); mvc.perform(get("/rest/v1/rollouts/{rolloutId}/deploygroups/{groupId}", rollout.getId(), firstGroup.getId()) @@ -688,7 +848,9 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.totalTargetsPerStatus.scheduled", equalTo(0))) .andExpect(jsonPath("$.totalTargetsPerStatus.cancelled", equalTo(0))) .andExpect(jsonPath("$.totalTargetsPerStatus.finished", equalTo(0))) - .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))); + .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))) + .andExpect(isConfirmationFlowEnabled() ? jsonPath("confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()); mvc.perform(get("/rest/v1/rollouts/{rolloutId}/deploygroups/{groupId}", rollout.getId(), secondGroup.getId()) .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) @@ -699,7 +861,10 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.totalTargetsPerStatus.scheduled", equalTo(5))) .andExpect(jsonPath("$.totalTargetsPerStatus.cancelled", equalTo(0))) .andExpect(jsonPath("$.totalTargetsPerStatus.finished", equalTo(0))) - .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))); + .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))) + .andExpect(isConfirmationFlowEnabled() ? jsonPath("confirmationRequired", equalTo(confirmationRequired)) + : jsonPath("confirmationRequired").doesNotExist()); + ; } @Step @@ -718,7 +883,10 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("$.totalTargetsPerStatus.scheduled", equalTo(0))) .andExpect(jsonPath("$.totalTargetsPerStatus.cancelled", equalTo(0))) .andExpect(jsonPath("$.totalTargetsPerStatus.finished", equalTo(0))) - .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))); + .andExpect(jsonPath("$.totalTargetsPerStatus.error", equalTo(0))) + .andExpect(isConfirmationFlowEnabled() ? jsonPath("confirmationRequired").exists() + : jsonPath("confirmationRequired").doesNotExist()); + ; } @Step @@ -729,6 +897,8 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("id", equalTo(firstGroup.getId().intValue()))) + .andExpect(isConfirmationFlowEnabled() ? jsonPath("confirmationRequired").exists() + : jsonPath("confirmationRequired").doesNotExist()) .andExpect(jsonPath("status", equalTo("creating"))).andExpect(jsonPath("name", endsWith("1"))) .andExpect(jsonPath("description", endsWith("1"))) .andExpect(jsonPath("$.targetFilterQuery", equalTo(""))) @@ -983,9 +1153,9 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final int weight = 66; final String invalideWeightRequest = JsonBuilder.rollout("withWeight", "d", 2, dsId, "id==rollout*", - new RolloutGroupConditionBuilder().withDefaults().build(), null, null, Action.WEIGHT_MIN - 1); + new RolloutGroupConditionBuilder().withDefaults().build(), null, null, Action.WEIGHT_MIN - 1, null); final String valideWeightRequest = JsonBuilder.rollout("withWeight", "d", 2, dsId, "id==rollout*", - new RolloutGroupConditionBuilder().withDefaults().build(), null, null, weight); + new RolloutGroupConditionBuilder().withDefaults().build(), null, null, weight, null); mvc.perform(post("/rest/v1/rollouts").content(valideWeightRequest).contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) @@ -1009,7 +1179,7 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final String targetFilterQuery, final int targets, final Action.ActionType type) throws Exception { final String actionType = MgmtRestModelMapper.convertActionType(type).getName(); final String rollout = JsonBuilder.rollout(name, "desc", groupSize, distributionSetId, targetFilterQuery, - new RolloutGroupConditionBuilder().withDefaults().build(), null, actionType, null); + new RolloutGroupConditionBuilder().withDefaults().build(), null, actionType, null, null); mvc.perform(post("/rest/v1/rollouts").content(rollout).contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()) @@ -1040,7 +1210,15 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { private Rollout createRollout(final String name, final int amountGroups, final long distributionSetId, final String targetFilterQuery) { - final Rollout rollout = createRolloutInCreatingSatate(name, amountGroups, distributionSetId, targetFilterQuery); + return createRollout(name, amountGroups, distributionSetId, targetFilterQuery, false); + } + + private Rollout createRollout(final String name, final int amountGroups, final long distributionSetId, + final String targetFilterQuery, final boolean confirmationRequired) { + final Rollout rollout = rolloutManagement.create( + entityFactory.rollout().create().name(name).set(distributionSetId).targetFilterQuery(targetFilterQuery), + amountGroups, confirmationRequired, new RolloutGroupConditionBuilder().withDefaults() + .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); // Run here, because Scheduler is disabled during tests rolloutManagement.handleRollouts(); @@ -1048,14 +1226,6 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { return rolloutManagement.get(rollout.getId()).orElseThrow(NoSuchElementException::new); } - private Rollout createRolloutInCreatingSatate(final String name, final int amountGroups, - final long distributionSetId, final String targetFilterQuery) { - return rolloutManagement.create( - entityFactory.rollout().create().name(name).set(distributionSetId).targetFilterQuery(targetFilterQuery), - amountGroups, new RolloutGroupConditionBuilder().withDefaults() - .successCondition(RolloutGroupSuccessCondition.THRESHOLD, "100").build()); - } - @Test @Description("Trigger next rollout group") void triggeringNextGroupRollout() throws Exception { @@ -1085,7 +1255,7 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { final DistributionSet dsA = testdataFactory.createDistributionSet(""); // CREATING state - final Rollout rollout = createRolloutInCreatingSatate("rollout1", 3, dsA.getId(), "controllerId==rollout*"); + final Rollout rollout = createRollout("rollout1", 3, dsA.getId(), "controllerId==rollout*", false); triggerNextGroupAndExpect(rollout, status().isBadRequest()); // READY state diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java index 645213182..698975d44 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java @@ -21,6 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; +import java.util.stream.Stream; import org.eclipse.hawkbit.exception.SpServerError; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; @@ -37,6 +38,10 @@ import org.eclipse.hawkbit.rest.exception.MessageNotReadableException; import org.eclipse.hawkbit.rest.json.model.ExceptionInfo; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; @@ -59,6 +64,7 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte private static final String JSON_PATH_FIELD_ID = ".id"; private static final String JSON_PATH_FIELD_NAME = ".name"; private static final String JSON_PATH_FIELD_QUERY = ".query"; + private static final String JSON_PATH_FIELD_CONFIRMATION_REQUIRED = ".confirmationRequired"; private static final String JSON_PATH_FIELD_CONTENT = ".content"; private static final String JSON_PATH_FIELD_SIZE = ".size"; private static final String JSON_PATH_FIELD_TOTAL = ".total"; @@ -76,6 +82,8 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte private static final String JSON_PATH_NAME = JSON_PATH_ROOT + JSON_PATH_FIELD_NAME; private static final String JSON_PATH_ID = JSON_PATH_ROOT + JSON_PATH_FIELD_ID; private static final String JSON_PATH_QUERY = JSON_PATH_ROOT + JSON_PATH_FIELD_QUERY; + private static final String JSON_PATH_CONFIRMATION_REQUIRED = JSON_PATH_ROOT + + JSON_PATH_FIELD_CONFIRMATION_REQUIRED; private static final String JSON_PATH_AUTO_ASSIGN_DS = JSON_PATH_ROOT + JSON_PATH_FIELD_AUTO_ASSIGN_DS; private static final String JSON_PATH_AUTO_ASSIGN_ACTION_TYPE = JSON_PATH_ROOT + JSON_PATH_FIELD_AUTO_ASSIGN_ACTION_TYPE; @@ -126,7 +134,8 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte .contentType(MediaType.APPLICATION_JSON)).andDo(print()).andExpect(status().isOk()) .andExpect(jsonPath(JSON_PATH_ID, equalTo(tfq.getId().intValue()))) .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(filterQuery2))) - .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName))); + .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName))) + .andExpect(jsonPath(JSON_PATH_CONFIRMATION_REQUIRED).doesNotExist()); final TargetFilterQuery tfqCheck = targetFilterQueryManagement.get(tfq.getId()).get(); assertThat(tfqCheck.getQuery()).isEqualTo(filterQuery2); @@ -149,7 +158,9 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte .contentType(MediaType.APPLICATION_JSON)).andDo(print()).andExpect(status().isOk()) .andExpect(jsonPath(JSON_PATH_ID, equalTo(tfq.getId().intValue()))) .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(filterQuery))) - .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName2))); + .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName2))) + .andExpect(jsonPath(JSON_PATH_CONFIRMATION_REQUIRED).doesNotExist()); + ; final TargetFilterQuery tfqCheck = targetFilterQueryManagement.get(tfq.getId()).get(); assertThat(tfqCheck.getQuery()).isEqualTo(filterQuery); @@ -257,6 +268,7 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte mvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId())).andDo(print()) .andExpect(status().isOk()).andExpect(jsonPath(JSON_PATH_NAME, equalTo(knownName))) .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(knownQuery))) + .andExpect(jsonPath(JSON_PATH_CONFIRMATION_REQUIRED).doesNotExist()) .andExpect(jsonPath("$._links.self.href", equalTo(hrefPrefix))) .andExpect(jsonPath("$._links.autoAssignDS.href", equalTo(hrefPrefix + "/autoAssignDS"))); } @@ -364,24 +376,30 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte } - @Test - @Description("Ensures that the distribution set auto-assignment works as intended with distribution set and action type validation") - public void setAutoAssignDistributionSetToTargetFilterQuery() throws Exception { + @ParameterizedTest + @MethodSource("confirmationOptions") + @Description("Ensures that the distribution set auto-assignment works as intended with distribution set, action type and confirmation validation") + public void setAutoAssignDistributionSetToTargetFilterQuery(final boolean confirmationFlowActive, + final Boolean confirmationRequired) throws Exception { final String knownQuery = "name==test05"; final String knownName = "filter05"; + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + final DistributionSet set = testdataFactory.createDistributionSet(); final TargetFilterQuery tfq = createSingleTargetFilterQuery(knownName, knownQuery); - verifyAutoAssignmentWithoutActionType(tfq, set); + verifyAutoAssignmentWithoutActionType(tfq, set, confirmationRequired); - verifyAutoAssignmentWithForcedActionType(tfq, set); + verifyAutoAssignmentWithForcedActionType(tfq, set, confirmationRequired); - verifyAutoAssignmentWithSoftActionType(tfq, set); + verifyAutoAssignmentWithSoftActionType(tfq, set, confirmationRequired); verifyAutoAssignmentWithTimeForcedActionType(tfq, set); - verifyAutoAssignmentWithDownloadOnlyActionType(tfq, set); + verifyAutoAssignmentWithDownloadOnlyActionType(tfq, set, confirmationRequired); verifyAutoAssignmentWithUnknownActionType(tfq, set); @@ -390,34 +408,68 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte verifyAutoAssignmentWithSoftDeletedDs(tfq); } - @Step - private void verifyAutoAssignmentWithoutActionType(final TargetFilterQuery tfq, final DistributionSet set) - throws Exception { - verifyAutoAssignmentByActionType(tfq, set, null); + @ParameterizedTest + @ValueSource(booleans = { true, false }) + @Description("Verify the confirmation required flag will be set based on the feature state") + void verifyConfirmationStateIfNotProvided(final boolean confirmationFlowActive) throws Exception { + final String knownQuery = "name==test05"; + final String knownName = "filter05"; + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + final DistributionSet set = testdataFactory.createDistributionSet(); + final TargetFilterQuery tfq = createSingleTargetFilterQuery(knownName, knownQuery); + + // do not provide something about the confirmation + verifyAutoAssignmentByActionType(tfq, set, null, null); + + assertThat(targetFilterQueryManagement.get(tfq.getId())).hasValueSatisfying(filter -> { + assertThat(filter.isConfirmationRequired()).isEqualTo(confirmationFlowActive); + }); + } + + private static Stream confirmationOptions() { + return Stream.of(Arguments.of(true, false), Arguments.of(true, true), Arguments.of(false, true), + Arguments.of(true, null), Arguments.of(false, null)); } @Step - private void verifyAutoAssignmentWithForcedActionType(final TargetFilterQuery tfq, final DistributionSet set) - throws Exception { - verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.FORCED); + private void verifyAutoAssignmentWithoutActionType(final TargetFilterQuery tfq, final DistributionSet set, + final Boolean confirmationRequired) throws Exception { + verifyAutoAssignmentByActionType(tfq, set, null, confirmationRequired); } @Step - private void verifyAutoAssignmentWithSoftActionType(final TargetFilterQuery tfq, final DistributionSet set) - throws Exception { - verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.SOFT); + private void verifyAutoAssignmentWithForcedActionType(final TargetFilterQuery tfq, final DistributionSet set, + final Boolean confirmationRequired) throws Exception { + verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.FORCED, confirmationRequired); + } + + @Step + private void verifyAutoAssignmentWithSoftActionType(final TargetFilterQuery tfq, final DistributionSet set, + final Boolean confirmationRequired) throws Exception { + verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.SOFT, confirmationRequired); } private void verifyAutoAssignmentByActionType(final TargetFilterQuery tfq, final DistributionSet set, - final MgmtActionType actionType) throws Exception { + final MgmtActionType actionType, final Boolean confirmationRequired) throws Exception { final String hrefPrefix = "http://localhost" + MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId(); - final String payload = actionType != null - ? "{\"id\":" + set.getId() + ", \"type\":\"" + actionType.getName() + "\"}" - : "{\"id\":" + set.getId() + "}"; + final JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", set.getId()); + if (actionType != null) { + jsonObject.put("type", actionType.getName()); + } + if (confirmationRequired != null) { + jsonObject.put("confirmationRequired", confirmationRequired); + } + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") - .content(payload).contentType(MediaType.APPLICATION_JSON)).andDo(print()).andExpect(status().isOk()); + .content(jsonObject.toString()).contentType(MediaType.APPLICATION_JSON)).andDo(print()) + .andExpect(status().isOk()); final TargetFilterQuery updatedFilterQuery = targetFilterQueryManagement.get(tfq.getId()).get(); final MgmtActionType expectedActionType = actionType != null ? actionType : MgmtActionType.FORCED; @@ -429,6 +481,10 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte mvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId())).andDo(print()) .andExpect(status().isOk()).andExpect(jsonPath(JSON_PATH_NAME, equalTo(tfq.getName()))) .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(tfq.getQuery()))) + .andExpect(isConfirmationFlowEnabled() + ? jsonPath(JSON_PATH_CONFIRMATION_REQUIRED, + equalTo(confirmationRequired == null || confirmationRequired)) + : jsonPath(JSON_PATH_CONFIRMATION_REQUIRED).doesNotExist()) .andExpect(jsonPath(JSON_PATH_AUTO_ASSIGN_DS, equalTo(set.getId().intValue()))) .andExpect(jsonPath(JSON_PATH_AUTO_ASSIGN_ACTION_TYPE, equalTo(expectedActionType.getName()))) .andExpect(jsonPath("$._links.self.href", equalTo(hrefPrefix))) @@ -448,9 +504,9 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte } @Step - private void verifyAutoAssignmentWithDownloadOnlyActionType(final TargetFilterQuery tfq, final DistributionSet set) - throws Exception { - verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.DOWNLOAD_ONLY); + private void verifyAutoAssignmentWithDownloadOnlyActionType(final TargetFilterQuery tfq, final DistributionSet set, + final Boolean confirmationRequired) throws Exception { + verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.DOWNLOAD_ONLY, confirmationRequired); } @Step diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java index dd5d74faf..11e948d9f 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java @@ -9,6 +9,7 @@ package org.eclipse.hawkbit.mgmt.rest.resource; import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_V1_AUTO_CONFIRM; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.not; @@ -36,6 +37,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.validation.ConstraintViolationException; @@ -73,6 +75,9 @@ import org.hamcrest.Matchers; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; import org.springframework.data.domain.PageRequest; @@ -764,9 +769,10 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { final Target test1 = entityFactory.target().create().controllerId("id1") .name(RandomStringUtils.randomAlphanumeric(NamedEntity.NAME_MAX_SIZE + 1)).build(); - final MvcResult mvcResult = mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING) - .content(JsonBuilder.targets(Collections.singletonList(test1), true)) - .contentType(MediaType.APPLICATION_JSON)) + final MvcResult mvcResult = mvc + .perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING) + .content(JsonBuilder.targets(Collections.singletonList(test1), true)) + .contentType(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()).andReturn(); assertThat(targetManagement.count()).isZero(); @@ -1253,8 +1259,7 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { // Update if (schedule == null) { final List updatedTargets = assignDistributionSet(one, Collections.singletonList(target)) - .getAssignedEntity() - .stream().map(Action::getTarget).collect(Collectors.toList()); + .getAssignedEntity().stream().map(Action::getTarget).collect(Collectors.toList()); // 2nd update // sleep 10ms to ensure that we can sort by reportedAt Awaitility.await().atMost(Duration.ONE_HUNDRED_MILLISECONDS).atLeast(5, TimeUnit.MILLISECONDS) @@ -1336,6 +1341,48 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { assertThat(targetManagement.getByControllerID(target.getControllerId()).get()).isEqualTo(target); } + @ParameterizedTest + @MethodSource("confirmationOptions") + @Description("Ensures that confirmation option is considered in assignment request.") + void assignDistributionSetToTargetWithConfirmationOptions(final boolean confirmationFlowActive, + final Boolean confirmationRequired) throws Exception { + + final Target target = testdataFactory.createTarget(); + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + if (confirmationFlowActive) { + enableConfirmationFlow(); + } + + final JSONObject jsonPayload = new JSONObject(); + jsonPayload.put("id", set.getId()); + if (confirmationRequired != null) { + jsonPayload.put("confirmationRequired", confirmationRequired); + } + + mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + target.getControllerId() + "/assignedDS") + .content(jsonPayload.toString()).contentType(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("assigned", equalTo(1))).andExpect(jsonPath("alreadyAssigned", equalTo(0))) + .andExpect(jsonPath("total", equalTo(1))); + + assertThat(deploymentManagement.getAssignedDistributionSet(target.getControllerId()).get()).isEqualTo(set); + + assertThat(deploymentManagement.findActionsByDistributionSet(PAGE, set.getId()).getContent()).hasSize(1) + .allMatch(action -> { + if (!confirmationFlowActive) { + return !action.isWaitingConfirmation(); + } + return confirmationRequired == null ? action.isWaitingConfirmation() + : confirmationRequired == action.isWaitingConfirmation(); + }); + } + + private static Stream confirmationOptions() { + return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true), + Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null)); + } + @Test @Description("Verfies that a DOWNLOAD_ONLY DS to target assignment is properly handled") void assignDownloadOnlyDistributionSetToTarget() throws Exception { @@ -2003,9 +2050,9 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { final JSONObject bodyValid = getAssignmentObject(dsId, MgmtActionType.FORCED, 98); mvc.perform(post("/rest/v1/targets/{targetId}/assignedDS", targetId).content(bodyValid.toString()) - .contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode", equalTo("hawkbit.server.error.multiassignmentNotEnabled"))); + .contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode", equalTo("hawkbit.server.error.multiassignmentNotEnabled"))); } @Test @@ -2057,7 +2104,7 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { } } - + @Test @Description("An action provides information of the rollout it was created for (if any).") void getActionWithRolloutInfo() throws Exception { @@ -2094,8 +2141,10 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { @Test @Description("Ensures that a post request for creating targets with target type works.") void createTargetsWithTargetType() throws Exception { - final TargetType type1 = testdataFactory.createTargetType("typeWithDs", Collections.singletonList(standardDsType)); - final TargetType type2 = testdataFactory.createTargetType("typeWithOutDs", Collections.singletonList(standardDsType)); + final TargetType type1 = testdataFactory.createTargetType("typeWithDs", + Collections.singletonList(standardDsType)); + final TargetType type2 = testdataFactory.createTargetType("typeWithOutDs", + Collections.singletonList(standardDsType)); final Target test1 = entityFactory.target().create().controllerId("id1").name("targetWithoutType") .securityToken("token").address("amqp://test123/foobar").description("testid1").build(); @@ -2132,8 +2181,7 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("[2].description", equalTo("testid3"))) .andExpect(jsonPath("[2].createdAt", not(equalTo(0)))) .andExpect(jsonPath("[2].createdBy", equalTo("bumlux"))) - .andExpect(jsonPath("[2].targetType", equalTo(type2.getId().intValue()))) - .andReturn(); + .andExpect(jsonPath("[2].targetType", equalTo(type2.getId().intValue()))).andReturn(); mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + test2.getControllerId())) .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) @@ -2159,10 +2207,11 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { @Description("Ensures that a post request for creating target with target type works.") void createTargetWithExistingTargetType() throws Exception { // create target type - List targetTypes = testdataFactory.createTargetTypes("targettype",1); + List targetTypes = testdataFactory.createTargetTypes("targettype", 1); assertThat(targetTypes).hasSize(1); - final Target target = entityFactory.target().create().controllerId("targetcontroller").name("testtarget").targetType(targetTypes.get(0).getId()).build(); + final Target target = entityFactory.target().create().controllerId("targetcontroller").name("testtarget") + .targetType(targetTypes.get(0).getId()).build(); final String targetList = JsonBuilder.targets(Collections.singletonList(target), false); @@ -2172,14 +2221,15 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(jsonPath("[0].controllerId", equalTo("targetcontroller"))) .andExpect(jsonPath("[0].targetType", equalTo(targetTypes.get(0).getId().intValue()))); - assertThat(targetManagement.getByControllerID("targetcontroller").get().getTargetType().getId()).isEqualTo(targetTypes.get(0).getId()); + assertThat(targetManagement.getByControllerID("targetcontroller").get().getTargetType().getId()) + .isEqualTo(targetTypes.get(0).getId()); } @Test @Description("Ensures that a put request for updating targets with target type works.") void updateTargetTypeInTarget() throws Exception { // create target type - List targetTypes = testdataFactory.createTargetTypes("targettype",2); + List targetTypes = testdataFactory.createTargetTypes("targettype", 2); assertThat(targetTypes).hasSize(2); String controllerId = "targetcontroller"; @@ -2234,7 +2284,8 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { .content("{\"id\":" + targetType.getId() + "}").contentType(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); - assertThat(targetManagement.getByControllerID(targetControllerId).get().getTargetType().getId()).isEqualTo(targetType.getId()); + assertThat(targetManagement.getByControllerID(targetControllerId).get().getTargetType().getId()) + .isEqualTo(targetType.getId()); } @Test @@ -2254,8 +2305,10 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { .andDo(MockMvcResultPrinter.print()).andExpect(status().isNotFound()); // verify response json exception message if body does not include id field - final MvcResult mvcResult = mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + targetControllerId + "/targettype") - .content("{\"unknownfield\":" + invalidTargetTypeId + "}").contentType(MediaType.APPLICATION_JSON)) + final MvcResult mvcResult = mvc + .perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + targetControllerId + "/targettype") + .content("{\"unknownfield\":" + invalidTargetTypeId + "}") + .contentType(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()).andReturn(); final ExceptionInfo exceptionInfo = ResourceUtility .convertException(mvcResult.getResponse().getContentAsString()); @@ -2268,7 +2321,7 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { @Description("Ensures that a delete request for unassign target type from target works.") void unassignTargetTypeFromTarget() throws Exception { // create target type - List targetTypes = testdataFactory.createTargetTypes("targettype",1); + List targetTypes = testdataFactory.createTargetTypes("targettype", 1); assertThat(targetTypes).hasSize(1); String targetControllerId = "targetcontroller"; @@ -2296,24 +2349,24 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isMethodNotAllowed()); // PUT is not allowed - mvc.perform(put(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING - + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING, knownTargetId)) - .andDo(MockMvcResultPrinter.print()).andExpect(status().isMethodNotAllowed()); + mvc.perform(put( + MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING, + knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isMethodNotAllowed()); // POST does not exist with path parameter targettype mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING + "/123", knownTargetId)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isNotFound()); - // DELETE does not exist with path parameter targettype + // DELETE does not exist with path parameter targettype mvc.perform(delete(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING + "/123", knownTargetId)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isNotFound()); // Invalid content - mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING, - knownTargetId)).andDo(MockMvcResultPrinter.print()) - .andExpect(status().isUnsupportedMediaType()); + mvc.perform(post( + MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_TARGET_TYPE_V1_REQUEST_MAPPING, + knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isUnsupportedMediaType()); // Bad request if id field is missing mvc.perform(post( @@ -2321,4 +2374,87 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { knownTargetId).content("{\"unknownfield\":123}").contentType(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()); } + + @ParameterizedTest + @MethodSource("possibleActiveStates") + void getAutoConfirmActive(final String initiator, final String remark) throws Exception { + final String knownTargetId = "targetId"; + testdataFactory.createTarget(knownTargetId); + confirmationManagement.activateAutoConfirmation(knownTargetId, initiator, remark); + + // GET with all possible responses + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM, + knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("active", equalTo(Boolean.TRUE))) + .andExpect(initiator == null ? jsonPath("initiator").doesNotExist() + : jsonPath("initiator", equalTo(initiator))) + .andExpect(remark == null ? jsonPath("remark").doesNotExist() : jsonPath("remark", equalTo(remark))) + .andExpect(jsonPath("_links.deactivate").exists()) + .andExpect(jsonPath("_links.activate").doesNotExist()); + } + + + @Test + void getAutoConfirmStateFromTargetsEndpoint() throws Exception { + final String knownTargetId = "targetId"; + testdataFactory.createTarget(knownTargetId); + + // GET if active + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()).andExpect(jsonPath("content.[0].autoConfirmActive").doesNotExist()); + + enableConfirmationFlow(); + + // GET if not active + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("content.[0].autoConfirmActive", equalTo(Boolean.FALSE))); + + confirmationManagement.activateAutoConfirmation(knownTargetId, "test", "remark"); + + // GET if active + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()).andExpect(jsonPath("content.[0].autoConfirmActive", equalTo(Boolean.TRUE))); + } + + @Test + void getAutoConfirmNotActive() throws Exception { + final String knownTargetId = "targetId"; + + // GET for not existing target + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM, + knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isNotFound()); + + testdataFactory.createTarget(knownTargetId); + + // GET for auto-confirm not active + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM, + knownTargetId)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("active", equalTo(Boolean.FALSE))).andExpect(jsonPath("initiator").doesNotExist()) + .andExpect(jsonPath("remark").doesNotExist()).andExpect(jsonPath("_links.activate").exists()); + } + + @Test + void autoConfirmStateReferenceOnTarget() throws Exception { + final String knownTargetId = "targetId"; + testdataFactory.createTarget(knownTargetId); + + // GET with confirmation flow not active should not expose + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}", knownTargetId)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("autoConfirmActive").doesNotExist()) + .andExpect(jsonPath("_links.autoConfirm").doesNotExist()); + + enableConfirmationFlow(); + + // GET with confirmation flow active should expose + mvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}", knownTargetId)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("autoConfirmActive").exists()).andExpect(jsonPath("_links.autoConfirm").exists()); + } + + private static Stream possibleActiveStates() { + return Stream.of(Arguments.of("someInitiator", "someRemark"), Arguments.of(null, "someRemark"), + Arguments.of("someInitiator", null), Arguments.of(null, null)); + } } diff --git a/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java b/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java index 30f152dbf..6a6f0e833 100644 --- a/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java +++ b/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java @@ -84,6 +84,7 @@ public class ResponseExceptionHandler { ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_CONCURRENT_MODIFICATION, HttpStatus.CONFLICT); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID, HttpStatus.BAD_REQUEST); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_ATTRIBUTES_INVALID, HttpStatus.BAD_REQUEST); + ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_AUTO_CONFIRMATION_ALREADY_ACTIVE, HttpStatus.CONFLICT); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_AUTO_ASSIGN_ACTION_TYPE_INVALID, HttpStatus.BAD_REQUEST); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_CONFIGURATION_VALUE_CHANGE_NOT_ALLOWED, HttpStatus.FORBIDDEN); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_MULTIASSIGNMENT_NOT_ENABLED, HttpStatus.BAD_REQUEST); diff --git a/hawkbit-rest/hawkbit-rest-core/src/test/java/org/eclipse/hawkbit/rest/util/JsonBuilder.java b/hawkbit-rest/hawkbit-rest-core/src/test/java/org/eclipse/hawkbit/rest/util/JsonBuilder.java index 492adb58e..20ed1855d 100644 --- a/hawkbit-rest/hawkbit-rest-core/src/test/java/org/eclipse/hawkbit/rest/util/JsonBuilder.java +++ b/hawkbit-rest/hawkbit-rest-core/src/test/java/org/eclipse/hawkbit/rest/util/JsonBuilder.java @@ -17,6 +17,7 @@ import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSetType; import org.eclipse.hawkbit.repository.model.RolloutGroup; +import org.eclipse.hawkbit.repository.model.RolloutGroupConditionBuilder; import org.eclipse.hawkbit.repository.model.RolloutGroupConditions; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.SoftwareModuleType; @@ -26,6 +27,9 @@ import org.eclipse.hawkbit.repository.model.TargetType; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.springframework.util.CollectionUtils; + +import static org.junit.jupiter.api.Assertions.fail; /** * Builder class for building certain json strings. @@ -478,26 +482,36 @@ public abstract class JsonBuilder { public static String rollout(final String name, final String description, final int groupSize, final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions) { return rollout(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, null, null, - null); - } - - public static String rollout(final String name, final String description, final Integer groupSize, - final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions, - final List groups) { - return rollout(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, groups, null, - null); + null, null); } public static String rollout(final String name, final String description, final Integer groupSize, final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions, final String type) { - return rollout(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, null, type, + return rollout(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, null, type, null, null); } + public static String rolloutWithGroups(final String name, final String description, final Integer groupSize, + final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions, + final List groups) { + return rolloutWithGroups(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, groups, + null, null, null); + } + + public static String rolloutWithGroups(final String name, final String description, final Integer groupSize, + final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions, + final List groups, final String type, final Integer weight, + final Boolean confirmationRequired) { + final List rolloutGroupsJson = groups.stream().map(JsonBuilder::rolloutGroup) + .collect(Collectors.toList()); + return rollout(name, description, groupSize, distributionSetId, targetFilterQuery, conditions, + rolloutGroupsJson, type, weight, confirmationRequired); + } + public static String rollout(final String name, final String description, final Integer groupSize, final long distributionSetId, final String targetFilterQuery, final RolloutGroupConditions conditions, - final List groups, final String type, final Integer weight) { + final List groupJsonList, final String type, final Integer weight, final Boolean confirmationRequired) { final JSONObject json = new JSONObject(); try { @@ -515,6 +529,10 @@ public abstract class JsonBuilder { json.put("weight", weight); } + if (confirmationRequired != null) { + json.put("confirmationRequired", confirmationRequired); + } + if (conditions != null) { final JSONObject successCondition = new JSONObject(); @@ -539,44 +557,11 @@ public abstract class JsonBuilder { errorAction.put("expression", conditions.getErrorActionExp()); } - if (groups != null) { + if (!CollectionUtils.isEmpty(groupJsonList)) { final JSONArray jsonGroups = new JSONArray(); - - for (final RolloutGroup group : groups) { - final JSONObject jsonGroup = new JSONObject(); - jsonGroup.put("name", group.getName()); - jsonGroup.put("description", group.getDescription()); - jsonGroup.put("targetFilterQuery", group.getTargetFilterQuery()); - jsonGroup.put("targetPercentage", group.getTargetPercentage()); - - if (group.getSuccessCondition() != null) { - final JSONObject successCondition = new JSONObject(); - jsonGroup.put("successCondition", successCondition); - successCondition.put("condition", group.getSuccessCondition().toString()); - successCondition.put("expression", group.getSuccessConditionExp()); - } - if (group.getSuccessAction() != null) { - final JSONObject successAction = new JSONObject(); - jsonGroup.put("successAction", successAction); - successAction.put("action", group.getSuccessAction().toString()); - successAction.put("expression", group.getSuccessActionExp()); - } - if (group.getErrorCondition() != null) { - final JSONObject errorCondition = new JSONObject(); - jsonGroup.put("errorCondition", errorCondition); - errorCondition.put("condition", group.getErrorCondition().toString()); - errorCondition.put("expression", group.getErrorConditionExp()); - } - if (group.getErrorAction() != null) { - final JSONObject errorAction = new JSONObject(); - jsonGroup.put("errorAction", errorAction); - errorAction.put("action", group.getErrorAction().toString()); - errorAction.put("expression", group.getErrorActionExp()); - } - - jsonGroups.put(jsonGroup); + for (final String groupJson : groupJsonList) { + jsonGroups.put(new JSONObject(groupJson)); } - json.put("groups", jsonGroups); } @@ -587,6 +572,72 @@ public abstract class JsonBuilder { return json.toString(); } + public static String rolloutGroup(final RolloutGroup rolloutGroup) { + final RolloutGroupConditions conditions = getConditions(rolloutGroup); + return rolloutGroup(rolloutGroup.getName(), rolloutGroup.getDescription(), rolloutGroup.getTargetFilterQuery(), + rolloutGroup.getTargetPercentage(), rolloutGroup.isConfirmationRequired(), conditions); + + } + + private static RolloutGroupConditions getConditions(final RolloutGroup rolloutGroup) { + return new RolloutGroupConditionBuilder() + .errorCondition(rolloutGroup.getErrorCondition(), rolloutGroup.getErrorConditionExp()) + .errorAction(rolloutGroup.getErrorAction(), rolloutGroup.getErrorActionExp()) + .successAction(rolloutGroup.getSuccessAction(), rolloutGroup.getSuccessActionExp()) + .successCondition(rolloutGroup.getSuccessCondition(), rolloutGroup.getSuccessConditionExp()).build(); + } + + public static String rolloutGroup(final String name, final String description, final String targetFilterQuery, + final Float targetPercentage, final Boolean confirmationRequired, + final RolloutGroupConditions rolloutGroupConditions) { + final JSONObject jsonGroup = new JSONObject(); + try { + jsonGroup.put("name", name); + jsonGroup.put("description", description); + jsonGroup.put("targetFilterQuery", targetFilterQuery); + if (targetPercentage == null) { + jsonGroup.put("targetPercentage", 100F); + } else { + jsonGroup.put("targetPercentage", targetPercentage); + } + + if (confirmationRequired != null) { + jsonGroup.put("confirmationRequired", confirmationRequired); + } + + if (rolloutGroupConditions.getSuccessCondition() != null) { + final JSONObject successCondition = new JSONObject(); + jsonGroup.put("successCondition", successCondition); + successCondition.put("condition", rolloutGroupConditions.getSuccessCondition().toString()); + successCondition.put("expression", rolloutGroupConditions.getSuccessConditionExp()); + } + if (rolloutGroupConditions.getSuccessAction() != null) { + final JSONObject successAction = new JSONObject(); + jsonGroup.put("successAction", successAction); + successAction.put("action", rolloutGroupConditions.getSuccessAction().toString()); + successAction.put("expression", rolloutGroupConditions.getSuccessActionExp()); + } + if (rolloutGroupConditions.getErrorCondition() != null) { + final JSONObject errorCondition = new JSONObject(); + jsonGroup.put("errorCondition", errorCondition); + errorCondition.put("condition", rolloutGroupConditions.getErrorCondition().toString()); + errorCondition.put("expression", rolloutGroupConditions.getErrorConditionExp()); + } + if (rolloutGroupConditions.getErrorAction() != null) { + final JSONObject errorAction = new JSONObject(); + jsonGroup.put("errorAction", errorAction); + errorAction.put("action", rolloutGroupConditions.getErrorAction().toString()); + errorAction.put("expression", rolloutGroupConditions.getErrorActionExp()); + } + + } catch (final JSONException e) { + e.printStackTrace(); + fail("Cannot parse JSON for rollout group."); + } + + return jsonGroup.toString(); + } + public static String cancelActionFeedback(final String id, final String execution) throws JSONException { return cancelActionFeedback(id, execution, null, RandomStringUtils.randomAlphanumeric(1000)); diff --git a/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/rootcontroller-api-guide.adoc b/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/rootcontroller-api-guide.adoc index 849155cb4..724d3c8d7 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/rootcontroller-api-guide.adoc +++ b/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/rootcontroller-api-guide.adoc @@ -299,9 +299,237 @@ include::{snippets}/rootcontroller/post-basedeployment-action-feedback/request-f include::../errors/400.adoc[] include::../errors/401.adoc[] include::../errors/403.adoc[] +include::../errors/404.adoc[] include::../errors/405.adoc[] include::../errors/406.adoc[] include::../errors/409.adoc[] +include::../errors/410.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + +== GET /{tenant}/controller/v1/{controllerid}/confirmationBase + +=== Implementation notes + +Core resource for confirmation related operations. While active actions awaiting confirmation will be referenced, the current auto-confirmation status will be shown. In case auto-confirmation is active, details like the initiator, remark and date of activation (as unix timestamp) will be provided. Reference links to switch the auto-confirmation state are exposed as well. + +=== Resource to request confirmation specific information for the controller + +==== Curl + +include::{snippets}/rootcontroller/get-confirmation-base-with-auto-confirm-active/curl-request.adoc[] + +==== Request URL + +include::{snippets}/rootcontroller/get-confirmation-base-with-auto-confirm-active/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/rootcontroller/get-confirmation-base-with-auto-confirm-active/path-parameters.adoc[] + +==== Response example (auto-confirmation is active) + +The response body in case auto-confirmation is active. + +include::{snippets}/rootcontroller/get-confirmation-base-with-auto-confirm-active/http-response.adoc[] + +==== Response example (auto-confirmation is not active) + +The response body references a link to activate auto-confirmation as well as a link to an open action waiting for confirmation (if present). + +include::{snippets}/rootcontroller/get-confirmation-base-with-auto-confirm-deactivated/http-response.adoc[] + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + +== POST /{tenant}/controller/v1/{controllerid}/confirmationBase/activateAutoConfirm + +=== Implementation notes + +The device can use this resource to activate auto-confirmation. As a result all current active as well as future actions will automatically be confirmed by mentioning the initiator as triggered person. Actions will be automatically confirmed, as long as auto-confirmation is active. + +=== Interface to activate auto-confirmation for a specific device + +==== Curl + +include::{snippets}/rootcontroller/activate-auto-confirmation/curl-request.adoc[] + +==== Request URL + +include::{snippets}/rootcontroller/activate-auto-confirmation/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/rootcontroller/activate-auto-confirmation/path-parameters.adoc[] + +==== Request fields + +include::{snippets}/rootcontroller/activate-auto-confirmation/request-fields.adoc[] + +=== Response (Status 200) + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + +== POST /{tenant}/controller/v1/{controllerid}/confirmationBase/deactivateAutoConfirm + +=== Implementation notes + +The device can use this resource to deactivate auto-confirmation. All active actions will remain unchanged while all future actions need to be confirmed, before processing with the deployment. + +=== Interface to deactivate auto-confirmation for a specific controller + +==== Curl + +include::{snippets}/rootcontroller/deactivate-auto-confirmation/curl-request.adoc[] + +==== Request URL + +include::{snippets}/rootcontroller/deactivate-auto-confirmation/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/rootcontroller/deactivate-auto-confirmation/path-parameters.adoc[] + +=== Response (Status 200) + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + +== GET /{tenant}/controller/v1/{controllerid}/confirmationBase/{actionId} + +=== Implementation notes + +Resource to receive information about a pending confirmation. +The response will be of the same format as the deploymentBase operation. +The controller should provide feedback about the confirmation first, before processing the deployment. + +Keep in mind that the provided download links for the artifacts are generated dynamically by the update server. +Host, port and path are not guaranteed to be similar to the provided examples below but will be defined at runtime. + +=== Confirmation status of an action + +==== Curl + +include::{snippets}/rootcontroller/get-confirmation-base-action/curl-request.adoc[] + +==== Request URL + +include::{snippets}/rootcontroller/get-confirmation-base-action/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/rootcontroller/get-confirmation-base-action/path-parameters.adoc[] + +==== Request query parameter + +include::{snippets}/rootcontroller/get-confirmation-base-action/request-parameters.adoc[] + +=== Response (Status 200) + +==== Response fields + +include::{snippets}/rootcontroller/get-confirmation-base-action/response-fields.adoc[] + +==== Response example + +The response body includes the detailed information about the action awaiting confirmation in the same format as for the deploymentBase operation. + +include::{snippets}/rootcontroller/get-confirmation-base-action/http-response.adoc[] + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404_target_action.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/429.adoc[] +|=== + +== POST /{tenant}/controller/v1/{controllerid}/confirmationBase/{actionId}/feedback + +=== Implementation notes + +The device will use this resource to either confirm or deny an action which is waiting for confirmation. The action will be transferred into the RUNNING state in case the device is confirming it. Afterwards it will be exposed by the deploymentBase. + +=== Feedback channel for actions waiting for confirmation + +==== Curl + +include::{snippets}/rootcontroller/post-confirmation-feedback/curl-request.adoc[] + +==== Request URL + +include::{snippets}/rootcontroller/post-confirmation-feedback/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/rootcontroller/post-confirmation-feedback/path-parameters.adoc[] + +==== Request fields + +include::{snippets}/rootcontroller/post-confirmation-feedback/request-fields.adoc[] + +=== Response (Status 200) + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404_target_action.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/410.adoc[] include::../errors/415.adoc[] include::../errors/429.adoc[] |=== diff --git a/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/targets-api-guide.adoc b/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/targets-api-guide.adoc index fac3803af..571a1ed4b 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/targets-api-guide.adoc +++ b/hawkbit-rest/hawkbit-rest-docs/src/main/asciidoc/targets-api-guide.adoc @@ -245,6 +245,152 @@ include::../errors/429.adoc[] |=== +== GET /rest/v1/targets/{targetId}/autoConfirm + +=== Implementation Notes + +Handles the GET request to check the current auto-confirmation state of a target. Required Permission: READ_TARGET + +=== Request auto-confirmation state for a specific target + +==== Curl + +include::{snippets}/targets/get-target-auto-confirm-state-active/curl-request.adoc[] + +==== Request URL + +include::{snippets}/targets/get-target-auto-confirm-state-active/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/targets/get-target-auto-confirm-state-active/path-parameters.adoc[] + +=== Response in case auto-confirmation is active (Status 200) + +In case auto-confirmation is active, details like the initiator, remark and date of activation (as unix timestamp) will be provided. Reference links to switch the auto-confirmation state are exposed as well. + +==== Response fields + +include::{snippets}/targets/get-target-auto-confirm-state-active/response-fields.adoc[] + +==== Response example + +include::{snippets}/targets/get-target-auto-confirm-state-active/http-response.adoc[] + +=== Response in case auto-confirmation is not active (Status 200) + +The response body references a link to activate auto-confirmation. + +==== Response fields + +include::{snippets}/targets/get-target-auto-confirm-state-not-active/response-fields.adoc[] + +==== Response example + +include::{snippets}/targets/get-target-auto-confirm-state-not-active/http-response.adoc[] + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/429.adoc[] +|=== + + +== POST /rest/v1/targets/{targetId}/autoConfirm/activate + +=== Implementation Notes + +Handles the POST request to activate auto-confirmation for a specific target. +As a result all current active as well as future actions will automatically be confirmed by mentioning the initiator as triggered person. +Actions will be automatically confirmed, as long as auto-confirmation is active. +Required Permission: UPDATE_TARGET + +=== Activate auto-confirmation for a target + +==== Curl + +include::{snippets}/targets/post-activate-auto-confirm/curl-request.adoc[] + +==== Request URL + +include::{snippets}/targets/post-activate-auto-confirm/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/targets/post-activate-auto-confirm/path-parameters.adoc[] + +==== Request fields + +include::{snippets}/targets/post-activate-auto-confirm/request-fields.adoc[] + +=== Response (Status 200) + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + + +== POST /rest/v1/targets/{targetId}/autoConfirm/deactivate + +=== Implementation Notes + +Handles the POST request to deactivate auto-confirmation for a specific target. +All active actions will remain unchanged while all future actions need to be confirmed, before processing with the deployment. +Required Permission: UPDATE_TARGET + +=== Deactivate auto-confirmation for a target + +==== Curl + +include::{snippets}/targets/post-deactivate-auto-confirm/curl-request.adoc[] + +==== Request URL + +include::{snippets}/targets/post-deactivate-auto-confirm/http-request.adoc[] + +==== Request path parameter + +include::{snippets}/targets/post-deactivate-auto-confirm/path-parameters.adoc[] + +=== Response (Status 200) + +=== Error responses + +|=== +| HTTP Status Code | Reason | Response Model + +include::../errors/400.adoc[] +include::../errors/401.adoc[] +include::../errors/403.adoc[] +include::../errors/404.adoc[] +include::../errors/405.adoc[] +include::../errors/406.adoc[] +include::../errors/409.adoc[] +include::../errors/415.adoc[] +include::../errors/429.adoc[] +|=== + + == GET /rest/v1/targets/{targetId}/actions === Implementation Notes diff --git a/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404.adoc b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404.adoc index 13a2b6335..85bf3f9b7 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404.adoc +++ b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404.adoc @@ -1,3 +1,3 @@ | `404 Not Found` -| Not Found Target. +| Target not found. | See <> diff --git a/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404_target_action.adoc b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404_target_action.adoc new file mode 100644 index 000000000..29f11470f --- /dev/null +++ b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/404_target_action.adoc @@ -0,0 +1,3 @@ +| `404 Not Found` +| Target or Action not found. +| See <> diff --git a/hawkbit-rest/hawkbit-rest-docs/src/main/errors/410.adoc b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/410.adoc new file mode 100644 index 000000000..e32d13679 --- /dev/null +++ b/hawkbit-rest/hawkbit-rest-docs/src/main/errors/410.adoc @@ -0,0 +1,2 @@ +| `410 Gone` +| Action is not active anymore. diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/DdiApiModelProperties.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/DdiApiModelProperties.java index 30892eebe..cdbc1742a 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/DdiApiModelProperties.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/DdiApiModelProperties.java @@ -96,6 +96,8 @@ final class DdiApiModelProperties { static final String DEPLOYMENT = "Detailed deployment operation"; + static final String CONFIRMATION = "Deployment confirmation operation"; + static final String CANCEL = "Detailed cancel operation of a deployment"; static final String INSTALLED = "Detailed operation of last successfully finished action"; @@ -137,4 +139,29 @@ final class DdiApiModelProperties { static final String UPDATE_MODE = "Optional parameter to specify the update mode that should be applied when updating target attributes. " + "Valid values are 'merge', 'replace', and 'remove'. Defaults to 'merge'."; + static final String TARGET_CONFIRMATION_STATE = "action confirmation state"; + + static final String TARGET_CONFIRMATION_CODE = "optional individual status code"; + + static final String TARGET_CONFIRMATION_DETAILS = "List of detailed message information"; + + static final String TARGET_AUTO_CONFIRM = "id of the action"; + + static final String TARGET_AUTO_CONFIRM_STATE = "flag if auto confirm is active"; + + static final String TARGET_AUTO_CONFIRM_INITIATOR_RESPONSE = "(optional) initiator set on activation"; + + static final String TARGET_AUTO_CONFIRM_REMARK_RESPONSE = "(optional) remark set on activation"; + + static final String TARGET_AUTO_CONFIRM_ACTIVATED_AT = "timestamp of the activation"; + + static final String TARGET_AUTO_CONFIRM_REFERENCE_ACTIVATE_AUTO_CONFIRM = "reference link to activate auto confirm"; + + static final String TARGET_AUTO_CONFIRM_REFERENCE_DEACTIVATE_AUTO_CONFIRM = "reference link to deactivate auto confirm"; + + static final String TARGET_AUTO_CONFIRM_REFERENCE_CONFIRMATION_BASE_ACTION = "reference link in case an action with open confirmation is present"; + + static final String TARGET_AUTO_CONFIRM_ACTIVATE_INITIATOR = "individual value (e.g. username) stored as initiator and automatically used as confirmed user in future actions"; + static final String TARGET_AUTO_CONFIRM_ACTIVATE_REMARK = "individual value to attach a remark which will be persisted when automatically confirming future actions"; + } diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/RootControllerDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/RootControllerDocumentationTest.java index 8bdb1e7f2..a930f7b4e 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/RootControllerDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/ddi/documentation/RootControllerDocumentationTest.java @@ -28,6 +28,8 @@ import java.util.Map; import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +import org.eclipse.hawkbit.ddi.json.model.DdiActivateAutoConfirmation; +import org.eclipse.hawkbit.ddi.json.model.DdiConfirmationFeedback; import org.eclipse.hawkbit.ddi.json.model.DdiProgress; import org.eclipse.hawkbit.ddi.json.model.DdiResult; import org.eclipse.hawkbit.ddi.json.model.DdiStatus; @@ -194,14 +196,13 @@ public class RootControllerDocumentationTest extends AbstractApiRestDocumentatio tenantAware.getCurrentTenant(), target.getControllerId(), cancelAction.getId()) .content(objectMapper.writeValueAsString(feedback)) .contentType(MediaType.APPLICATION_JSON_UTF8)) - .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()).andDo(this.document - .document( + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andDo(this.document.document( pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID), parameterWithName("actionId").description(DdiApiModelProperties.ACTION_ID_CANCELED)), requestFields( - optionalRequestFieldWithPath("id") - .description(DdiApiModelProperties.FEEDBACK_ACTION_ID) + optionalRequestFieldWithPath("id").description(DdiApiModelProperties.FEEDBACK_ACTION_ID) .type(JsonFieldType.NUMBER), optionalRequestFieldWithPath("time") .description(DdiApiModelProperties.FEEDBACK_ACTION_TIME), @@ -409,14 +410,14 @@ public class RootControllerDocumentationTest extends AbstractApiRestDocumentatio + DdiRestConstants.DEPLOYMENT_BASE_ACTION + "/{actionId}/feedback", tenantAware.getCurrentTenant(), target.getControllerId(), actionId).content(objectMapper.writeValueAsString(feedback)) .contentType(MediaType.APPLICATION_JSON_UTF8).accept(MediaTypes.HAL_JSON_VALUE)) - .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()).andDo(this.document.document( + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andDo(this.document.document( pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID), parameterWithName("actionId").description(DdiApiModelProperties.ACTION_ID)), requestFields( - optionalRequestFieldWithPath("id") - .description(DdiApiModelProperties.FEEDBACK_ACTION_ID) + optionalRequestFieldWithPath("id").description(DdiApiModelProperties.FEEDBACK_ACTION_ID) .type(JsonFieldType.NUMBER), optionalRequestFieldWithPath("time") .description(DdiApiModelProperties.FEEDBACK_ACTION_TIME), @@ -580,4 +581,234 @@ public class RootControllerDocumentationTest extends AbstractApiRestDocumentatio } + @Test + @Description("Resource to retrieve the current state of auto confirmation. In case auto-confirm is active a reference to disable it will be provided.") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void getConfirmationBaseWithAutoConfirmActive() throws Exception { + enableConfirmationFlow(); + + final DistributionSet set = testdataFactory.createDistributionSet("one"); + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + confirmationManagement.activateAutoConfirmation(target.getControllerId(), "exampleUserId", "exampleRemark"); + assignDistributionSetWithMaintenanceWindow(set.getId(), target.getControllerId(), getTestSchedule(-5), + getTestDuration(10), getTestTimeZone()); + + mockMvc.perform( + get(DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE, + tenantAware.getCurrentTenant(), target.getControllerId()).accept(MediaTypes.HAL_JSON_VALUE)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(MediaTypes.HAL_JSON)) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID)), + responseFields( + fieldWithPath("autoConfirm").description(DdiApiModelProperties.TARGET_AUTO_CONFIRM), + fieldWithPath("autoConfirm.active") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_STATE), + fieldWithPath("autoConfirm.initiator") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_INITIATOR_RESPONSE), + fieldWithPath("autoConfirm.remark") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_REMARK_RESPONSE), + fieldWithPath("autoConfirm.activatedAt") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_ACTIVATED_AT), + fieldWithPath("_links.deactivateAutoConfirm").description( + DdiApiModelProperties.TARGET_AUTO_CONFIRM_REFERENCE_DEACTIVATE_AUTO_CONFIRM)))); + } + + @Test + @Description("Resource to retrieve the current state of auto confirmation. In case actions are waiting for a confirmation, they will be referenced.") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void getConfirmationBaseWithAutoConfirmDeactivated() throws Exception { + enableConfirmationFlow(); + + final DistributionSet set = testdataFactory.createDistributionSet("one"); + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + assignDistributionSetWithMaintenanceWindow(set.getId(), target.getControllerId(), getTestSchedule(-5), + getTestDuration(10), getTestTimeZone()); + + mockMvc.perform( + get(DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE, + tenantAware.getCurrentTenant(), target.getControllerId()).accept(MediaTypes.HAL_JSON_VALUE)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(MediaTypes.HAL_JSON)) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID)), + responseFields( + fieldWithPath("autoConfirm").description(DdiApiModelProperties.TARGET_AUTO_CONFIRM), + fieldWithPath("autoConfirm.active") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_STATE), + fieldWithPath("_links.activateAutoConfirm").description( + DdiApiModelProperties.TARGET_AUTO_CONFIRM_REFERENCE_ACTIVATE_AUTO_CONFIRM), + fieldWithPath("_links.confirmationBase").description( + DdiApiModelProperties.TARGET_AUTO_CONFIRM_REFERENCE_CONFIRMATION_BASE_ACTION)))); + } + + @Test + @Description("Core resource for confirmation of actions. Contains all necessary information for confirmation.") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void getConfirmationBaseAction() throws Exception { + enableConfirmationFlow(); + + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + set.getModules().forEach(module -> { + final byte[] random = RandomStringUtils.random(5).getBytes(); + + artifactManagement.create( + new ArtifactUpload(new ByteArrayInputStream(random), module.getId(), "binary.tgz", false, 0)); + artifactManagement.create( + new ArtifactUpload(new ByteArrayInputStream(random), module.getId(), "file.signature", false, 0)); + }); + + softwareModuleManagement.createMetaData( + entityFactory.softwareModuleMetadata().create(set.getModules().iterator().next().getId()) + .key("aMetadataKey").value("Metadata value as defined in software module").targetVisible(true)); + + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + final Long actionId = getFirstAssignedActionId(assignDistributionSetWithMaintenanceWindow(set.getId(), + target.getControllerId(), getTestSchedule(-5), getTestDuration(10), getTestTimeZone())); + + mockMvc.perform(get( + DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + + "/{actionId}?actionHistory=10", + tenantAware.getCurrentTenant(), target.getControllerId(), actionId).accept(MediaTypes.HAL_JSON_VALUE)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(content().contentType(MediaTypes.HAL_JSON)) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID), + parameterWithName("actionId").description(DdiApiModelProperties.ACTION_ID)), + requestParameters( + parameterWithName("actionHistory").description(DdiApiModelProperties.ACTION_HISTORY)), + responseFields(fieldWithPath("id").description(DdiApiModelProperties.ACTION_ID), + fieldWithPath("confirmation").description(DdiApiModelProperties.CONFIRMATION), + fieldWithPath("confirmation.download") + .description(DdiApiModelProperties.HANDLING_DOWNLOAD).type("enum") + .attributes(key("value").value("['skip', 'attempt', 'forced']")), + fieldWithPath("confirmation.update").description(DdiApiModelProperties.HANDLING_UPDATE) + .type("enum").attributes(key("value").value("['skip', 'attempt', 'forced']")), + fieldWithPath("confirmation.maintenanceWindow") + .description(DdiApiModelProperties.MAINTENANCE_WINDOW).type("enum") + .attributes(key("value").value("['available', 'unavailable']")), + fieldWithPath("confirmation.chunks").description(DdiApiModelProperties.CHUNK), + fieldWithPath("confirmation.chunks[].metadata") + .description(DdiApiModelProperties.CHUNK_META_DATA).optional(), + fieldWithPath("confirmation.chunks[].metadata[].key") + .description(DdiApiModelProperties.CHUNK_META_DATA_KEY).optional(), + fieldWithPath("confirmation.chunks[].metadata[].value") + .description(DdiApiModelProperties.CHUNK_META_DATA_VALUE).optional(), + fieldWithPath("confirmation.chunks[].part") + .description(DdiApiModelProperties.CHUNK_TYPE), + fieldWithPath("confirmation.chunks[].name") + .description(DdiApiModelProperties.CHUNK_NAME), + fieldWithPath("confirmation.chunks[].version") + .description(DdiApiModelProperties.CHUNK_VERSION), + fieldWithPath("confirmation.chunks[].artifacts") + .description(DdiApiModelProperties.ARTIFACTS), + fieldWithPath("confirmation.chunks[].artifacts[].filename") + .description(DdiApiModelProperties.ARTIFACTS), + fieldWithPath("confirmation.chunks[].artifacts[].hashes") + .description(DdiApiModelProperties.ARTIFACTS), + fieldWithPath("confirmation.chunks[].artifacts[].hashes.sha1") + .description(DdiApiModelProperties.ARTIFACT_HASHES_SHA1), + fieldWithPath("confirmation.chunks[].artifacts[].hashes.md5") + .description(DdiApiModelProperties.ARTIFACT_HASHES_MD5), + fieldWithPath("confirmation.chunks[].artifacts[].hashes.sha256") + .description(DdiApiModelProperties.ARTIFACT_HASHES_SHA256), + fieldWithPath("confirmation.chunks[].artifacts[].size") + .description(DdiApiModelProperties.ARTIFACT_SIZE), + fieldWithPath("confirmation.chunks[].artifacts[]._links.download") + .description(DdiApiModelProperties.ARTIFACT_HTTPS_DOWNLOAD_LINK_BY_CONTROLLER), + fieldWithPath("confirmation.chunks[].artifacts[]._links.md5sum") + .description(DdiApiModelProperties.ARTIFACT_HTTPS_HASHES_MD5SUM_LINK), + fieldWithPath("confirmation.chunks[].artifacts[]._links.download-http") + .description(DdiApiModelProperties.ARTIFACT_HTTP_DOWNLOAD_LINK_BY_CONTROLLER), + fieldWithPath("confirmation.chunks[].artifacts[]._links.md5sum-http") + .description(DdiApiModelProperties.ARTIFACT_HTTP_HASHES_MD5SUM_LINK), + fieldWithPath("actionHistory").description(DdiApiModelProperties.ACTION_HISTORY_RESP), + fieldWithPath("actionHistory.status") + .description(DdiApiModelProperties.ACTION_HISTORY_RESP_STATUS), + fieldWithPath("actionHistory.messages") + .description(DdiApiModelProperties.ACTION_HISTORY_RESP_MESSAGES)))); + } + + @Test + @Description("Feedback channel for confirming an action") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void postConfirmationFeedback() throws Exception { + enableConfirmationFlow(); + + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + final Long actionId = getFirstAssignedActionId(assignDistributionSet(set.getId(), target.getControllerId())); + + final DdiConfirmationFeedback feedback = new DdiConfirmationFeedback( + DdiConfirmationFeedback.Confirmation.CONFIRMED, 33, List.of("Feedback message")); + + mockMvc.perform( + post(DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + + "/{actionId}/feedback", tenantAware.getCurrentTenant(), target.getControllerId(), actionId) + .content(objectMapper.writeValueAsString(feedback)) + .contentType(MediaType.APPLICATION_JSON_UTF8).accept(MediaTypes.HAL_JSON_VALUE)) + .andDo(MockMvcResultPrinter.print()).andExpect( + status().isOk()) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID), + parameterWithName("actionId").description(DdiApiModelProperties.ACTION_ID)), + + requestFields( + requestFieldWithPath("confirmation") + .description(DdiApiModelProperties.TARGET_CONFIRMATION_STATE).type("enum") + .attributes(key("value").value("['confirmed', 'denied']")), + + optionalRequestFieldWithPath("code") + .description(DdiApiModelProperties.TARGET_CONFIRMATION_CODE), + + optionalRequestFieldWithPath("details") + .description(DdiApiModelProperties.TARGET_CONFIRMATION_DETAILS)))); + } + + @Test + @Description("Resource to activate auto-confirmation on a target.") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void activateAutoConfirmation() throws Exception { + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + + final DdiActivateAutoConfirmation body = new DdiActivateAutoConfirmation("exampleUser", "exampleRemark"); + + mockMvc.perform(post( + DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/" + + DdiRestConstants.AUTO_CONFIRM_ACTIVATE, + tenantAware.getCurrentTenant(), target.getControllerId()).content(objectMapper.writeValueAsString(body)) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID)), + requestFields( + optionalRequestFieldWithPath("initiator") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_ACTIVATE_INITIATOR), + optionalRequestFieldWithPath("remark") + .description(DdiApiModelProperties.TARGET_AUTO_CONFIRM_ACTIVATE_REMARK)))); + } + + @Test + @Description("Resource to deactivate auto-confirmation on a target.") + @WithUser(tenantId = "TENANT_ID", authorities = "ROLE_CONTROLLER", allSpPermissions = true) + public void deactivateAutoConfirmation() throws Exception { + final Target target = targetManagement.create(entityFactory.target().create().controllerId(CONTROLLER_ID)); + + mockMvc.perform(post( + DdiRestConstants.BASE_V1_REQUEST_MAPPING + "/{controllerId}/" + DdiRestConstants.CONFIRMATION_BASE + "/" + + DdiRestConstants.AUTO_CONFIRM_DEACTIVATE, + tenantAware.getCurrentTenant(), target.getControllerId())).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()) + .andDo(this.document.document( + pathParameters(parameterWithName("tenant").description(ApiModelPropertiesGeneric.TENANT), + parameterWithName("controllerId").description(DdiApiModelProperties.CONTROLLER_ID)))); + } + } diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/AbstractApiRestDocumentation.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/AbstractApiRestDocumentation.java index 5935d2222..b370a7dee 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/AbstractApiRestDocumentation.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/AbstractApiRestDocumentation.java @@ -192,7 +192,7 @@ public abstract class AbstractApiRestDocumentation extends AbstractRestIntegrati final Rollout rollout = testdataFactory.createRolloutByVariables("rollout", "rollout desc", 1, "name==" + name, distributionSet, "50", "5", timeforced ? ActionType.TIMEFORCED : ActionType.FORCED, - isMultiAssignmentsEnabled() ? 600 : null); + isMultiAssignmentsEnabled() ? 600 : null, isConfirmationFlowActive()); // start the rollout and handle it rolloutManagement.start(rollout.getId()); @@ -280,6 +280,8 @@ public abstract class AbstractApiRestDocumentation extends AbstractRestIntegrati fieldWithPath(fieldArrayPrefix + "securityToken").description(MgmtApiModelProperties.SECURITY_TOKEN), fieldWithPath(fieldArrayPrefix + "requestAttributes") .description(MgmtApiModelProperties.REQUEST_ATTRIBUTES), + fieldWithPath(fieldArrayPrefix + "autoConfirmActive") + .description(MgmtApiModelProperties.AUTO_CONFIRM_ACTIVE), fieldWithPath(fieldArrayPrefix + "installedAt").description(MgmtApiModelProperties.INSTALLED_AT), fieldWithPath(fieldArrayPrefix + "lastModifiedAt") .description(ApiModelPropertiesGeneric.LAST_MODIFIED_AT).type("Number"), @@ -314,7 +316,9 @@ public abstract class AbstractApiRestDocumentation extends AbstractRestIntegrati .description(MgmtApiModelProperties.LINKS_ACTIONS), fieldWithPath(fieldArrayPrefix + "_links.metadata").description(MgmtApiModelProperties.META_DATA), fieldWithPath(fieldArrayPrefix + "_links.targetType") - .description(MgmtApiModelProperties.LINK_TO_TARGET_TYPE))); + .description(MgmtApiModelProperties.LINK_TO_TARGET_TYPE), + fieldWithPath(fieldArrayPrefix + "_links.autoConfirm") + .description(MgmtApiModelProperties.LINK_TO_AUTO_CONFIRM))); } fields.addAll(Arrays.asList(descriptors)); @@ -364,7 +368,12 @@ public abstract class AbstractApiRestDocumentation extends AbstractRestIntegrati protected boolean isMultiAssignmentsEnabled() { return Boolean.TRUE.equals(tenantConfigurationManagement - .getConfigurationValue(TenantConfigurationKey.MULTI_ASSIGNMENTS_ENABLED, Boolean.class).getValue()); + .getConfigurationValue(TenantConfigurationKey.MULTI_ASSIGNMENTS_ENABLED, Boolean.class).getValue()); + } + + protected boolean isConfirmationFlowActive() { + return Boolean.TRUE.equals(tenantConfigurationManagement + .getConfigurationValue(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, Boolean.class).getValue()); } } diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/MgmtApiModelProperties.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/MgmtApiModelProperties.java index 407a32efa..31373286e 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/MgmtApiModelProperties.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/documentation/MgmtApiModelProperties.java @@ -35,6 +35,8 @@ public final class MgmtApiModelProperties { public static final String LINK_TO_TARGET_TYPE = "The link to the target type."; public static final String LINK_TO_TARGET = "The link to the target."; + public static final String LINK_TO_AUTO_CONFIRM = "The link to the detailed auto confirm state."; + // software module types public static final String SMT_TYPE = "The type of the software module identified by its key."; public static final String SMT_VENDOR = "The software vendor of the entity."; @@ -93,6 +95,8 @@ public final class MgmtApiModelProperties { // rollout public static final String ROLLOUT_FILTER_QUERY = "target filter query language expression"; + public static final String ROLLOUT_CONFIRMATION_REQUIRED = "(available with user consent flow active) if the confirmation is required for this rollout. Value will be used if confirmation options are missing in the rollout group definitions. Confirmation is required per default"; + public static final String ROLLOUT_GROUP_CONFIRMATION_REQUIRED = "(available with user consent flow active) if the confirmation is required for this rollout group. Confirmation is required per default."; public static final String ROLLOUT_GROUP_FILTER_QUERY = "target filter query language expression that selects a subset of targets which match the target filter of the Rollout"; public static final String ROLLOUT_GROUP_TARGET_PERCENTAGE = "percentage of remaining and matching targets that should be added to this group"; public static final String ROLLOUT_DS_ID = "the ID of distributionset of this rollout"; @@ -144,6 +148,8 @@ public final class MgmtApiModelProperties { public static final String ACTION_FORCE_TYPE = "Force type of the action that provides a hint if the controller should apply the action immediately or whenever possible."; + public static final String ACTION_CONFIRMATION_REQUIRED = "(Available with user consent flow active) Defines, if the confirmation is required for an action. Confirmation is required per default."; + public static final String ACTION_FORCE_TIME = "In case of timeforced mode the difference, measured in milliseconds, between the time the action should switch to forced and midnight, January 1, 1970 UTC."; public static final String ACTION_FORCED = "Set to forced in order to switch action to forced mode."; @@ -178,6 +184,8 @@ public final class MgmtApiModelProperties { public static final String REQUEST_ATTRIBUTES = "Request re-transmission of target attributes."; + public static final String AUTO_CONFIRM_ACTIVE = "Present if user consent flow active. Indicates if auto-confirm is active"; + public static final String META_DATA = "List of metadata."; public static final String META_DATA_KEY = "Metadata property key."; @@ -186,6 +194,21 @@ public final class MgmtApiModelProperties { public static final String SM_META_DATA_TARGET_VISIBLE = "Metadata property is visible to targets as part of software update action."; + public static final String AUTO_CONFIRM_STATE_ACTIVE = "Flag if auto confirm is active"; + + public static final String AUTO_CONFIRM_STATE_INITIATOR = "(Optional) initiator set on activation"; + + public static final String AUTO_CONFIRM_STATE_REMARK = "(Optional) remark set on activation"; + + public static final String AUTO_CONFIRM_STATE_ACTIVATED_AT = "timestamp of the activation"; + + public static final String AUTO_CONFIRM_STATE_REFERENCE_ACTIVATE_AUTO_CONFIRM = "reference link to activate auto confirm (present if not active)"; + + public static final String AUTO_CONFIRM_STATE_REFERENCE_DEACTIVATE_AUTO_CONFIRM = "reference link to deactivate auto confirm (present if active)"; + + public static final String AUTO_CONFIRM_ACTIVATE_INITIATOR = "individual value (e.g. username) stored as initiator and automatically used as confirmed user in future actions"; + public static final String AUTO_CONFIRM_ACTIVATE_REMARK = "individual value to attach a remark which will be persisted when automatically confirming future actions"; + public static final String SM_TYPE_KEY = "Key that can be interpreted by the target."; public static final String SM_MAX_ASSIGNMENTS = "Maximum number of assignments to a distribution set/target, e.g. only one firmware but multiple applications."; @@ -225,6 +248,7 @@ public final class MgmtApiModelProperties { public static final String FORCETIME = "Forcetime in milliseconds."; public static final String FORCE = "Force as boolean."; public static final String ASSIGNMENT_WEIGHT = "Importance of the assignment."; + public static final String ASSIGNMENT_CONFIRMATION_REQUIRED = "(Available with user consent flow active) Specifies if the confirmation by the device is required for this action."; public static final String ASSIGNMENT_TYPE = "The type of the assignment."; public static final String TARGET_ASSIGNED = "The number of targets that have been assigned as part of this operation."; public static final String TARGET_ASSIGNED_ALREADY = "The number of targets which already had been the assignment."; diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java index 85871986d..b34a1243e 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java @@ -354,6 +354,8 @@ public class DistributionSetsDocumentationTest extends AbstractApiRestDocumentat @Description("Handles the POST request for assigning multiple targets to a distribution set.The request body must always be a list of target IDs." + " Required Permission: " + SpPermission.READ_REPOSITORY + " and " + SpPermission.UPDATE_TARGET) public void createAssignedTarget() throws Exception { + enableConfirmationFlow(); + final DistributionSet set = testdataFactory.createUpdatedDistributionSet(); // prepare targets @@ -396,7 +398,10 @@ public class DistributionSetsDocumentationTest extends AbstractApiRestDocumentat .description(MgmtApiModelProperties.MAINTENANCE_WINDOW_TIMEZONE), optionalRequestFieldWithPath("[].type") .description(MgmtApiModelProperties.ASSIGNMENT_TYPE).attributes( - key("value").value("['soft', 'forced','timeforced', 'downloadonly']"))), + key("value").value("['soft', 'forced','timeforced', 'downloadonly']")), + optionalRequestFieldWithPath("[].confirmationRequired") + .description(MgmtApiModelProperties.ACTION_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString())), responseFields( fieldWithPath("assigned").description(MgmtApiModelProperties.DS_NEW_ASSIGNED_TARGETS), fieldWithPath("alreadyAssigned").type(JsonFieldType.NUMBER) diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/RolloutResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/RolloutResourceDocumentationTest.java index 0a7ca772b..208938301 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/RolloutResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/RolloutResourceDocumentationTest.java @@ -51,8 +51,6 @@ import org.springframework.restdocs.payload.FieldDescriptor; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.restdocs.snippet.Snippet; -import com.fasterxml.jackson.core.JsonProcessingException; - import io.qameta.allure.Description; import io.qameta.allure.Feature; import io.qameta.allure.Story; @@ -181,6 +179,7 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati @Test @Description("Handles the POST request of creating a rollout. Required Permission: " + SpPermission.CREATE_ROLLOUT) public void createRollout() throws Exception { + enableConfirmationFlow(); testdataFactory.createTargets(20, "targets-"); @@ -212,6 +211,9 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati requestFieldWithPath("name").description(ApiModelPropertiesGeneric.NAME), optionalRequestFieldWithPath("type").description(MgmtApiModelProperties.ROLLOUT_TYPE) .attributes(key("value").value("['soft', 'forced', 'timeforced', 'downloadonly']")), + optionalRequestFieldWithPath("confirmationRequired") + .description(MgmtApiModelProperties.ROLLOUT_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString()), requestFieldWithPath("distributionSetId").description(MgmtApiModelProperties.ROLLOUT_DS_ID), requestFieldWithPath("targetFilterQuery") .description(MgmtApiModelProperties.ROLLOUT_FILTER_QUERY), @@ -254,6 +256,7 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati @Description("Handles the POST request of creating a rollout with a groups definition. Required Permission: " + SpPermission.CREATE_ROLLOUT) public void createRolloutWithGroupsDefinition() throws Exception { + enableConfirmationFlow(); final int amountTargets = 10; testdataFactory.createTargets(amountTargets, "targets-", "rollout"); @@ -285,11 +288,11 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati .successAction(RolloutGroupSuccessAction.NEXTGROUP, "") .errorCondition(RolloutGroupErrorCondition.THRESHOLD, "80") .errorAction(RolloutGroupErrorAction.PAUSE, "").build(); - mockMvc.perform( - post(MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING) - .content(JsonBuilder.rollout(name, description, null, dsId, targetFilter, - rolloutGroupConditions, rolloutGroups)) - .contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.HAL_JSON_VALUE)) + mockMvc.perform(post(MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING) + .content(JsonBuilder.rolloutWithGroups(name, description, null, dsId, targetFilter, + rolloutGroupConditions, rolloutGroups, null, null, true)) + .contentType( + MediaType.APPLICATION_JSON).accept(MediaTypes.HAL_JSON_VALUE)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()) .andExpect(content().contentType(MediaTypes.HAL_JSON)) .andDo(this.document.document( @@ -302,6 +305,8 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati .description(MgmtApiModelProperties.ROLLOUT_DS_ID), requestFieldWithPath("targetFilterQuery") .description(MgmtApiModelProperties.ROLLOUT_FILTER_QUERY), + requestFieldWithPath("confirmationRequired") + .description(MgmtApiModelProperties.ROLLOUT_CONFIRMATION_REQUIRED), optionalRequestFieldWithPath("description") .description(ApiModelPropertiesGeneric.DESCRPTION), optionalRequestFieldWithPath("successCondition") @@ -338,6 +343,8 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati .description(ApiModelPropertiesGeneric.DESCRPTION), optionalRequestFieldWithPath("groups[].targetFilterQuery") .description(MgmtApiModelProperties.ROLLOUT_GROUP_FILTER_QUERY), + optionalRequestFieldWithPath("groups[].confirmationRequired") + .description(MgmtApiModelProperties.ROLLOUT_GROUP_CONFIRMATION_REQUIRED), optionalRequestFieldWithPath("groups[].targetPercentage") .description(MgmtApiModelProperties.ROLLOUT_GROUP_TARGET_PERCENTAGE) .attributes(key("value").value("0..100")), @@ -467,6 +474,8 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati @Description("Handles the GET request of retrieving the deploy groups of a rollout. Required Permission: " + SpPermission.READ_ROLLOUT) public void getRolloutDeployGroups() throws Exception { + enableConfirmationFlow(); + final Rollout rollout = createRolloutEntity(); mockMvc.perform(get(MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING + "/{rolloutId}/deploygroups", rollout.getId()) @@ -485,6 +494,8 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati @Description("Handles the GET request of retrieving a deploy group of a rollout. Required Permission: " + SpPermission.READ_ROLLOUT) public void getRolloutDeployGroup() throws Exception { + enableConfirmationFlow(); + final Rollout rollout = createRolloutEntity(); final RolloutGroup firstRolloutGroup = rolloutGroupManagement.findByRollout(PAGE, rollout.getId()).getContent() .get(0); @@ -499,7 +510,7 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati } private Snippet getRolloutDeployGroupResponseFields(final boolean isArray, final boolean withDetails, - final FieldDescriptor... descriptors) throws JsonProcessingException { + final FieldDescriptor... descriptors) { final String arrayPrefix = getArrayPrefix(isArray); final List allFieldDescriptor = new ArrayList<>(); allFieldDescriptor.addAll(Arrays.asList(descriptors)); @@ -534,6 +545,9 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati allFieldDescriptor.add(fieldWithPath(arrayPrefix + "targetPercentage") .description(MgmtApiModelProperties.ROLLOUT_GROUP_TARGET_PERCENTAGE)); + allFieldDescriptor.add(fieldWithPath(arrayPrefix + "confirmationRequired") + .description(MgmtApiModelProperties.ROLLOUT_GROUP_CONFIRMATION_REQUIRED)); + allFieldDescriptor.add(fieldWithPath(arrayPrefix + "successCondition") .description(MgmtApiModelProperties.ROLLOUT_SUCCESS_CONDITION)); allFieldDescriptor.add(fieldWithPath(arrayPrefix + "successCondition.condition") @@ -663,7 +677,7 @@ public class RolloutResourceDocumentationTest extends AbstractApiRestDocumentati if (isMultiAssignmentsEnabled()) { rolloutCreate.weight(400); } - final Rollout rollout = rolloutManagement.create(rolloutCreate, 5, new RolloutGroupConditionBuilder() + final Rollout rollout = rolloutManagement.create(rolloutCreate, 5, false, new RolloutGroupConditionBuilder() .withDefaults().successCondition(RolloutGroupSuccessCondition.THRESHOLD, "10").build()); // Run here, because Scheduler is disabled during tests diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java index 2ac45172e..1e47462d5 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java @@ -61,7 +61,6 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes @Test @Description("Handles the GET request of retrieving all target filter queries within SP. Required Permission: READ_TARGET.") public void getTargetFilterQueries() throws Exception { - createTargetFilterQueryWithDS(createDistributionSet()); mockMvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING)).andExpect(status().isOk()) @@ -80,6 +79,12 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes .description(MgmtApiModelProperties.ACTION_FORCE_TYPE) .type(JsonFieldType.STRING.toString()) .attributes(key("value").value("['forced', 'soft', 'downloadonly']")), + fieldWithPath("content[].autoAssignWeight") + .description(MgmtApiModelProperties.RESULTING_ACTIONS_WEIGHT) + .type(JsonFieldType.NUMBER.toString()), + fieldWithPath("content[].confirmationRequired") + .description(MgmtApiModelProperties.ACTION_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString()), fieldWithPath("content[].createdAt").description(ApiModelPropertiesGeneric.CREATED_AT), fieldWithPath("content[].createdBy").description(ApiModelPropertiesGeneric.CREATED_BY), fieldWithPath("content[].lastModifiedAt") @@ -182,6 +187,8 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes @Description("Handles the POST request of setting a distribution set for auto assignment within SP. Required Permission: CREATE_TARGET.") public void postAutoAssignDS() throws Exception { enableMultiAssignments(); + enableConfirmationFlow(); + final TargetFilterQuery tfq = createTargetFilterQuery(); final DistributionSet distributionSet = createDistributionSet(); final String autoAssignBody = new JSONObject().put("id", distributionSet.getId()) @@ -201,7 +208,10 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes .attributes(key("value").value("['forced', 'soft', 'downloadonly']")), requestFieldWithPathMandatoryInMultiAssignMode("weight") .description(MgmtApiModelProperties.RESULTING_ACTIONS_WEIGHT) - .attributes(key("value").value("0 - 1000"))), + .attributes(key("value").value("0 - 1000")), + optionalRequestFieldWithPath("confirmationRequired") + .description(MgmtApiModelProperties.ACTION_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString())), getResponseFieldTargetFilterQuery(false))); } @@ -232,6 +242,9 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes fieldWithPath(arrayPrefix + "autoAssignWeight") .description(MgmtApiModelProperties.RESULTING_ACTIONS_WEIGHT) .type(JsonFieldType.NUMBER.toString()), + fieldWithPath(arrayPrefix + "confirmationRequired") + .description(MgmtApiModelProperties.ACTION_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString()), fieldWithPath(arrayPrefix + "createdAt").description(ApiModelPropertiesGeneric.CREATED_AT), fieldWithPath(arrayPrefix + "createdBy").description(ApiModelPropertiesGeneric.CREATED_BY), fieldWithPath(arrayPrefix + "lastModifiedAt").description(ApiModelPropertiesGeneric.LAST_MODIFIED_AT), diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetResourceDocumentationTest.java index c14c24217..069d1859a 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetResourceDocumentationTest.java @@ -9,6 +9,9 @@ package org.eclipse.hawkbit.rest.mgmt.documentation; import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_V1_ACTIVATE_AUTO_CONFIRM; +import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_V1_AUTO_CONFIRM; +import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_V1_DEACTIVATE_AUTO_CONFIRM; import static org.hamcrest.CoreMatchers.equalTo; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; @@ -31,6 +34,7 @@ import java.util.List; import java.util.Map; import org.eclipse.hawkbit.im.authentication.SpPermission; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirmUpdate; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; import org.eclipse.hawkbit.repository.ActionStatusFields; import org.eclipse.hawkbit.repository.model.Action; @@ -78,6 +82,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio @Test @Description("Handles the GET request of retrieving all targets within SP. Required Permission: READ_TARGET.") public void getTargets() throws Exception { + enableConfirmationFlow(); + createTargetByGivenNameWithAttributes(targetId, createDistributionSet()); mockMvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING)).andExpect(status().isOk()) @@ -98,9 +104,11 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio fieldWithPath("content[].securityToken").description(MgmtApiModelProperties.SECURITY_TOKEN), fieldWithPath("content[].requestAttributes") .description(MgmtApiModelProperties.REQUEST_ATTRIBUTES), + fieldWithPath("content[].autoConfirmActive") + .description(MgmtApiModelProperties.AUTO_CONFIRM_ACTIVE), fieldWithPath("content[].installedAt").description(MgmtApiModelProperties.INSTALLED_AT), - fieldWithPath("content[].lastModifiedAt") - .description(ApiModelPropertiesGeneric.LAST_MODIFIED_AT).type("Number"), + fieldWithPath("content[].lastModifiedAt").description( + ApiModelPropertiesGeneric.LAST_MODIFIED_AT).type("Number"), fieldWithPath("content[].lastModifiedBy") .description(ApiModelPropertiesGeneric.LAST_MODIFIED_BY).type("String"), fieldWithPath("content[].ipAddress").description(MgmtApiModelProperties.IP_ADDRESS) @@ -125,6 +133,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio @Test @Description("Handles the POST request of creating new targets within SP. The request body must always be a list of targets. Required Permission: CREATE_TARGET.") public void postTargets() throws Exception { + enableConfirmationFlow(); + final TargetType defaultType = testdataFactory.createTargetType("defaultType", Collections.emptyList()); final String target = createTargetJsonForPostRequest("123456", "controllerId", "test", defaultType); @@ -157,6 +167,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio fieldWithPath("[]securityToken").description(MgmtApiModelProperties.SECURITY_TOKEN), fieldWithPath("[]requestAttributes") .description(MgmtApiModelProperties.REQUEST_ATTRIBUTES), + fieldWithPath("[]autoConfirmActive") + .description(MgmtApiModelProperties.AUTO_CONFIRM_ACTIVE), fieldWithPath("[]targetType").description(MgmtApiModelProperties.TARGETTYPE_ID), fieldWithPath("[]targetTypeName").description(MgmtApiModelProperties.TARGETTYPE_NAME), fieldWithPath("[]_links.self").ignored()))); @@ -175,6 +187,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio @Test @Description("Handles the GET request of retrieving a single target within SP. Required Permission: READ_TARGET.") public void getTarget() throws Exception { + enableConfirmationFlow(); + final Target target = createTargetByGivenNameWithAttributes(targetId, createDistributionSet()); mockMvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}", target.getControllerId())) @@ -187,6 +201,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio @Test @Description("Handles the PUT request of updating a target within SP. Required Permission: UPDATE_TARGET.") public void putTarget() throws Exception { + enableConfirmationFlow(); + final Target target = createTargetByGivenNameWithAttributes(targetId, createDistributionSet()); final String targetAsJson = createJsonTarget(targetId, "newTargetName", "I've been updated"); @@ -504,7 +520,9 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio .description(MgmtApiModelProperties.ACTION_STATUS_CODE).type("Integer"), fieldWithPath("content[].type").description(MgmtApiModelProperties.ACTION_STATUS_TYPE) .attributes(key("value").value( - "['finished', 'error', 'warning', 'pending', 'running', 'canceled', 'retrieved', 'canceling']"))))); + "['finished', 'error', 'warning', 'running', 'canceled', 'canceling', " // + + "'retrieved', 'download', 'scheduled', 'cancel_rejected', " // + + "'downloaded', 'wait_for_confirmation']"))))); } @Test @@ -565,6 +583,9 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio .description(MgmtApiModelProperties.ASSIGNMENT_WEIGHT) .type(JsonFieldType.NUMBER).attributes(key("value").value("0 - 1000")), optionalRequestFieldWithPath("forcetime").description(MgmtApiModelProperties.FORCETIME), + optionalRequestFieldWithPath("[].confirmationRequired") + .description(MgmtApiModelProperties.ASSIGNMENT_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString()), optionalRequestFieldWithPath("maintenanceWindow") .description(MgmtApiModelProperties.MAINTENANCE_WINDOW), optionalRequestFieldWithPath("maintenanceWindow.schedule") @@ -594,7 +615,8 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio new JSONObject().put("schedule", getTestSchedule(100)) .put("duration", getTestDuration(10)).put("timezone", getTestTimeZone()))) .toString(); - body.put(new JSONObject().put("id", sets.get(0).getId()).put("type", "forced").put("weight", 800)); + body.put(new JSONObject().put("id", sets.get(0).getId()).put("type", "forced").put("weight", 800) + .put("confirmationRequired", true)); enableMultiAssignments(); mockMvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" @@ -611,6 +633,9 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio .attributes(key("value").value("0 - 1000")), optionalRequestFieldWithPath("[].forcetime") .description(MgmtApiModelProperties.FORCETIME), + optionalRequestFieldWithPath("[].confirmationRequired") + .description(MgmtApiModelProperties.ASSIGNMENT_CONFIRMATION_REQUIRED) + .type(JsonFieldType.BOOLEAN.toString()), optionalRequestFieldWithPath("[].maintenanceWindow") .description(MgmtApiModelProperties.MAINTENANCE_WINDOW), optionalRequestFieldWithPath("[].maintenanceWindow.schedule") @@ -869,6 +894,76 @@ public class TargetResourceDocumentationTest extends AbstractApiRestDocumentatio pathParameters(parameterWithName("targetId").description(ApiModelPropertiesGeneric.ITEM_ID)))); } + @Test + @Description("Handles the GET request of retrieving the auto-confirm state of a target. In this case auto-confirm is active for the requested target. Required Permission: READ_TARGET.") + public void getTargetAutoConfirmStateActive() throws Exception { + final Target testTarget = testdataFactory.createTarget(targetId); + confirmationManagement.activateAutoConfirmation(testTarget.getControllerId(), "custom_initiator_value", + "custom_remark"); + + mockMvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM, + testTarget.getControllerId())).andExpect(status().isOk()).andDo(MockMvcResultPrinter.print()) + .andDo(this.document.document( + pathParameters(parameterWithName("targetId").description(ApiModelPropertiesGeneric.ITEM_ID)), + responseFields( + fieldWithPath("active").description(MgmtApiModelProperties.AUTO_CONFIRM_STATE_ACTIVE), + fieldWithPath("initiator") + .description(MgmtApiModelProperties.AUTO_CONFIRM_STATE_INITIATOR), + fieldWithPath("remark").description(MgmtApiModelProperties.AUTO_CONFIRM_STATE_REMARK), + fieldWithPath("activatedAt") + .description(MgmtApiModelProperties.AUTO_CONFIRM_STATE_ACTIVATED_AT), + fieldWithPath("_links.deactivate").optional().description( + MgmtApiModelProperties.AUTO_CONFIRM_STATE_REFERENCE_DEACTIVATE_AUTO_CONFIRM)))); + } + + @Test + @Description("Handles the GET request of retrieving the auto-confirm state of a target. In this case auto-confirm is not active for the requested target. Required Permission: READ_TARGET.") + public void getTargetAutoConfirmStateNotActive() throws Exception { + final Target testTarget = testdataFactory.createTarget(targetId); + + mockMvc.perform(get(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM, + testTarget.getControllerId())).andExpect(status().isOk()).andDo(MockMvcResultPrinter.print()) + .andDo(this.document.document( + pathParameters(parameterWithName("targetId").description(ApiModelPropertiesGeneric.ITEM_ID)), + responseFields( + fieldWithPath("active").description(MgmtApiModelProperties.AUTO_CONFIRM_STATE_ACTIVE), + fieldWithPath("_links.activate").optional().description( + MgmtApiModelProperties.AUTO_CONFIRM_STATE_REFERENCE_ACTIVATE_AUTO_CONFIRM)))); + } + + @Test + @Description("Handles the POST request to activate auto-confirm on a target. Payload can be provided to specify more details about the operation. Required Permission: UPDATE_TARGET.") + public void postActivateAutoConfirm() throws Exception { + final Target testTarget = testdataFactory.createTarget(targetId); + + final MgmtTargetAutoConfirmUpdate body = new MgmtTargetAutoConfirmUpdate("custom_initiator_value", + "custom_remark_value"); + + mockMvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM + "/" + + TARGET_V1_ACTIVATE_AUTO_CONFIRM, testTarget.getControllerId()) + .content(this.objectMapper.writeValueAsString(body)).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andDo(MockMvcResultPrinter.print()) + .andDo(this.document.document( + pathParameters(parameterWithName("targetId").description(ApiModelPropertiesGeneric.ITEM_ID)), + requestFields( + optionalRequestFieldWithPath("initiator") + .description(MgmtApiModelProperties.AUTO_CONFIRM_ACTIVATE_INITIATOR), + optionalRequestFieldWithPath("remark") + .description(MgmtApiModelProperties.AUTO_CONFIRM_ACTIVATE_REMARK)))); + } + + @Test + @Description("Handles the POST request to deactivate auto-confirm on a target. Required Permission: UPDATE_TARGET.") + public void postDeactivateAutoConfirm() throws Exception { + final Target testTarget = testdataFactory.createTarget(targetId); + confirmationManagement.activateAutoConfirmation(testTarget.getControllerId(), null, null); + + mockMvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/" + TARGET_V1_AUTO_CONFIRM + "/" + + TARGET_V1_DEACTIVATE_AUTO_CONFIRM, testTarget.getControllerId())).andExpect(status().isOk()) + .andDo(MockMvcResultPrinter.print()).andDo(this.document.document( + pathParameters(parameterWithName("targetId").description(ApiModelPropertiesGeneric.ITEM_ID)))); + } + private String createTargetJsonForPostRequest(final String controllerId, final String name, final String description, final TargetType targetType) throws JsonProcessingException { final Map target = new HashMap<>(); diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetTagResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetTagResourceDocumentationTest.java index 2d1773d21..f2de5dfd0 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetTagResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetTagResourceDocumentationTest.java @@ -153,6 +153,8 @@ public class TargetTagResourceDocumentationTest extends AbstractApiRestDocumenta @Test @Description("Handles the GET request of retrieving all assigned targets by the given") public void getAssignedTargets() throws Exception { + enableConfirmationFlow(); + final TargetTag tag = createTargetTagEntitiy(); final Target target = createTargetByGivenNameWithAttributes("Target1", distributionSet); targetManagement.assignTag(Arrays.asList(target.getControllerId()), tag.getId()); @@ -200,6 +202,8 @@ public class TargetTagResourceDocumentationTest extends AbstractApiRestDocumenta @Test @Description("Handles the POST request to assign targets to the given tag id") public void assignTargets() throws Exception { + enableConfirmationFlow(); + final Long tagId = createTargetTagId(); final MgmtAssignedTargetRequestBody assignedTargetRequestBody1 = new MgmtAssignedTargetRequestBody(); assignedTargetRequestBody1 diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TenantResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TenantResourceDocumentationTest.java index 442d1d908..61d96a40f 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TenantResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TenantResourceDocumentationTest.java @@ -91,6 +91,8 @@ public class TenantResourceDocumentationTest extends AbstractApiRestDocumentatio "if multiple distribution sets can be assigned to the same targets."); CONFIG_ITEM_DESCRIPTIONS.put(TenantConfigurationKey.BATCH_ASSIGNMENTS_ENABLED, "if distribution set can be assigned to multiple targets in a single batch message."); + CONFIG_ITEM_DESCRIPTIONS.put(TenantConfigurationKey.USER_CONFIRMATION_ENABLED, + "if confirmation is required when distribution set is assigned to target."); } @Autowired diff --git a/hawkbit-security-core/src/main/java/org/eclipse/hawkbit/im/authentication/SpPermission.java b/hawkbit-security-core/src/main/java/org/eclipse/hawkbit/im/authentication/SpPermission.java index 7eb69a895..fcc751b4f 100644 --- a/hawkbit-security-core/src/main/java/org/eclipse/hawkbit/im/authentication/SpPermission.java +++ b/hawkbit-security-core/src/main/java/org/eclipse/hawkbit/im/authentication/SpPermission.java @@ -413,6 +413,14 @@ public final class SpPermission { public static final String HAS_AUTH_TENANT_CONFIGURATION = HAS_AUTH_PREFIX + TENANT_CONFIGURATION + HAS_AUTH_SUFFIX + HAS_AUTH_OR + IS_SYSTEM_CODE; + /** + * Spring security eval hasAuthority expression to check if spring + * context contains {@link SpPermission#IS_CONTROLLER} or + * {@link #HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET}. + */ + public static final String IS_CONTROLLER_OR_HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET = + IS_CONTROLLER + HAS_AUTH_OR + HAS_AUTH_READ_REPOSITORY_AND_UPDATE_TARGET; + private SpringEvalExpressions() { // utility class } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java index cc0520bae..0829b8f87 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/UiProperties.java @@ -254,6 +254,15 @@ public class UiProperties implements Serializable { */ private String maintenanceWindowView = ""; + /** + * Link to documentation of the user consent and confirmation flow. + */ + private String userConsentAndConfirmationGuide = ""; + + public String getAutoConfirmationView() { + return autoConfirmationView; + } + /** * Link to documentation of system configuration view. */ @@ -279,6 +288,11 @@ public class UiProperties implements Serializable { */ private String rolloutView = ""; + /** + * Link to documentation of auto confirmation view. + */ + private String autoConfirmationView = ""; + /** * Link to documentation of state machine */ @@ -353,6 +367,13 @@ public class UiProperties implements Serializable { return maintenanceWindowView; } + /** + * @return Link to documentation of the user consent and confirmation flow. + */ + public String getUserConsentAndConfirmationGuide() { + return userConsentAndConfirmationGuide; + } + /** * @return Link to documentation of provisioning state machine */ @@ -425,6 +446,10 @@ public class UiProperties implements Serializable { this.maintenanceWindowView = maintenanceWindowView; } + public void setUserConsentAndConfirmationGuide(final String userConsentAndConfirmationGuide) { + this.userConsentAndConfirmationGuide = userConsentAndConfirmationGuide; + } + public void setProvisioningStateMachine(final String provisioningStateMachine) { this.provisioningStateMachine = provisioningStateMachine; } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/artifacts/UploadArtifactView.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/artifacts/UploadArtifactView.java index 10c931bd1..b9577cb68 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/artifacts/UploadArtifactView.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/artifacts/UploadArtifactView.java @@ -302,22 +302,22 @@ public class UploadArtifactView extends AbstractEventListenersAwareView implemen @Override public void beforeLeave(final ViewBeforeLeaveEvent event) { if (isAnyUploadInUploadQueue()) { - final ConfirmationDialog confirmDeleteDialog = new ConfirmationDialog(i18n, - i18n.getMessage(UIMessageIdProvider.CAPTION_CLEAR_FILE_UPLOAD_QUEUE), - i18n.getMessage(UIMessageIdProvider.MESSAGE_CLEAR_FILE_UPLOAD_QUEUE), ok -> { - if (Boolean.TRUE.equals(ok)) { - // Clear all queued file uploads - artifactUploadState.clearFileStates(); - super.beforeLeave(event); - } else { - // Send a PostViewChangeEvent to the DashboardMenu - // as if the navigation actually - // happened to prevent the DashboardMenu navigation - // from getting stuck - final DashboardMenuItem dashboardMenuItem = dashboardMenu.getByViewName(VIEW_NAME); - dashboardMenu.postViewChange(DashboardEvent.createPostViewChangeEvent(dashboardMenuItem)); - } - }, UIComponentIdProvider.UPLOAD_QUEUE_CLEAR_CONFIRMATION_DIALOG); + final ConfirmationDialog confirmDeleteDialog = ConfirmationDialog + .newBuilder(i18n, UIComponentIdProvider.UPLOAD_QUEUE_CLEAR_CONFIRMATION_DIALOG) + .caption(i18n.getMessage(UIMessageIdProvider.CAPTION_CLEAR_FILE_UPLOAD_QUEUE)) + .question(i18n.getMessage(UIMessageIdProvider.MESSAGE_CLEAR_FILE_UPLOAD_QUEUE)) + .onSaveOrUpdate(() -> { + // Clear all queued file uploads + artifactUploadState.clearFileStates(); + super.beforeLeave(event); + }).onCancel(() -> { + // Send a PostViewChangeEvent to the DashboardMenu + // as if the navigation actually + // happened to prevent the DashboardMenu navigation + // from getting stuck + final DashboardMenuItem dashboardMenuItem = dashboardMenu.getByViewName(VIEW_NAME); + dashboardMenu.postViewChange(DashboardEvent.createPostViewChangeEvent(dashboardMenuItem)); + }).build(); UI.getCurrent().addWindow(confirmDeleteDialog.getWindow()); confirmDeleteDialog.getWindow().bringToFront(); } else { diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/ConfirmationDialog.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/ConfirmationDialog.java index 6ad975db9..ee85b52d7 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/ConfirmationDialog.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/ConfirmationDialog.java @@ -19,6 +19,7 @@ import org.eclipse.hawkbit.ui.utils.HawkbitCommonUtil; import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; import org.eclipse.hawkbit.ui.utils.SPUIStyleDefinitions; import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; +import org.springframework.util.Assert; import org.springframework.util.StringUtils; import com.vaadin.server.Resource; @@ -37,30 +38,12 @@ public class ConfirmationDialog implements Serializable { private static final long serialVersionUID = 1L; - private final transient Consumer callback; + private final transient Runnable callback; private final CommonDialogWindow window; - /** - * Constructor for configuring confirmation dialog. - * - * @param i18n - * internationalization - * @param caption - * the dialog caption. - * @param question - * the question. - * @param callback - * the callback. - * @param tab - * ConfirmationTab which contains more information about the - * action which has to be confirmed, e.g. maintenance window - * @param id - * the id of the confirmation window - */ - public ConfirmationDialog(final VaadinMessageSource i18n, final String caption, final String question, - final Consumer callback, final Component tab, final String id) { - this(i18n, caption, question, callback, null, id, tab); + public static Builder newBuilder(final VaadinMessageSource i18n, final String id) { + return new Builder(i18n, id); } /** @@ -72,51 +55,43 @@ public class ConfirmationDialog implements Serializable { * the dialog caption. * @param question * the question. - * @param callback - * the callback. - * @param id - * the id of the confirmation dialog - */ - public ConfirmationDialog(final VaadinMessageSource i18n, final String caption, final String question, - final Consumer callback, final String id) { - this(i18n, caption, question, callback, null, id, null); - } - - /** - * Constructor for configuring confirmation dialog. - * - * @param i18n - * internationalization - * @param caption - * the dialog caption. - * @param question - * the question. - * @param callback - * the callback. + * @param hint + * added in the dialog + * @param onSaveOrUpdate + * the callback onSaveOrUpdate. + * @param onCancel + * the callback on cancel. * @param icon * the icon of the dialog * @param id * the id of the confirmation dialog * @param tab - * ConfirmationTab which contains more information about the - * action which has to be confirmed, e.g. maintenance window + * ConfirmationTab which contains more information about the action + * which has to be confirmed, e.g. maintenance window */ - public ConfirmationDialog(final VaadinMessageSource i18n, final String caption, final String question, - final Consumer callback, final Resource icon, final String id, final Component tab) { + private ConfirmationDialog(final VaadinMessageSource i18n, final String caption, final String question, + final String hint, final Runnable onSaveOrUpdate, final Runnable onCancel, final Resource icon, + final String id, final Component tab) { final VerticalLayout content = new VerticalLayout(); content.setMargin(false); content.setSpacing(false); if (question != null) { - content.addComponent(createConfirmationQuestion(question)); + content.addComponent(createConfirmationLabel(question)); + } + if (hint != null) { + content.addComponent(createConfirmationLabel(hint)); } if (tab != null) { content.addComponent(tab); } final WindowBuilder windowBuilder = new WindowBuilder(SPUIDefinitions.CONFIRMATION_WINDOW).caption(caption) - .content(content).cancelButtonClickListener(e -> callback.accept(false)) - .saveDialogCloseListener(getSaveDialogCloseListener()).hideMandatoryExplanation() + .content(content).cancelButtonClickListener(e -> { + if (onCancel != null) { + onCancel.run(); + } + }).saveDialogCloseListener(getSaveDialogCloseListener()).hideMandatoryExplanation() .buttonDecorator(SPUIButtonStyleTiny.class).confirmStyle(ConfirmStyle.OK).i18n(i18n); if (!StringUtils.isEmpty(id)) { @@ -124,7 +99,7 @@ public class ConfirmationDialog implements Serializable { } this.window = windowBuilder.buildCommonDialogWindow(); window.setSaveButtonEnabled(true); - this.callback = callback; + this.callback = onSaveOrUpdate; if (icon != null) { window.setIcon(icon); @@ -137,7 +112,9 @@ public class ConfirmationDialog implements Serializable { return new SaveDialogCloseListener() { @Override public void saveOrUpdate() { - callback.accept(true); + if (callback != null) { + callback.run(); + } } @Override @@ -147,10 +124,10 @@ public class ConfirmationDialog implements Serializable { }; } - private static Label createConfirmationQuestion(final String question) { + private static Label createConfirmationLabel(final String text) { // ContentMode.HTML is used instead of ContentMode.PREFORMATTED here due // to no linebreaks if an entity name is very long - final String questionHtmlSave = HawkbitCommonUtil.sanitizeHtml(question); + final String questionHtmlSave = HawkbitCommonUtil.sanitizeHtml(text); final Label questionLbl = new Label(questionHtmlSave, ContentMode.HTML); questionLbl.setWidth(100, Unit.PERCENTAGE); questionLbl.addStyleName(SPUIStyleDefinitions.CONFIRMBOX_QUESTION_LABEL); @@ -175,4 +152,75 @@ public class ConfirmationDialog implements Serializable { return window; } + /** + * Builder for a confirmation dialog + */ + public static class Builder { + + private final VaadinMessageSource i18n; + private final String id; + private String caption; + private String question; + private String hint; + private Resource icon; + private Component tab; + + private Runnable callbackOnSaveOrUpdate; + + private Runnable callbackOnCancel; + + /** + * private constructor + * + * @param i18n + * required field + * @param id + * required field + */ + Builder(final VaadinMessageSource i18n, final String id) { + this.i18n = i18n; + this.id = id; + } + + public Builder caption(final String caption) { + this.caption = caption; + return this; + } + + public Builder question(final String question) { + this.question = question; + return this; + } + + public Builder hint(final String hint) { + this.hint = hint; + return this; + } + + public Builder icon(final Resource icon) { + this.icon = icon; + return this; + } + + public Builder tab(final Component tab) { + this.tab = tab; + return this; + } + + public Builder onSaveOrUpdate(final Runnable callback) { + this.callbackOnSaveOrUpdate = callback; + return this; + } + + public Builder onCancel(final Runnable callback) { + this.callbackOnCancel = callback; + return this; + } + + public ConfirmationDialog build() { + Assert.isTrue(StringUtils.hasText(caption), "Caption cannot be null."); + return new ConfirmationDialog(i18n, caption, question, hint, callbackOnSaveOrUpdate, callbackOnCancel, icon, + id, tab); + } + } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/FormComponentBuilder.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/FormComponentBuilder.java index 354ca1bb5..ab331ac8c 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/FormComponentBuilder.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/builder/FormComponentBuilder.java @@ -77,14 +77,14 @@ public final class FormComponentBuilder { * @return the TextField with its Binding */ public static BoundComponent createNameInput(final Binder binder, - final VaadinMessageSource i18n, final String fieldId) { + final VaadinMessageSource i18n, final String fieldId) { final TextField nameInput = new TextFieldBuilder(NamedEntity.NAME_MAX_SIZE).id(fieldId) - .caption(i18n.getMessage(TEXTFIELD_NAME)).prompt(i18n.getMessage(TEXTFIELD_NAME)).buildTextComponent(); + .caption(i18n.getMessage(TEXTFIELD_NAME)).prompt(i18n.getMessage(TEXTFIELD_NAME)).buildTextComponent(); nameInput.setSizeUndefined(); final Binding binding = binder.forField(nameInput) - .asRequired(i18n.getMessage(UIMessageIdProvider.MESSAGE_ERROR_NAMEREQUIRED)) - .bind(NameAware::getName, NameAware::setName); + .asRequired(i18n.getMessage(UIMessageIdProvider.MESSAGE_ERROR_NAMEREQUIRED)) + .bind(NameAware::getName, NameAware::setName); return new BoundComponent<>(nameInput, binding); } @@ -131,13 +131,19 @@ public final class FormComponentBuilder { */ public static BoundComponent