diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/ControllerPollProperties.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/ControllerPollProperties.java index b5bbdd616..15b3a818b 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/ControllerPollProperties.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/ControllerPollProperties.java @@ -49,6 +49,16 @@ public class ControllerPollProperties implements Serializable { */ private String pollingOverdueTime = "00:05:00"; + /** + * This configuration value is used to change the polling interval so that + * controller tries to poll at least these many times between the last + * polling and before start of maintenance window. The polling interval is + * bounded by configured pollingTime and minPollingTime. The polling + * interval is modified as per following scheme: pollingTime(@time=t) = + * (maintenanceWindowStartTime - t)/maintenanceWindowPollCount. + */ + private int maintenanceWindowPollCount = 3; + public String getPollingTime() { return pollingTime; } @@ -81,4 +91,23 @@ public class ControllerPollProperties implements Serializable { this.minPollingTime = minPollingTime; } + /** + * Returns poll count for maintenance window + * ({@link ControllerPollProperties#maintenanceWindowPollCount}). + * + * @return maintenanceWindowPollCount as int. + */ + public int getMaintenanceWindowPollCount() { + return maintenanceWindowPollCount; + } + + /** + * Sets poll count for maintenance window + * ({@link ControllerPollProperties#maintenanceWindowPollCount}). + * + * @param maintenanceWindowPollCount. + */ + public void setMaintenanceWindowPollCount(int maintenanceWindowPollCount) { + this.maintenanceWindowPollCount = maintenanceWindowPollCount; + } } 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 84e5243f2..3456d7979 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 @@ -169,7 +169,12 @@ public enum SpServerError { /** * */ - SP_REPO_OPERATION_NOT_SUPPORTED("hawkbit.server.error.operation.notSupported", "Operation or method is (no longer) supported by service."); + SP_REPO_OPERATION_NOT_SUPPORTED("hawkbit.server.error.operation.notSupported", "Operation or method is (no longer) supported by service."), + + /** + * Error message informing that the maintenance schedule is invalid. + */ + SP_MAINTENANCE_SCHEDULE_INVALID("hawkbit.server.error.maintenanceScheduleInvalid", "Information for schedule, duration or timezone is missing; or there is no valid maintenance window available in future."); private final String key; private final String message; diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeployment.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeployment.java index 2d62035e2..1b4ee6a10 100644 --- a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeployment.java +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeployment.java @@ -12,13 +12,14 @@ import java.util.Collections; import java.util.List; import javax.validation.constraints.NotNull; - +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonValue; /** * Detailed update action information. */ +@JsonInclude(JsonInclude.Include.NON_NULL) public class DdiDeployment { private HandlingType download; @@ -29,6 +30,8 @@ public class DdiDeployment { @NotNull private List chunks; + private MaintenanceWindowStatus maintenanceWindow = null; + /** * Constructor. */ @@ -45,11 +48,21 @@ public class DdiDeployment { * handling type * @param chunks * to handle. + * @param maintenanceWindow + * specifying whether there is a maintenance schedule associated. + * If it is, the value is either 'available' (i.e. the + * maintenance window is now available as per defined schedule + * and the update can progress) or 'unavailable' (implying that + * maintenance window is not available now and update should not + * be attempted). If there is no maintenance schedule defined, + * the parameter is null. */ - public DdiDeployment(final HandlingType download, final HandlingType update, final List chunks) { + public DdiDeployment(final HandlingType download, final HandlingType update, final List chunks, + final MaintenanceWindowStatus maintenanceWindow) { this.download = download; this.update = update; this.chunks = chunks; + this.maintenanceWindow = maintenanceWindow; } public HandlingType getDownload() { @@ -68,6 +81,10 @@ public class DdiDeployment { return Collections.unmodifiableList(chunks); } + public MaintenanceWindowStatus getMaintenanceWindow() { + return this.maintenanceWindow; + } + /** * The handling type for the update action. */ @@ -100,9 +117,41 @@ public class DdiDeployment { } } + /** + * Status of the maintenance window for action. + */ + public enum MaintenanceWindowStatus { + /** + * A window is currently available, target can go ahead with + * installation. + */ + AVAILABLE("available"), + + /** + * A window is not available, target should wait and skip the + * installation. + */ + UNAVAILABLE("unavailable"); + + private String status; + + MaintenanceWindowStatus(final String status) { + this.status = status; + } + + /** + * @return status of maintenance window. + */ + @JsonValue + public String getStatus() { + return this.status; + } + } + @Override public String toString() { - return "Deployment [download=" + download + ", update=" + update + ", chunks=" + chunks + "]"; + return "Deployment [download=" + download + ", update=" + update + ", chunks=" + chunks + + (maintenanceWindow == null ? "]" : (", maintenanceWindow=" + maintenanceWindow + "]")); } } diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiStatus.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiStatus.java index 78deeefe8..0170d67f9 100644 --- a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiStatus.java +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiStatus.java @@ -102,7 +102,12 @@ public class DdiStatus { /** * Action is started after a reset, power loss, etc. */ - RESUMED("resumed"); + RESUMED("resumed"), + + /** + * The action has been downloaded by the target. + */ + DOWNLOADED("downloaded"); private String name; diff --git a/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java b/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java index 62db9cd44..cd931a49c 100644 --- a/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java +++ b/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DataConversionHelper.java @@ -155,7 +155,10 @@ public final class DataConversionHelper { private static int calculateEtag(final Action action) { final int prime = 31; int result = action.hashCode(); - result = prime * result + (action.isHitAutoForceTime(System.currentTimeMillis()) ? 1231 : 1237); + int offsetPrime = action.isHitAutoForceTime(System.currentTimeMillis()) ? 1231 : 1237; + offsetPrime = (action.hasMaintenanceSchedule() && action.isMaintenanceWindowAvailable()) ? 1249 : offsetPrime; + + result = prime * result + offsetPrime; return result; } diff --git a/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java b/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java index 6107eefe8..9c85728d5 100644 --- a/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java +++ b/hawkbit-ddi-resource/src/main/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootController.java @@ -29,6 +29,7 @@ import org.eclipse.hawkbit.ddi.json.model.DdiConfigData; import org.eclipse.hawkbit.ddi.json.model.DdiControllerBase; import org.eclipse.hawkbit.ddi.json.model.DdiDeployment; import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType; +import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.MaintenanceWindowStatus; import org.eclipse.hawkbit.ddi.json.model.DdiDeploymentBase; import org.eclipse.hawkbit.ddi.json.model.DdiResult.FinalResult; import org.eclipse.hawkbit.ddi.rest.api.DdiRestConstants; @@ -41,6 +42,7 @@ import org.eclipse.hawkbit.repository.SystemManagement; import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; 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.SoftwareModuleNotAssignedToTargetException; import org.eclipse.hawkbit.repository.model.Action; @@ -140,9 +142,12 @@ public class DdiRootController implements DdiRootControllerRestApi { final Target target = controllerManagement.findOrRegisterTargetIfItDoesNotexist(controllerId, IpUtil .getClientIpFromRequest(requestResponseContextHolder.getHttpServletRequest(), securityProperties)); - return new ResponseEntity<>(DataConversionHelper.fromTarget(target, - controllerManagement.findOldestActiveActionByTarget(controllerId).orElse(null), - controllerManagement.getPollingTime(), tenantAware), HttpStatus.OK); + final Action action = controllerManagement.findOldestActiveActionByTarget(controllerId).orElse(null); + + checkAndCancelExpiredAction(action); + + return new ResponseEntity<>(DataConversionHelper.fromTarget(target, action, + controllerManagement.getPollingTimeForAction(action), tenantAware), HttpStatus.OK); } @Override @@ -266,6 +271,8 @@ public class DdiRootController implements DdiRootControllerRestApi { return ResponseEntity.notFound().build(); } + checkAndCancelExpiredAction(action); + if (!action.isCancelingOrCanceled()) { final List chunks = DataConversionHelper.createChunks(target, action, artifactUrlHandler, @@ -273,8 +280,6 @@ public class DdiRootController implements DdiRootControllerRestApi { new ServletServerHttpRequest(requestResponseContextHolder.getHttpServletRequest()), controllerManagement); - final HandlingType handlingType = action.isForce() ? HandlingType.FORCED : HandlingType.ATTEMPT; - final List actionHistoryMsgs = controllerManagement.getActionHistoryMessages(action.getId(), actionHistoryMessageCount == null ? Integer.parseInt(DdiRestConstants.NO_ACTION_HISTORY) : actionHistoryMessageCount); @@ -282,8 +287,17 @@ public class DdiRootController implements DdiRootControllerRestApi { final DdiActionHistory actionHistory = actionHistoryMsgs.isEmpty() ? null : new DdiActionHistory(action.getStatus().name(), actionHistoryMsgs); + final HandlingType downloadType = action.isForce() ? HandlingType.FORCED : HandlingType.ATTEMPT; + final HandlingType updateType = action.hasMaintenanceSchedule() + ? (action.isMaintenanceWindowAvailable() ? downloadType : HandlingType.SKIP) : downloadType; + + MaintenanceWindowStatus maintenanceWindow = action.hasMaintenanceSchedule() + ? (action.isMaintenanceWindowAvailable() ? MaintenanceWindowStatus.AVAILABLE + : MaintenanceWindowStatus.UNAVAILABLE) + : null; + final DdiDeploymentBase base = new DdiDeploymentBase(Long.toString(action.getId()), - new DdiDeployment(handlingType, handlingType, chunks), actionHistory); + new DdiDeployment(downloadType, updateType, chunks, maintenanceWindow), actionHistory); LOG.debug("Found an active UpdateAction for target {}. returning deyploment: {}", controllerId, base); @@ -351,6 +365,13 @@ public class DdiRootController implements DdiRootControllerRestApi { case CLOSED: status = handleClosedCase(feedback, controllerId, actionid, messages); break; + case DOWNLOADED: + LOG.debug( + "Controller confirmed download of distribution set (actionId: {}, controllerId: {}) as we got {} report.", + actionid, controllerId, feedback.getStatus().getExecution()); + status = Status.DOWNLOADED; + messages.add(RepositoryConstants.SERVER_MESSAGE_PREFIX + "Target confirmed download of distribution set."); + break; default: status = handleDefaultCase(feedback, controllerId, actionid, messages); break; @@ -514,4 +535,21 @@ public class DdiRootController implements DdiRootControllerRestApi { return controllerManagement.findActionWithDetails(actionId) .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)); } + + /** + * If the action has a maintenance schedule defined but is no longer valid, + * cancel the action. + * + * @param action + * is the {@link Action} to check. + */ + private void checkAndCancelExpiredAction(final Action action) { + if (action != null && action.hasMaintenanceSchedule() && action.isMaintenanceScheduleLapsed()) { + try { + controllerManagement.cancelAction(action.getId()); + } catch (final CancelActionNotAllowedException e) { + LOG.info("Cancel action not allowed exception :{}", e); + } + } + } } diff --git a/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java b/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java index a104f4096..35dd38719 100644 --- a/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java +++ b/hawkbit-ddi-resource/src/test/java/org/eclipse/hawkbit/ddi/rest/resource/DdiRootControllerTest.java @@ -16,6 +16,9 @@ import static org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpre import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; import static org.hamcrest.Matchers.startsWith; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -42,6 +45,7 @@ import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetUpdateStatus; import org.eclipse.hawkbit.repository.test.matcher.Expect; import org.eclipse.hawkbit.repository.test.matcher.ExpectEvents; +import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest; import org.eclipse.hawkbit.repository.test.util.WithSpringAuthorityRule; import org.eclipse.hawkbit.repository.test.util.WithUser; import org.eclipse.hawkbit.rest.util.JsonBuilder; @@ -498,4 +502,95 @@ public class DdiRootControllerTest extends AbstractDDiApiIntegrationTest { .andExpect(jsonPath("$.actionHistory.messages", hasItem(containsString(TARGET_COMPLETED_INSTALLATION_MSG)))); } + + @Test + @Description("Test the polling time based on different maintenance window start and end time.") + public void testSleepTimeResponseForDifferentMaintenanceWindowParameters() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + + securityRule.runAs(WithSpringAuthorityRule.withUser("tenantadmin", HAS_AUTH_TENANT_CONFIGURATION), () -> { + tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.POLLING_TIME_INTERVAL, + "00:05:00"); + tenantConfigurationManagement.addOrUpdateConfiguration(TenantConfigurationKey.MIN_POLLING_TIME_INTERVAL, + "00:01:00"); + return null; + }); + + Target savedTarget = testdataFactory.createTarget("1911"); + assignDistributionSetWithMaintenanceWindow(ds.getId(), savedTarget.getControllerId(), + AbstractIntegrationTest.getTestSchedule(16), AbstractIntegrationTest.getTestDuration(10), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/1911/")).andExpect(status().isOk()) + .andExpect(jsonPath("$.config.polling.sleep", greaterThanOrEqualTo("00:05:00"))); + + Target savedTarget1 = testdataFactory.createTarget("2911"); + final DistributionSet ds1 = testdataFactory.createDistributionSet("1"); + assignDistributionSetWithMaintenanceWindow(ds1.getId(), savedTarget1.getControllerId(), + AbstractIntegrationTest.getTestSchedule(10), AbstractIntegrationTest.getTestDuration(10), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/2911/")).andExpect(status().isOk()) + .andExpect(jsonPath("$.config.polling.sleep", lessThan("00:05:00"))) + .andExpect(jsonPath("$.config.polling.sleep", greaterThanOrEqualTo("00:03:00"))); + + Target savedTarget2 = testdataFactory.createTarget("3911"); + final DistributionSet ds2 = testdataFactory.createDistributionSet("2"); + assignDistributionSetWithMaintenanceWindow(ds2.getId(), savedTarget2.getControllerId(), + AbstractIntegrationTest.getTestSchedule(5), AbstractIntegrationTest.getTestDuration(5), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/3911/")).andExpect(status().isOk()) + .andExpect(jsonPath("$.config.polling.sleep", lessThan("00:02:00"))); + + Target savedTarget3 = testdataFactory.createTarget("4911"); + final DistributionSet ds3 = testdataFactory.createDistributionSet("3"); + assignDistributionSetWithMaintenanceWindow(ds3.getId(), savedTarget3.getControllerId(), + AbstractIntegrationTest.getTestSchedule(-5), AbstractIntegrationTest.getTestDuration(15), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/4911/")).andExpect(status().isOk()) + .andExpect(jsonPath("$.config.polling.sleep", equalTo("00:05:00"))); + + } + + @Test + @Description("Test download and update values before maintenance window start time.") + public void testDownloadAndUpdateStatusBeforeMaintenaceWindowStartTime() throws Exception { + Target savedTarget = testdataFactory.createTarget("1911"); + final DistributionSet ds = testdataFactory.createDistributionSet(""); + savedTarget = assignDistributionSetWithMaintenanceWindow(ds.getId(), savedTarget.getControllerId(), + AbstractIntegrationTest.getTestSchedule(2), AbstractIntegrationTest.getTestDuration(1), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/1911/")).andExpect(status().isOk()); + + final Action action = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + + mvc.perform(get("/{tenant}/controller/v1/1911/deploymentBase/{actionId}", tenantAware.getCurrentTenant(), + action.getId()).accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()).andExpect(jsonPath("$.deployment.download", equalTo("forced"))) + .andExpect(jsonPath("$.deployment.update", equalTo("skip"))); + } + + @Test + @Description("Test download and update values after maintenance window start time.") + public void testDownloadAndUpdateStatusDuringMaintenaceWindow() throws Exception { + Target savedTarget = testdataFactory.createTarget("1911"); + final DistributionSet ds = testdataFactory.createDistributionSet(""); + savedTarget = assignDistributionSetWithMaintenanceWindow(ds.getId(), savedTarget.getControllerId(), + AbstractIntegrationTest.getTestSchedule(-5), AbstractIntegrationTest.getTestDuration(10), + AbstractIntegrationTest.getTestTimeZone()).getAssignedEntity().iterator().next(); + + mvc.perform(get("/default-tenant/controller/v1/1911/")).andExpect(status().isOk()); + + final Action action = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + + mvc.perform(get("/{tenant}/controller/v1/1911/deploymentBase/{actionId}", tenantAware.getCurrentTenant(), + action.getId()).accept(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()).andExpect(jsonPath("$.deployment.download", equalTo("forced"))) + .andExpect(jsonPath("$.deployment.update", equalTo("forced"))); + } } diff --git a/hawkbit-ddi-resource/src/test/resources/ddi-test.properties b/hawkbit-ddi-resource/src/test/resources/ddi-test.properties index bc6c0764a..e728fea61 100644 --- a/hawkbit-ddi-resource/src/test/resources/ddi-test.properties +++ b/hawkbit-ddi-resource/src/test/resources/ddi-test.properties @@ -10,6 +10,9 @@ # DDI configuration - START hawkbit.controller.pollingTime=00:01:00 hawkbit.controller.pollingOverdueTime=00:01:00 +hawkbit.controller.minPollingTime=00:00:30 + +hawkbit.controller.maintenanceWindowPollCount=3 # DDI configuration - END # Upload configuration - START 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 7b0674c88..24931ad1b 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 @@ -312,11 +312,12 @@ public class AmqpConfiguration { AmqpMessageDispatcherService amqpMessageDispatcherService(final RabbitTemplate rabbitTemplate, final AmqpMessageSenderService amqpSenderService, final ArtifactUrlHandler artifactUrlHandler, final SystemSecurityContext systemSecurityContext, final SystemManagement systemManagement, - final TargetManagement targetManagement, final DistributionSetManagement distributionSetManagement, + final ControllerManagement controllerManagement, final TargetManagement targetManagement, + final DistributionSetManagement distributionSetManagement, final SoftwareModuleManagement softwareModuleManagement) { return new AmqpMessageDispatcherService(rabbitTemplate, amqpSenderService, artifactUrlHandler, - systemSecurityContext, systemManagement, targetManagement, serviceMatcher, distributionSetManagement, - softwareModuleManagement); + systemSecurityContext, systemManagement, controllerManagement, targetManagement, serviceMatcher, + distributionSetManagement, softwareModuleManagement); } private static Map getTTLMaxArgsAuthenticationQueue() { 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 eabea05e2..ac8288b2c 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 @@ -13,6 +13,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.stream.Collectors; import org.eclipse.hawkbit.api.ApiType; @@ -29,6 +30,7 @@ import org.eclipse.hawkbit.dmf.json.model.DmfArtifactHash; import org.eclipse.hawkbit.dmf.json.model.DmfDownloadAndUpdateRequest; import org.eclipse.hawkbit.dmf.json.model.DmfMetadata; import org.eclipse.hawkbit.dmf.json.model.DmfSoftwareModule; +import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; import org.eclipse.hawkbit.repository.RepositoryConstants; import org.eclipse.hawkbit.repository.SoftwareModuleManagement; @@ -40,6 +42,7 @@ import org.eclipse.hawkbit.repository.event.remote.entity.CancelTargetAssignment import org.eclipse.hawkbit.repository.model.Artifact; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.SoftwareModuleMetadata; +import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.util.IpUtil; @@ -73,6 +76,7 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { private final AmqpMessageSenderService amqpSenderService; private final SystemSecurityContext systemSecurityContext; private final SystemManagement systemManagement; + private final ControllerManagement controllerManagement; private final TargetManagement targetManagement; private final ServiceMatcher serviceMatcher; private final DistributionSetManagement distributionSetManagement; @@ -91,6 +95,8 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { * for execution with system permissions * @param systemManagement * the systemManagement + * @param controllerManagement + * for target repository access * @param targetManagement * to access target information * @param serviceMatcher @@ -102,14 +108,15 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { protected AmqpMessageDispatcherService(final RabbitTemplate rabbitTemplate, final AmqpMessageSenderService amqpSenderService, final ArtifactUrlHandler artifactUrlHandler, final SystemSecurityContext systemSecurityContext, final SystemManagement systemManagement, - final TargetManagement targetManagement, final ServiceMatcher serviceMatcher, - final DistributionSetManagement distributionSetManagement, + final ControllerManagement controllerManagement, final TargetManagement targetManagement, + final ServiceMatcher serviceMatcher, final DistributionSetManagement distributionSetManagement, final SoftwareModuleManagement softwareModuleManagement) { super(rabbitTemplate); this.artifactUrlHandler = artifactUrlHandler; this.amqpSenderService = amqpSenderService; this.systemSecurityContext = systemSecurityContext; this.systemManagement = systemManagement; + this.controllerManagement = controllerManagement; this.targetManagement = targetManagement; this.serviceMatcher = serviceMatcher; this.distributionSetManagement = distributionSetManagement; @@ -149,6 +156,32 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { }); } + /** + * Method to get the type of event depending on whether the action has a + * valid maintenance window available or not based on defined maintenance + * schedule. In case of no maintenance schedule or if there is a valid + * window available, the topic {@link EventTopic#DOWNLOAD_AND_INSTALL} is + * returned else {@link EventTopic#DOWNLOAD_AND_SKIP} is returned. + * + * @param target + * for which to find the event type + * + * @return {@link EventTopic} to use for message. + */ + EventTopic getEventTypeForTarget(Target target) { + Optional action = controllerManagement.findOldestActiveActionByTarget(target.getControllerId()); + + if (action.isPresent()) { + if (action.get().isMaintenanceWindowAvailable()) { + return EventTopic.DOWNLOAD_AND_INSTALL; + } else { + return EventTopic.DOWNLOAD_AND_SKIP; + } + } + + return EventTopic.DOWNLOAD_AND_INSTALL; + } + /** * Method to send a message to a RabbitMQ Exchange after the assignment of * the Distribution set to a Target has been canceled. @@ -203,8 +236,7 @@ public class AmqpMessageDispatcherService extends BaseAmqpService { }); final Message message = getMessageConverter().toMessage(downloadAndUpdateRequest, - createConnectorMessagePropertiesEvent(tenant, target.getControllerId(), - EventTopic.DOWNLOAD_AND_INSTALL)); + createConnectorMessagePropertiesEvent(tenant, target.getControllerId(), getEventTypeForTarget(target))); amqpSenderService.sendMessage(message, targetAdress); } 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 9c62de00e..0d44a4a51 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 @@ -271,7 +271,8 @@ public class AmqpMessageHandlerService extends BaseAmqpService { final Action addUpdateActionStatus = getUpdateActionStatus(status, actionStatus); - if (!addUpdateActionStatus.isActive()) { + if (!addUpdateActionStatus.isActive() || (addUpdateActionStatus.hasMaintenanceSchedule() + && addUpdateActionStatus.isMaintenanceWindowAvailable())) { lookIfUpdateAvailable(action.getTarget()); } } @@ -306,6 +307,9 @@ public class AmqpMessageHandlerService extends BaseAmqpService { case WARNING: status = Status.WARNING; break; + case DOWNLOADED: + status = Status.DOWNLOADED; + break; case CANCEL_REJECTED: status = hanldeCancelRejectedState(message, action); break; diff --git a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java index 2c5c41ef0..8fbe5e4c9 100644 --- a/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java +++ b/hawkbit-dmf/hawkbit-dmf-amqp/src/test/java/org/eclipse/hawkbit/amqp/AmqpMessageDispatcherServiceTest.java @@ -111,8 +111,8 @@ public class AmqpMessageDispatcherServiceTest extends AbstractIntegrationTest { when(systemManagement.getTenantMetadata()).thenReturn(tenantMetaData); amqpMessageDispatcherService = new AmqpMessageDispatcherService(rabbitTemplate, senderService, - artifactUrlHandlerMock, systemSecurityContext, systemManagement, targetManagement, serviceMatcher, - distributionSetManagement, softwareModuleManagement); + artifactUrlHandlerMock, systemSecurityContext, systemManagement, controllerManagement, targetManagement, + serviceMatcher, distributionSetManagement, softwareModuleManagement); } 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 f87685de8..871daa5b6 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 @@ -28,6 +28,10 @@ public enum EventTopic { /** * Topic when updating device attributes. */ - UPDATE_ATTRIBUTES; + UPDATE_ATTRIBUTES, + /** + * Topic when sending a download only task, skipping the install. + */ + DOWNLOAD_AND_SKIP; } 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 10b3d870c..a833ee0e7 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 @@ -59,5 +59,10 @@ public enum DmfActionStatus { /** * Cancellation has been rejected by the target.. */ - CANCEL_REJECTED; + CANCEL_REJECTED, + + /** + * Action has been downloaded for this target. + */ + DOWNLOADED; } diff --git a/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/MaintenanceWindow.java b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/MaintenanceWindow.java new file mode 100644 index 000000000..f48251545 --- /dev/null +++ b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/MaintenanceWindow.java @@ -0,0 +1,90 @@ +/** + * Copyright (c) Siemens AG, 2018 + * + * 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; + +import com.fasterxml.jackson.annotation.JsonSetter; + +/** + * JSON model for Management API to define the maintenance window based on a + * schedule defined as cron expression, duration in HH:mm:ss format and time + * zone as offset from UTC. + */ +public class MaintenanceWindow { + + private String maintenanceSchedule; + private String maintenanceWindowDuration; + private String maintenanceWindowTimeZone; + + /** + * Sets the maintenance schedule. + * + * @param maintenanceSchedule + * is the cron expression to be used for scheduling maintenance + * window(s). Expression has 6 mandatory fields and a last + * optional field: "second minute hour dayofmonth month weekday + * year". + */ + @JsonSetter("schedule") + public void setMaintenanceSchedule(String maintenanceSchedule) { + this.maintenanceSchedule = maintenanceSchedule; + } + + /** + * Sets the maintenance window duration. + * + * @param maintenanceWindowDuration + * in HH:mm:ss format specifying the duration of a maintenance + * window, for example 00:30:00 for 30 minutes. + */ + @JsonSetter("duration") + public void setMaintenanceWindowDuration(String maintenanceWindowDuration) { + this.maintenanceWindowDuration = maintenanceWindowDuration; + } + + /** + * Sets the maintenance window timezone. + * + * @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. + */ + @JsonSetter("timezone") + public void setMaintenanceWindowTimeZone(String maintenanceWindowTimeZone) { + this.maintenanceWindowTimeZone = maintenanceWindowTimeZone; + } + + /** + * Returns the maintenance schedule for the {@link Action}. + * + * @return cron expression as {@link String}. + */ + public String getMaintenanceSchedule() { + return maintenanceSchedule; + } + + /** + * Returns the duration of maintenance window for the {@link Action}. + * + * @return duration in HH:mm:ss format as {@link String}. + */ + public String getMaintenanceWindowDuration() { + return maintenanceWindowDuration; + } + + /** + * Returns the timezone of maintenance window for the {@link Action}. + * + * @return the timezone offset from UTC in +/-hh:mm as {@link String}. + */ + public String getMaintenanceWindowTimeZone() { + return maintenanceWindowTimeZone; + } +} diff --git a/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java index 46c43ac8b..5ac69767c 100644 --- a/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java +++ b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/distributionset/MgmtTargetAssignmentRequestBody.java @@ -8,6 +8,8 @@ */ package org.eclipse.hawkbit.mgmt.json.model.distributionset; +import org.eclipse.hawkbit.mgmt.json.model.MaintenanceWindow; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -25,6 +27,12 @@ public class MgmtTargetAssignmentRequestBody { private MgmtActionType type; + /** + * {@link MaintenanceWindow} object containing schedule, duration and + * timezone. + */ + private MaintenanceWindow maintenanceWindow = null; + /** * @return the id */ @@ -70,4 +78,23 @@ public class MgmtTargetAssignmentRequestBody { this.forcetime = forcetime; } + /** + * Returns {@link MaintenanceWindow} for the target assignment request. + * + * @return {@link MaintenanceWindow}. + */ + public MaintenanceWindow getMaintenanceWindow() { + return maintenanceWindow; + } + + /** + * Sets {@link MaintenanceWindow} for the target assignment request. + * + * @param maintenanceWindow + * as {@link MaintenanceWindow}. + */ + public void setMaintenanceWindow(MaintenanceWindow maintenanceWindow) { + this.maintenanceWindow = maintenanceWindow; + } + } diff --git a/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssigment.java b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssigment.java index edfcc5687..7ee5cb408 100644 --- a/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssigment.java +++ b/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtDistributionSetAssigment.java @@ -3,6 +3,7 @@ */ package org.eclipse.hawkbit.mgmt.json.model.target; +import org.eclipse.hawkbit.mgmt.json.model.MaintenanceWindow; import org.eclipse.hawkbit.mgmt.json.model.MgmtId; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; @@ -14,6 +15,12 @@ public class MgmtDistributionSetAssigment extends MgmtId { private long forcetime; private MgmtActionType type; + /** + * {@link MaintenanceWindow} object defining a schedule, duration and + * timezone. + */ + private MaintenanceWindow maintenanceWindow = null; + /** * @return the type */ @@ -44,4 +51,22 @@ public class MgmtDistributionSetAssigment extends MgmtId { this.forcetime = forcetime; } + /** + * Returns {@link MaintenanceWindow} for distribution set assignment. + * + * @return {@link MaintenanceWindow}. + */ + public MaintenanceWindow getMaintenanceWindow() { + return maintenanceWindow; + } + + /** + * Sets {@link MaintenanceWindow} for distribution set assignment. + * + * @param maintenanceWindow + * as {@link MaintenanceWindow}. + */ + public void setMaintenanceWindow(MaintenanceWindow maintenanceWindow) { + this.maintenanceWindow = maintenanceWindow; + } } diff --git a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java index b9b89f2ff..e7fc6d7fb 100644 --- a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java +++ b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResource.java @@ -36,6 +36,7 @@ import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.DistributionSetMetadata; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.Target; @@ -248,12 +249,18 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { .stream().map(MgmtTargetAssignmentRequestBody::getId).collect(Collectors.toList())))); } - return ResponseEntity.ok(MgmtDistributionSetMapper.toResponse(this.deployManagament.assignDistributionSet( + final DistributionSetAssignmentResult assignDistributionSet = this.deployManagament.assignDistributionSet( distributionSetId, - assignments.stream() - .map(assignment -> new TargetWithActionType(assignment.getId(), - MgmtRestModelMapper.convertActionType(assignment.getType()), assignment.getForcetime())) - .collect(Collectors.toList())))); + assignments.stream().map(t -> new TargetWithActionType(t.getId(), + MgmtRestModelMapper.convertActionType(t.getType()), t.getForcetime(), + t.getMaintenanceWindow() == null ? null : t.getMaintenanceWindow().getMaintenanceSchedule(), + t.getMaintenanceWindow() == null ? null + : t.getMaintenanceWindow().getMaintenanceWindowDuration(), + t.getMaintenanceWindow() == null ? null + : t.getMaintenanceWindow().getMaintenanceWindowTimeZone())) + .collect(Collectors.toList())); + + return ResponseEntity.ok(MgmtDistributionSetMapper.toResponse(assignDistributionSet)); } @@ -299,8 +306,7 @@ public class MgmtDistributionSetResource implements MgmtDistributionSetRestApi { } @Override - public ResponseEntity updateMetadata( - @PathVariable("distributionSetId") final Long distributionSetId, + public ResponseEntity updateMetadata(@PathVariable("distributionSetId") final Long distributionSetId, @PathVariable("metadataKey") final String metadataKey, @RequestBody final MgmtMetadataBodyPut metadata) { // check if distribution set exists otherwise throw exception // immediately diff --git a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java index 5d9d93582..f72bd813c 100644 --- a/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java +++ b/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java @@ -12,9 +12,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import javax.validation.ValidationException; +import org.eclipse.hawkbit.mgmt.json.model.MaintenanceWindow; import org.eclipse.hawkbit.mgmt.json.model.PagedList; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionRequestBodyPut; @@ -37,6 +39,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.Target; +import org.eclipse.hawkbit.repository.model.TargetWithActionType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -276,8 +279,18 @@ public class MgmtTargetResource implements MgmtTargetRestApi { findTargetWithExceptionIfNotFound(controllerId); final ActionType type = (dsId.getType() != null) ? MgmtRestModelMapper.convertActionType(dsId.getType()) : ActionType.FORCED; - return ResponseEntity.ok(MgmtDistributionSetMapper.toResponse(deploymentManagement - .assignDistributionSet(dsId.getId(), type, dsId.getForcetime(), Arrays.asList(controllerId)))); + MaintenanceWindow maintenanceWindow = dsId.getMaintenanceWindow(); + + return ResponseEntity + .ok(MgmtDistributionSetMapper.toResponse(this.deploymentManagement.assignDistributionSet(dsId.getId(), + Arrays.asList(controllerId).stream() + .map(t -> new TargetWithActionType(t, type, dsId.getForcetime(), + maintenanceWindow == null ? null : maintenanceWindow.getMaintenanceSchedule(), + maintenanceWindow == null ? null + : maintenanceWindow.getMaintenanceWindowDuration(), + maintenanceWindow == null ? null + : maintenanceWindow.getMaintenanceWindowTimeZone())) + .collect(Collectors.toList())))); } @Override diff --git a/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java b/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java index a4cacb888..ddd6bc612 100644 --- a/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java +++ b/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtDistributionSetResourceTest.java @@ -36,6 +36,7 @@ import org.eclipse.hawkbit.repository.model.DistributionSetMetadata; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetWithActionType; +import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest; import org.eclipse.hawkbit.repository.test.util.TestdataFactory; import org.eclipse.hawkbit.repository.test.util.WithUser; import org.eclipse.hawkbit.rest.util.JsonBuilder; @@ -206,19 +207,23 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr @Description("Ensures that multi target assignment through API is reflected by the repository.") public void assignMultipleTargetsToDistributionSet() throws Exception { final DistributionSet createdDs = testdataFactory.createDistributionSet(); - final List targets = testdataFactory.createTargets(5); - final JSONArray list = new JSONArray(); - targets.forEach(target -> list.put(new JSONObject().put("id", target.getControllerId()))); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + list.put(new JSONObject().put("id", Long.valueOf(targetId))); + } // assign already one target to DS - assignDistributionSet(createdDs.getId(), targets.get(0).getControllerId()); + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); mvc.perform(post( MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") .contentType(MediaType.APPLICATION_JSON).content(list.toString())) - .andExpect(status().isOk()).andExpect(jsonPath("$.assigned", equalTo(targets.size() - 1))) + .andExpect(status().isOk()).andExpect(jsonPath("$.assigned", equalTo(knownTargetIds.length - 1))) .andExpect(jsonPath("$.alreadyAssigned", equalTo(1))) - .andExpect(jsonPath("$.total", equalTo(targets.size()))); + .andExpect(jsonPath("$.total", equalTo(knownTargetIds.length))); assertThat(targetManagement.findByAssignedDistributionSet(PAGE, createdDs.getId()).getContent()) .as("Five targets in repository have DS assigned").hasSize(5); @@ -247,6 +252,129 @@ public class MgmtDistributionSetResourceTest extends AbstractManagementApiIntegr assertThat(targetManagement.findByInstalledDistributionSet(PAGE, createdDs.getId()).getContent()).hasSize(4); } + @Test + @Description("Assigns multiple targets to distribution set with only maintenance schedule.") + public void assignMultipleTargetsToDistributionSetWithMaintenanceWindowStartOnly() throws Exception { + // prepare distribution set + final Set createDistributionSetsAlphabetical = createDistributionSetsAlphabetical(1); + final DistributionSet createdDs = createDistributionSetsAlphabetical.iterator().next(); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + list.put(new JSONObject().put("id", Long.valueOf(targetId)).put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(0), "", ""))); + } + // assign already one target to DS + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); + + mvc.perform(post( + MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") + .contentType(MediaType.APPLICATION_JSON).content(list.toString())) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Assigns multiple targets to distribution set with only maintenance window duration.") + public void assignMultipleTargetsToDistributionSetWithMaintenanceWindowEndOnly() throws Exception { + // prepare distribution set + final Set createDistributionSetsAlphabetical = createDistributionSetsAlphabetical(1); + final DistributionSet createdDs = createDistributionSetsAlphabetical.iterator().next(); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + list.put(new JSONObject().put("id", Long.valueOf(targetId)).put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow("", AbstractIntegrationTest.getTestDuration(10), ""))); + } + // assign already one target to DS + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); + + mvc.perform(post( + MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") + .contentType(MediaType.APPLICATION_JSON).content(list.toString())) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Assigns multiple targets to distribution set with valid maintenance window.") + public void assignMultipleTargetsToDistributionSetWithValidMaintenanceWindow() throws Exception { + // prepare distribution set + final Set createDistributionSetsAlphabetical = createDistributionSetsAlphabetical(1); + final DistributionSet createdDs = createDistributionSetsAlphabetical.iterator().next(); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + list.put(new JSONObject().put("id", Long.valueOf(targetId)).put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(10), + AbstractIntegrationTest.getTestDuration(10), AbstractIntegrationTest.getTestTimeZone()))); + } + // assign already one target to DS + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); + + mvc.perform(post( + MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") + .contentType(MediaType.APPLICATION_JSON).content(list.toString())) + .andExpect(status().isOk()); + } + + @Test + @Description("Assigns multiple targets to distribution set with last maintenance window scheduled before current time.") + public void assignMultipleTargetsToDistributionSetWithMaintenanceWindowEndTimeBeforeStartTime() throws Exception { + // prepare distribution set + final Set createDistributionSetsAlphabetical = createDistributionSetsAlphabetical(1); + final DistributionSet createdDs = createDistributionSetsAlphabetical.iterator().next(); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + list.put(new JSONObject().put("id", Long.valueOf(targetId)).put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(-30), + AbstractIntegrationTest.getTestDuration(5), AbstractIntegrationTest.getTestTimeZone()))); + } + // assign already one target to DS + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); + + mvc.perform(post( + MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") + .contentType(MediaType.APPLICATION_JSON).content(list.toString())) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Assigns multiple targets to distribution set with and without maintenance window.") + public void assignMultipleTargetsToDistributionSetWithAndWithoutMaintenanceWindow() throws Exception { + // prepare distribution set + final Set createDistributionSetsAlphabetical = createDistributionSetsAlphabetical(1); + final DistributionSet createdDs = createDistributionSetsAlphabetical.iterator().next(); + // prepare targets + final String[] knownTargetIds = new String[] { "1", "2", "3", "4", "5" }; + final JSONArray list = new JSONArray(); + for (final String targetId : knownTargetIds) { + testdataFactory.createTarget(targetId); + if (Integer.parseInt(targetId) % 2 == 0) { + list.put(new JSONObject().put("id", Long.valueOf(targetId)).put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(10), + AbstractIntegrationTest.getTestDuration(5), + AbstractIntegrationTest.getTestTimeZone()))); + } else { + list.put(new JSONObject().put("id", Long.valueOf(targetId))); + } + } + // assign already one target to DS + assignDistributionSet(createdDs.getId(), knownTargetIds[0]); + + mvc.perform(post( + MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + createdDs.getId() + "/assignedTargets") + .contentType(MediaType.APPLICATION_JSON).content(list.toString())) + .andExpect(status().isOk()); + } + @Test @Description("Ensures that assigned targets of DS are returned as reflected by the repository.") public void getAssignedTargetsOfDistributionSet() throws Exception { diff --git a/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java b/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java index 654736165..2ec283404 100644 --- a/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java +++ b/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java @@ -48,6 +48,7 @@ import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.SoftwareModule; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetUpdateStatus; +import org.eclipse.hawkbit.repository.test.util.AbstractIntegrationTest; import org.eclipse.hawkbit.repository.test.util.WithUser; import org.eclipse.hawkbit.rest.exception.MessageNotReadableException; import org.eclipse.hawkbit.rest.json.model.ExceptionInfo; @@ -62,6 +63,7 @@ import org.springframework.data.domain.Sort.Direction; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.hateoas.MediaTypes; import com.jayway.jsonpath.JsonPath; @@ -1218,6 +1220,74 @@ public class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest assertThat(deploymentManagement.getAssignedDistributionSet("fsdfsd").get()).isEqualTo(set); } + @Test + @Description("Assigns distribution set to target with only maintenance schedule.") + public void assignDistributionSetToTargetWithMaintenanceWindowStartTimeOnly() throws Exception { + + final Target target = testdataFactory.createTarget("fsdfsd"); + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + final String body = new JSONObject().put("id", set.getId()).put("type", "forced").put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(0), "", "")) + .toString(); + + mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + target.getControllerId() + "/assignedDS") + .content(body).contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Assigns distribution set to target with only maintenance window duration.") + public void assignDistributionSetToTargetWithMaintenanceWindowEndTimeOnly() throws Exception { + + final Target target = testdataFactory.createTarget("fsdfsd"); + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + final String body = new JSONObject().put("id", set.getId()).put("type", "forced").put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow("", AbstractIntegrationTest.getTestDuration(10), "")) + .toString(); + + mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + target.getControllerId() + "/assignedDS") + .content(body).contentType(MediaTypes.HAL_JSON_VALUE)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Assigns distribution set to target with valid maintenance window.") + public void assignDistributionSetToTargetWithValidMaintenanceWindow() throws Exception { + + final Target target = testdataFactory.createTarget("fsdfsd"); + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + final String body = new JSONObject().put("id", set.getId()).put("type", "forced").put("forcetime", "0") + .put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(10), + AbstractIntegrationTest.getTestDuration(10), AbstractIntegrationTest.getTestTimeZone())) + .toString(); + + mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + target.getControllerId() + "/assignedDS") + .content(body).contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()); + } + + @Test + @Description("Assigns distribution set to target with last maintenance window scheduled before current time.") + public void assignDistributionSetToTargetWithMaintenanceWindowEndTimeBeforeStartTime() throws Exception { + + final Target target = testdataFactory.createTarget("fsdfsd"); + final DistributionSet set = testdataFactory.createDistributionSet("one"); + + final String body = new JSONObject().put("id", set.getId()).put("type", "forced") + .put("maintenanceWindow", + AbstractIntegrationTest.getMaintenanceWindow(AbstractIntegrationTest.getTestSchedule(-30), + AbstractIntegrationTest.getTestDuration(5), AbstractIntegrationTest.getTestTimeZone())) + .toString(); + + mvc.perform(post(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + target.getControllerId() + "/assignedDS") + .content(body).contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()); + } + @Test public void invalidRequestsOnAssignDistributionSetToTarget() throws Exception { diff --git a/hawkbit-repository/hawkbit-repository-api/pom.xml b/hawkbit-repository/hawkbit-repository-api/pom.xml index 69cbdaef4..778fac4d9 100644 --- a/hawkbit-repository/hawkbit-repository-api/pom.xml +++ b/hawkbit-repository/hawkbit-repository-api/pom.xml @@ -47,6 +47,11 @@ + + com.cronutils + cron-utils + 5.0.5 + @@ -65,4 +70,4 @@ test - \ No newline at end of file + 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 e58d06eca..975eca5c5 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 @@ -21,6 +21,7 @@ import javax.validation.constraints.NotNull; import org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpressions; import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; +import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException; import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; @@ -211,11 +212,51 @@ public interface ControllerManagement { Optional getActionForDownloadByTargetAndSoftwareModule(@NotEmpty String controllerId, long moduleId); /** + * Returns configured polling interval at which the controller polls hawkBit + * server. + * * @return current {@link TenantConfigurationKey#POLLING_TIME_INTERVAL}. */ @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) String getPollingTime(); + /** + * Returns the configured minimum polling interval. + * + * @return current {@link TenantConfigurationKey#MIN_POLLING_TIME_INTERVAL}. + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + String getMinPollingTime(); + + /** + * Returns the count to be used for reducing polling interval while calling + * {@link ControllerManagement#getPollingTimeForAction()}. + * + * @return configured value of + * {@link TenantConfigurationKey#MAINTENANCE_WINDOW_POLL_COUNT}. + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + public int getMaintenanceWindowPollCount(); + + /** + * Returns polling time based on the maintenance window for an action. + * Server will reduce the polling interval as the start time for maintenance + * window approaches, so that at least these many attempts are made between + * current polling until start of maintenance window. Poll time keeps + * reducing with MinPollingTime as lower limit + * {@link TenantConfigurationKey#MIN_POLLING_TIME_INTERVAL}. After the start + * of maintenance window, it resets to default + * {@link TenantConfigurationKey#POLLING_TIME_INTERVAL}. + * + * @param action + * id the {@link Action} for which polling time is calculated + * based on it having maintenance window or not + * + * @return current {@link TenantConfigurationKey#POLLING_TIME_INTERVAL}. + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + String getPollingTimeForAction(final Action action); + /** * Checks if a given target has currently or has even been assigned to the * given artifact through the action history list. This can e.g. indicate if @@ -349,4 +390,23 @@ public interface ControllerManagement { */ @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) List getActionHistoryMessages(long actionId, int messageCount); + + /** + * Cancels given {@link Action} for this {@link Target}. However, it might + * be possible that the controller will continue to work on the cancelation. + * The controller needs to acknowledge or reject the cancelation using + * {@link DdiRootController#postCancelActionFeedback}. + * + * @param actionId + * to be canceled + * + * @return canceled {@link Action} + * + * @throws CancelActionNotAllowedException + * in case the given action is not active or is already canceled + * @throws EntityNotFoundException + * if action with given actionId does not exist. + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + Action cancelAction(long actionId); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java new file mode 100644 index 000000000..5645e8867 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java @@ -0,0 +1,172 @@ +/** + * Copyright (c) Siemens AG, 2018 + * + * 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 static com.cronutils.model.CronType.QUARTZ; + +import java.time.Duration; +import java.time.LocalTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.Optional; +import java.util.TimeZone; +import java.util.regex.Pattern; + +import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; + +import com.cronutils.model.Cron; +import com.cronutils.model.definition.CronDefinition; +import com.cronutils.model.definition.CronDefinitionBuilder; +import com.cronutils.model.time.ExecutionTime; +import com.cronutils.parser.CronParser; + +/** + * Helper class to check validity of maintenance schedule definition and manage + * scheduling of maintenance window using a cron expression based scheduler. It + * also provides a helper method for conversion of duration specified in + * HH:mm:ss format to ISO format. + */ +public class MaintenanceScheduleHelper { + + ExecutionTime scheduleExecutor = null; + Duration duration = null; + TimeZone timeZone = null; + + /** + * Constructor that accepts a cron expression, duration and time zone and + * instantiates the cron parser and scheduler executor. + * + * @param cronSchedule + * is the cron expression to be used for scheduling the + * maintenance window. Expression has 6 mandatory fields and 1 + * last optional field: "second minute hour dayofmonth month + * weekday year" + * @param duration + * in HH:mm:ss format specifying the duration of a maintenance + * window, for example 00:30:00 for 30 minutes + * @param timezone + * 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. + */ + public MaintenanceScheduleHelper(String cronSchedule, String duration, String timeZone) { + this.timeZone = TimeZone.getTimeZone(ZoneOffset.of(timeZone)); + this.duration = Duration.parse(convertToISODuration(duration)); + + CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(QUARTZ); + CronParser parser = new CronParser(cronDefinition); + Cron quartzCron = parser.parse(cronSchedule); + this.scheduleExecutor = ExecutionTime.forCron(quartzCron); + } + + /** + * Method calculates the next available maintenance window within the + * schedule but after a given time. + * + * @param after + * is the {@link ZonedDateTime} after which the window is + * required + * + * @return {@link Optional} of the next available window. In + * case there is none, returns empty value. + */ + public Optional nextExecution(ZonedDateTime after) { + try { + ZonedDateTime next = this.scheduleExecutor.nextExecution(after); + return Optional.of(next); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** + * Method checks if there are any more valid maintenance windows after a + * given time. + * + * @param after + * is the {@link ZonedDateTime} after which the windows are + * checked + * + * @return true if there is at least one valid schedule remaining, else + * false. + */ + public boolean hasValidScheduleAfter(ZonedDateTime after) { + return nextExecution(after).isPresent(); + } + + /** + * Check if the maintenance schedule definition is valid in terms of + * validity of cron expression, duration and availability of at least one + * valid maintenance window. Further a maintenance schedule is valid if + * either all the parameters: schedule, duration and time zone are valid or + * are null. + * + * @param cronSchedule + * is a cron expression with 6 mandatory fields and 1 last + * optional field: "second minute hour dayofmonth month weekday + * year" + * @param duration + * in HH:mm:ss format specifying the duration of a maintenance + * window, for example 00:30:00 for 30 minutes + * @param timezone + * 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 + * + * @return true if the schedule is valid, else throw an exception + * + * @throws InvalidMaintenanceScheduleException + * if the defined schedule fails the validity criteria. + */ + public static boolean validateMaintenanceSchedule(String cronSchedule, String duration, String timezone) { + // check if schedule, duration and timezone are all not null. + if (cronSchedule != null && duration != null && timezone != null) { + // check if schedule, duration and timezone are all not empty. + if (!(cronSchedule.isEmpty() || duration.isEmpty() || timezone.isEmpty())) { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(timezone)); + MaintenanceScheduleHelper scheduleHelper = new MaintenanceScheduleHelper(cronSchedule, duration, + timezone); + // check if there is a window currently active or available in + // future. + if (!scheduleHelper.hasValidScheduleAfter(now.minus(Duration.parse(convertToISODuration(duration))))) { + throw new InvalidMaintenanceScheduleException( + "No valid maintenance window available after current time"); + } + } else { + throw new InvalidMaintenanceScheduleException("Either of schedule, duration or timezone empty."); + } + } else if (!(cronSchedule == null && duration == null && timezone == null)) { + throw new InvalidMaintenanceScheduleException( + "All of schedule, duration and timezone should either be null or non empty."); + } + + return true; + } + + /** + * Convert the time interval or duration specified in "HH:mm:ss" format to + * ISO format. + * + * @param timeInterval + * in "HH:mm:ss" string format. This format is popularly used but + * can be confused with time of the day, hence conversion to ISO + * specified format for time duration is required + * + * @return the time interval or duration in ISO format + * + * @throws DateTimeParseException + * if the text cannot be converted to ISO format. + */ + public static String convertToISODuration(String timeInterval) { + return Duration.between(LocalTime.MIN, LocalTime.parse(timeInterval)).toString(); + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java new file mode 100644 index 000000000..bb09b9af5 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) Siemens AG, 2018 + * + * 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 thrown if trying to set a maintenance schedule that is + * invalid. A maintenance schedule is considered to be valid only if schedule, + * duration and timezone are all null, or are all valid; in which case there + * should be at least one valid window after the current time. + */ +public class InvalidMaintenanceScheduleException extends AbstractServerRtException { + private static final long serialVersionUID = 1L; + + /** + * Constructor for {@link InvalidMaintenanceScheduleException}. + * + * @param message + * the message for this exception. + */ + public InvalidMaintenanceScheduleException(final String message) { + super(message, SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID); + } + + /** + * Constructor for {@link InvalidMaintenanceScheduleException}. + * + * @param message + * the message for this exception + * @param cause + * the cause for this exception. + */ + public InvalidMaintenanceScheduleException(final String message, final Throwable cause) { + super(message, SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID, cause); + } +} 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 749318388..689ce3c21 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 @@ -15,6 +15,21 @@ import java.util.concurrent.TimeUnit; */ public interface Action extends TenantAwareBaseEntity { + /** + * Maximum length of controllerId. + */ + int MAINTENANCE_SCHEDULE_CRON_LENGTH = 128; + + /** + * Maximum length of controllerId. + */ + int MAINTENANCE_WINDOW_DURATION_LENGTH = 16; + + /** + * Maximum length of controllerId. + */ + int MAINTENANCE_WINDOW_TIMEZONE_LENGTH = 8; + /** * @return the distributionSet */ @@ -168,7 +183,13 @@ public interface Action extends TenantAwareBaseEntity { /** * Cancellation has been rejected by the controller. */ - CANCEL_REJECTED; + CANCEL_REJECTED, + + /** + * Action has been downloaded by the target and waiting for update to + * start. + */ + DOWNLOADED; } /** @@ -193,4 +214,36 @@ public interface Action extends TenantAwareBaseEntity { */ TIMEFORCED; } + + /** + * The method checks whether the action has a maintenance schedule defined + * for it. A maintenance schedule defines a set of maintenance windows + * during which actual update can be performed. A valid schedule defines at + * least one maintenance window. + * + * @return true if action has a maintenance schedule, else false. + */ + boolean hasMaintenanceSchedule(); + + /** + * The method checks whether the maintenance schedule has already lapsed for + * the action, i.e. there are no more windows available for maintenance. + * Controller manager uses the method to check if the maintenance schedule + * has lapsed, and automatically cancels the action if it is lapsed. + * + * @return true if maintenance schedule has lapsed, else false. + */ + boolean isMaintenanceScheduleLapsed(); + + /** + * The method checks whether a maintenance window is available for the + * action to proceed. If it is available, a 'true' value is returned. The + * maintenance window is considered available: 1) If there is no maintenance + * schedule at all, in which case device can start update any time after + * download is finished; or 2) the current time is within a scheduled + * maintenance window start and end time. + * + * @return true if maintenance window is available, else false. + */ + boolean isMaintenanceWindowAvailable(); } 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 3944938ee..0850f6606 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 @@ -8,6 +8,8 @@ */ package org.eclipse.hawkbit.repository.model; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; +import org.eclipse.hawkbit.repository.exception.InvalidMaintenanceScheduleException; import org.eclipse.hawkbit.repository.model.Action.ActionType; /** @@ -21,6 +23,9 @@ public class TargetWithActionType { private final String controllerId; private final ActionType actionType; private final long forceTime; + private String maintenanceSchedule = null; + private String maintenanceWindowDuration = null; + private String maintenanceWindowTimeZone = null; public TargetWithActionType(final String controllerId) { this.controllerId = controllerId; @@ -34,6 +39,46 @@ public class TargetWithActionType { this.forceTime = forceTime; } + /** + * Constructor that also accepts maintenance schedule parameters and checks + * for validity of the specified maintenance schedule. + * + * @param controllerId + * for which the action is created. + * @param actionType + * specified for the action. + * @param maintenanceSchedule + * is the cron expression to be used for scheduling maintenance + * windows. Expression has 6 mandatory fields and 1 last optional + * field: "second minute hour dayofmonth month weekday year" + * @param maintenanceWindowDuration + * in HH:mm:ss format specifying the duration of a maintenance + * 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. + * + * @throws InvalidMaintenanceScheduleException + * if the parameters do not define a valid maintenance schedule. + */ + public TargetWithActionType(final String controllerId, final ActionType actionType, final long forceTime, + String maintenanceSchedule, String maintenanceWindowDuration, String maintenanceWindowTimeZone) { + this.controllerId = controllerId; + this.actionType = actionType; + this.forceTime = forceTime; + + if (MaintenanceScheduleHelper.validateMaintenanceSchedule(maintenanceSchedule, maintenanceWindowDuration, + maintenanceWindowTimeZone)) { + this.maintenanceSchedule = maintenanceSchedule; + this.maintenanceWindowDuration = maintenanceWindowDuration; + this.maintenanceWindowTimeZone = maintenanceWindowTimeZone; + } else { + throw new InvalidMaintenanceScheduleException("Invalid maintenance window definition"); + } + } + public ActionType getActionType() { if (actionType != null) { return actionType; @@ -52,4 +97,31 @@ public class TargetWithActionType { public String getControllerId() { return controllerId; } + + /** + * Returns the maintenance schedule for the {@link Action}. + * + * @return cron expression as {@link String}. + */ + public String getMaintenanceSchedule() { + return this.maintenanceSchedule; + } + + /** + * Returns the duration of maintenance window for the {@link Action}. + * + * @return duration in HH:mm:ss format as {@link String}. + */ + public String getMaintenanceWindowDuration() { + return maintenanceWindowDuration; + } + + /** + * Returns the timezone of maintenance window for the {@link Action}. + * + * @return the timezone offset from UTC in +/-hh:mm as {@link String}. + */ + public String getMaintenanceWindowTimeZone() { + return maintenanceWindowTimeZone; + } } 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 acae2d49f..5fbeb070d 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 @@ -91,6 +91,18 @@ public class TenantConfigurationProperties { */ public static final String POLLING_TIME_INTERVAL = "pollingTime"; + /** + * See system default in + * {@link ControllerPollProperties#getMinPollingTime()}. + */ + public static final String MIN_POLLING_TIME_INTERVAL = "minPollingTime"; + + /** + * See system default in + * {@link ControllerPollProperties#getMaintenanceWindowPollCount()}. + */ + public static final String MAINTENANCE_WINDOW_POLL_COUNT = "maintenanceWindowPollCount"; + /** * See system default in * {@link ControllerPollProperties#getPollingOverdueTime()}. diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/validator/TenantConfigurationIntegerValidator.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/validator/TenantConfigurationIntegerValidator.java new file mode 100644 index 000000000..6cd8899b4 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/validator/TenantConfigurationIntegerValidator.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) Siemens AG, 2018 + * + * 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.tenancy.configuration.validator; + +/** + * Specific tenant configuration validator, which validates that the given value + * is an Integer. + */ +public class TenantConfigurationIntegerValidator implements TenantConfigurationValidator { + + @Override + public Class validateToClass() { + return Integer.class; + } +} 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 6576d4a77..aca52648b 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 @@ -12,6 +12,15 @@ hawkbit.controller.pollingTime=00:05:00 hawkbit.controller.pollingOverdueTime=00:05:00 hawkbit.controller.maxPollingTime=23:59:59 hawkbit.controller.minPollingTime=00:00:30 + +# This configuration value is used to change the polling interval so that +# controller tries to poll at least these many times between the last polling +# and before start of maintenance window. The polling interval is bounded by +# configured pollingTime and minPollingTime. The polling interval is +# modified as per following scheme: +# pollingTime(@time=t) = (maintenanceStartTime - t)/maintenanceWindowPollCount +hawkbit.controller.maintenanceWindowPollCount=3 + # Attention: if you want to use a maximumPollingTime greater 23:59:59 you have to update the DurationField in the configuration window # Default tenant configuration - START @@ -45,10 +54,19 @@ hawkbit.server.tenant.configuration.polling-time.keyName=pollingTime hawkbit.server.tenant.configuration.polling-time.defaultValue=${hawkbit.controller.pollingTime} hawkbit.server.tenant.configuration.polling-time.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationPollingDurationValidator +hawkbit.server.tenant.configuration.min-polling-time.keyName=minPollingTime +hawkbit.server.tenant.configuration.min-polling-time.defaultValue=${hawkbit.controller.minPollingTime} +hawkbit.server.tenant.configuration.min-polling-time.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationPollingDurationValidator + hawkbit.server.tenant.configuration.polling-overdue-time.keyName=pollingOverdueTime hawkbit.server.tenant.configuration.polling-overdue-time.defaultValue=${hawkbit.controller.pollingOverdueTime} hawkbit.server.tenant.configuration.polling-overdue-time.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationPollingDurationValidator +hawkbit.server.tenant.configuration.maintenance-window-poll-count.keyName=maintenanceWindowPollCount +hawkbit.server.tenant.configuration.maintenance-window-poll-count.defaultValue=${hawkbit.controller.maintenanceWindowPollCount} +hawkbit.server.tenant.configuration.maintenance-window-poll-count.dataType=java.lang.Integer +hawkbit.server.tenant.configuration.maintenance-window-poll-count.validator=org.eclipse.hawkbit.tenancy.configuration.validator.TenantConfigurationIntegerValidator + hawkbit.server.tenant.configuration.anonymous-download-enabled.keyName=anonymous.download.enabled hawkbit.server.tenant.configuration.anonymous-download-enabled.defaultValue=${hawkbit.server.download.anonymous.enabled} hawkbit.server.tenant.configuration.anonymous-download-enabled.dataType=java.lang.Boolean 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 ffa5da1ae..ed127be92 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 @@ -219,6 +219,9 @@ public abstract class AbstractDsAssignmentStrategy { actionForTarget.setActive(true); actionForTarget.setTarget(target); actionForTarget.setDistributionSet(set); + actionForTarget.setMaintenanceSchedule(targetWithActionType.getMaintenanceSchedule()); + actionForTarget.setMaintenanceWindowDuration(targetWithActionType.getMaintenanceWindowDuration()); + actionForTarget.setMaintenanceWindowTimeZone(targetWithActionType.getMaintenanceWindowTimeZone()); return actionForTarget; } 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 73954b20a..dd287c1bb 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 @@ -9,7 +9,15 @@ package org.eclipse.hawkbit.repository.jpa; import java.net.URI; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.Collection; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.Collections; import java.util.List; import java.util.Map; @@ -29,12 +37,14 @@ import javax.persistence.criteria.Root; import org.eclipse.hawkbit.repository.ControllerManagement; import org.eclipse.hawkbit.repository.EntityFactory; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.RepositoryConstants; import org.eclipse.hawkbit.repository.RepositoryProperties; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; import org.eclipse.hawkbit.repository.event.remote.TargetPollEvent; +import org.eclipse.hawkbit.repository.event.remote.entity.CancelTargetAssignmentEvent; import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; @@ -71,6 +81,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.transaction.PlatformTransactionManager; @@ -175,6 +186,142 @@ public class JpaControllerManagement implements ControllerManagement { .getConfigurationValue(TenantConfigurationKey.POLLING_TIME_INTERVAL, String.class).getValue()); } + /** + * Returns the configured minimum polling interval. + * + * @return current {@link TenantConfigurationKey#MIN_POLLING_TIME_INTERVAL}. + */ + @Override + public String getMinPollingTime() { + return systemSecurityContext.runAsSystem(() -> tenantConfigurationManagement + .getConfigurationValue(TenantConfigurationKey.MIN_POLLING_TIME_INTERVAL, String.class).getValue()); + } + + /** + * Returns the count to be used for reducing polling interval while calling + * {@link ControllerManagement#getPollingTimeForAction()}. + * + * @return configured value of + * {@link TenantConfigurationKey#MAINTENANCE_WINDOW_POLL_COUNT}. + */ + @Override + public int getMaintenanceWindowPollCount() { + return systemSecurityContext.runAsSystem(() -> tenantConfigurationManagement + .getConfigurationValue(TenantConfigurationKey.MAINTENANCE_WINDOW_POLL_COUNT, Integer.class).getValue()); + } + + /** + * Returns polling time based on the maintenance window for an action. + * Server will reduce the polling interval as the start time for maintenance + * window approaches, so that at least these many attempts are made between + * current polling until start of maintenance window. Poll time keeps + * reducing with MinPollingTime as lower limit + * {@link TenantConfigurationKey#MIN_POLLING_TIME_INTERVAL}. After the start + * of maintenance window, it resets to default + * {@link TenantConfigurationKey#POLLING_TIME_INTERVAL}. + * + * @param action + * id the {@link Action} for which polling time is calculated + * based on it having maintenance window or not + * + * @return current {@link TenantConfigurationKey#POLLING_TIME_INTERVAL}. + */ + @Override + public String getPollingTimeForAction(final Action action) { + if (action == null || !action.hasMaintenanceSchedule() || action.isMaintenanceScheduleLapsed()) { + return getPollingTime(); + } + + JpaAction jpaAction = getActionAndThrowExceptionIfNotFound(action.getId()); + return (new EventTimer(getPollingTime(), getMinPollingTime(), ChronoUnit.SECONDS)) + .timeToNextEvent(getMaintenanceWindowPollCount(), jpaAction.getMaintenanceWindowStartTime().get()); + } + + /** + * EventTimer to handle reduction of polling interval based on maintenance + * window start time. Class models the next polling time as an event to be + * raised and time to next polling as a timer. The event, in this case the + * polling, should happen when timer expires. Class makes use of java.time + * package to manipulate and calculate timer duration. + */ + private class EventTimer { + + private final String defaultEventInterval; + private final Duration defaultEventIntervalDuration; + + private final String minimumEventInterval; + private final Duration minimumEventIntervalDuration; + + private final TemporalUnit timeUnit; + + /** + * Constructor. + * + * @param defaultEventInterval + * default timer value to use for interval between events. + * This puts an upper bound for the timer value + * @param minimumEventInterval + * for loading {@link DistributionSet#getModules()}. This + * puts a lower bound to the timer value + * @param timerUnit + * representing the unit of time to be used for timer. + */ + EventTimer(String defaultEventInterval, String minimumEventInterval, TemporalUnit timeUnit) { + this.defaultEventInterval = defaultEventInterval; + this.defaultEventIntervalDuration = Duration + .parse(MaintenanceScheduleHelper.convertToISODuration(defaultEventInterval)); + + this.minimumEventInterval = minimumEventInterval; + this.minimumEventIntervalDuration = Duration + .parse(MaintenanceScheduleHelper.convertToISODuration(minimumEventInterval)); + + this.timeUnit = timeUnit; + } + + /** + * This method calculates the time interval until the next event based + * on the desired number of events before the time when interval is + * reset to default. The return value is bounded by + * {@link EventTimer#defaultEventInterval} and + * {@link EventTimer#minimumEventInterval}. + * + * @param eventCount + * number of events desired until the interval is reset to + * default. This is not guaranteed as the interval between + * events cannot be less than the minimum interval + * @param timerResetTime + * time when exponential forwarding should reset to default + * + * @return String in HH:mm:ss format for time to next event. + */ + String timeToNextEvent(int eventCount, ZonedDateTime timerResetTime) { + ZonedDateTime currentTime = ZonedDateTime.now(); + + // If there is no reset time, or if we already past the reset time, + // return the default interval. + if (timerResetTime == null || currentTime.compareTo(timerResetTime) > 0) { + return defaultEventInterval; + } + + // Calculate the interval timer based on desired event count. + Duration currentIntervalDuration = Duration.of(currentTime.until(timerResetTime, timeUnit), timeUnit) + .dividedBy(eventCount); + + // Need not return interval greater than the default. + if (currentIntervalDuration.compareTo(defaultEventIntervalDuration) > 0) { + return defaultEventInterval; + } + + // Should not return interval less than minimum. + if (currentIntervalDuration.compareTo(minimumEventIntervalDuration) < 0) { + return minimumEventInterval; + } + + return String.format("%02d:%02d:%02d", currentIntervalDuration.toHours(), + currentIntervalDuration.toMinutes() % 60, currentIntervalDuration.getSeconds() % 60); + } + } + @Override public Optional getActionForDownloadByTargetAndSoftwareModule(final String controllerId, final long moduleId) { @@ -748,4 +895,55 @@ public class JpaControllerManagement implements ControllerManagement { } } + + /** + * Cancels given {@link Action} for this {@link Target}. The method will + * immediately add a {@link Status#CANCELED} status to the action. However, + * it might be possible that the controller will continue to work on the + * cancelation. The controller needs to acknowledge or reject the + * cancelation using {@link DdiRootController#postCancelActionFeedback}. + * + * @param actionId + * to be canceled + * + * @return canceled {@link Action} + * + * @throws CancelActionNotAllowedException + * in case the given action is not active or is already canceled + * @throws EntityNotFoundException + * if action with given actionId does not exist. + */ + @Modifying + @Transactional(isolation = Isolation.READ_COMMITTED) + public Action cancelAction(long actionId) { + LOG.debug("cancelAction({})", actionId); + + final JpaAction action = actionRepository.findById(actionId) + .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)); + + if (action.isCancelingOrCanceled()) { + throw new CancelActionNotAllowedException("Actions in canceling or canceled state cannot be canceled"); + } + + if (action.isActive()) { + LOG.debug("action ({}) was still active. Change to {}.", action, Status.CANCELING); + action.setStatus(Status.CANCELING); + + // document that the status has been retrieved + actionStatusRepository.save(new JpaActionStatus(action, Status.CANCELING, System.currentTimeMillis(), + "manual cancelation requested")); + final Action saveAction = actionRepository.save(action); + cancelAssignDistributionSetEvent((JpaTarget) action.getTarget(), action.getId()); + + return saveAction; + } else { + throw new CancelActionNotAllowedException( + "Action [id: " + action.getId() + "] is not active and cannot be canceled"); + } + } + + private void cancelAssignDistributionSetEvent(final JpaTarget target, final Long actionId) { + afterCommit.afterCommit(() -> eventPublisher + .publishEvent(new CancelTargetAssignmentEvent(target, actionId, applicationContext.getId()))); + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java index 87d0097f5..5882b4030 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaAction.java @@ -8,8 +8,12 @@ */ package org.eclipse.hawkbit.repository.jpa.model; +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Collections; import java.util.List; +import java.util.Optional; import javax.persistence.Column; import javax.persistence.ConstraintMode; @@ -27,6 +31,7 @@ import javax.persistence.OneToMany; import javax.persistence.Table; import javax.validation.constraints.NotNull; +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; import org.eclipse.hawkbit.repository.event.remote.entity.ActionCreatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.ActionUpdatedEvent; import org.eclipse.hawkbit.repository.model.Action; @@ -95,7 +100,8 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio @ConversionValue(objectValue = "RETRIEVED", dataValue = "6"), @ConversionValue(objectValue = "DOWNLOAD", dataValue = "7"), @ConversionValue(objectValue = "SCHEDULED", dataValue = "8"), - @ConversionValue(objectValue = "CANCEL_REJECTED", dataValue = "9") }) + @ConversionValue(objectValue = "CANCEL_REJECTED", dataValue = "9"), + @ConversionValue(objectValue = "DOWNLOADED", dataValue = "10")}) @Convert("status") @NotNull private Status status; @@ -112,6 +118,20 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio @JoinColumn(name = "rollout", updatable = false, foreignKey = @ForeignKey(value = ConstraintMode.CONSTRAINT, name = "fk_action_rollout")) private JpaRollout rollout; + @Column(name = "maintenance_cron_schedule", length = Action.MAINTENANCE_SCHEDULE_CRON_LENGTH) + private String maintenanceSchedule; + + @Column(name = "maintenance_duration", length = Action.MAINTENANCE_WINDOW_DURATION_LENGTH) + private String maintenanceWindowDuration; + + @Column(name = "maintenance_time_zone", length = Action.MAINTENANCE_WINDOW_TIMEZONE_LENGTH) + private String maintenanceWindowTimeZone; + + /** + * A transient (non serialized) maintenance schedule helper. + */ + private transient MaintenanceScheduleHelper scheduleHelper = null; + @Override public DistributionSet getDistributionSet() { return distributionSet; @@ -217,4 +237,144 @@ public class JpaAction extends AbstractJpaTenantAwareBaseEntity implements Actio // there is no action deletion } + /** + * Sets the maintenance schedule. + * + * @param maintenanceSchedule + * is a cron expression to be used for scheduling. + */ + public void setMaintenanceSchedule(String maintenanceSchedule) { + this.maintenanceSchedule = maintenanceSchedule; + } + + /** + * Sets the maintenance window duration. + * + * @param maintenanceWindowDuration + * is the duration of an available maintenance schedule in + * HH:mm:ss format. + */ + public void setMaintenanceWindowDuration(String maintenanceWindowDuration) { + this.maintenanceWindowDuration = maintenanceWindowDuration; + } + + /** + * Sets the time zone to be used for maintenance window. + * + * @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. + */ + public void setMaintenanceWindowTimeZone(String maintenanceWindowTimeZone) { + this.maintenanceWindowTimeZone = maintenanceWindowTimeZone; + } + + /** + * Get the transient schedule helper. Instantiate one if not already done + * after deserialization. + * + * @return the {@link MaintenanceScheduleHelper} object. + */ + MaintenanceScheduleHelper getScheduler() { + if (this.scheduleHelper == null) { + this.scheduleHelper = new MaintenanceScheduleHelper(maintenanceSchedule, maintenanceWindowDuration, + maintenanceWindowTimeZone); + } + return this.scheduleHelper; + } + + /** + * Returns the duration of each maintenance window in ISO 8601 format. + * + * @return the {@link Duration} of each maintenance window. + */ + Duration getMaintenanceWindowDuration() { + return Duration.parse(MaintenanceScheduleHelper.convertToISODuration(this.maintenanceWindowDuration)); + } + + /** + * Returns the start time of next available maintenance window for the + * {@link Action} as {@link ZonedDateTime}. If a maintenance window is + * already active, the start time of currently active window is returned. + * + * @return the start time as {@link Optional}. + */ + public Optional getMaintenanceWindowStartTime() { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(maintenanceWindowTimeZone)); + return getScheduler().nextExecution(now.minus(getMaintenanceWindowDuration())); + } + + /** + * Returns the end time of next available or active maintenance window for + * the {@link Action} as {@link ZonedDateTime}. If a maintenance window is + * already active, the end time of currently active window is returned. + * + * @return the end time of window as {@link Optional}. + */ + public Optional getMaintenanceWindowEndTime() { + if (getMaintenanceWindowStartTime().isPresent()) { + return Optional.of(getMaintenanceWindowStartTime().get().plus(getMaintenanceWindowDuration())); + } + return Optional.empty(); + } + + /** + * The method checks whether the action has a maintenance schedule defined + * for it. A maintenance schedule defines a set of maintenance windows + * during which actual update can be performed. A valid schedule defines at + * least one maintenance window. + * + * @return true if action has a maintenance schedule, else false. + */ + @Override + public boolean hasMaintenanceSchedule() { + return this.maintenanceSchedule != null; + } + + /** + * The method checks whether the maintenance schedule has already lapsed for + * the action, i.e. there are no more windows available for maintenance. + * Controller manager uses the method to check if the maintenance schedule + * has lapsed, and automatically cancels the action if it is lapsed. + * + * @return true if maintenance schedule has lapsed, else false. + */ + @Override + public boolean isMaintenanceScheduleLapsed() { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(maintenanceWindowTimeZone)); + return !getScheduler().nextExecution(now.minus(getMaintenanceWindowDuration())).isPresent(); + } + + /** + * The method checks whether a maintenance window is available for the + * action to proceed. If it is available, a 'true' value is returned. The + * maintenance window is considered available: 1) If there is no maintenance + * schedule at all, in which case device can start update any time after + * download is finished; or 2) the current time is within a scheduled + * maintenance window start and end time. + * + * @return true if maintenance window is available, else false. + */ + @Override + public boolean isMaintenanceWindowAvailable() { + if (!hasMaintenanceSchedule()) { + // if there is no defined maintenance schedule, a window is always + // available. + return true; + } else if (isMaintenanceScheduleLapsed()) { + // if a defined maintenance schedule has lapsed, a window is never + // available. + return false; + } else { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.of(maintenanceWindowTimeZone)); + if (this.getMaintenanceWindowStartTime().isPresent() && this.getMaintenanceWindowEndTime().isPresent()) { + return now.isAfter(this.getMaintenanceWindowStartTime().get()) + && now.isBefore(this.getMaintenanceWindowEndTime().get()); + } else { + return false; + } + } + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaActionStatus.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaActionStatus.java index b976958f4..9e8b46b93 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaActionStatus.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaActionStatus.java @@ -72,7 +72,8 @@ public class JpaActionStatus extends AbstractJpaTenantAwareBaseEntity implements @ConversionValue(objectValue = "RETRIEVED", dataValue = "6"), @ConversionValue(objectValue = "DOWNLOAD", dataValue = "7"), @ConversionValue(objectValue = "SCHEDULED", dataValue = "8"), - @ConversionValue(objectValue = "CANCEL_REJECTED", dataValue = "9") }) + @ConversionValue(objectValue = "CANCEL_REJECTED", dataValue = "9"), + @ConversionValue(objectValue = "DOWNLOADED", dataValue = "10")}) @Convert("status") @NotNull private Status status; diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_4__add_maintenance_window___H2.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_4__add_maintenance_window___H2.sql new file mode 100644 index 000000000..6c546cede --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_4__add_maintenance_window___H2.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_action ADD column maintenance_cron_schedule VARCHAR(40); +ALTER TABLE sp_action ADD column maintenance_duration VARCHAR(40); +ALTER TABLE sp_action ADD column maintenance_time_zone VARCHAR(40); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_4__add_maintenance_window___MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_4__add_maintenance_window___MYSQL.sql new file mode 100644 index 000000000..6c546cede --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_4__add_maintenance_window___MYSQL.sql @@ -0,0 +1,3 @@ +ALTER TABLE sp_action ADD column maintenance_cron_schedule VARCHAR(40); +ALTER TABLE sp_action ADD column maintenance_duration VARCHAR(40); +ALTER TABLE sp_action ADD column maintenance_time_zone VARCHAR(40); 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 115e0d546..058a9ea79 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 @@ -16,8 +16,15 @@ import java.io.IOException; import java.net.URI; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.RandomStringUtils; @@ -227,6 +234,41 @@ public abstract class AbstractIntegrationTest { new TargetWithActionType(controllerId, ActionType.FORCED, RepositoryModelConstants.NO_FORCE_TIME))); } + /** + * Test helper method to assign distribution set to a target with a + * maintenance schedule. + * + * @param dsID + * is the ID for the distribution set being assigned + * @param controllerId + * is the ID for the controller to which the distribution set is + * being assigned + * @param maintenanceSchedule + * is the cron expression to be used for scheduling the + * maintenance window. Expression has 6 mandatory fields and 1 + * last optional field: "second minute hour dayofmonth month + * weekday year" + * @param maintenanceWindowDuration + * in HH:mm:ss format specifying the duration of a maintenance + * 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 + * + * @return result of the assignment as + * {@link DistributionSetAssignmentResult}. + */ + protected DistributionSetAssignmentResult assignDistributionSetWithMaintenanceWindow(final Long dsID, + final String controllerId, final String maintenanceSchedule, final String maintenanceWindowDuration, + final String maintenanceWindowTimeZone) { + return deploymentManagement.assignDistributionSet(dsID, + Arrays.asList(new TargetWithActionType(controllerId, ActionType.FORCED, + RepositoryModelConstants.NO_FORCE_TIME, maintenanceSchedule, maintenanceWindowDuration, + maintenanceWindowTimeZone))); + } + protected DistributionSetAssignmentResult assignDistributionSet(final DistributionSet pset, final List targets) { return deploymentManagement.assignDistributionSet(pset.getId(), @@ -325,4 +367,38 @@ public abstract class AbstractIntegrationTest { } } } + + /** + * Gets a valid cron expression describing a schedule with a single + * maintenance window, starting specified number of minutes after current + * time. + * + * @param minutesToAdd + * is the number of minutes after the current time + * + * @return {@link String} containing a valid cron expression. + */ + public static String getTestSchedule(int minutesToAdd) { + ZonedDateTime currentTime = ZonedDateTime.now(); + currentTime = currentTime.plusMinutes(minutesToAdd); + return String.format("0 %d %d %d %d ? %d", currentTime.getMinute(), currentTime.getHour(), + currentTime.getDayOfMonth(), currentTime.getMonthValue(), currentTime.getYear()); + } + + public static String getTestDuration(int duration) { + return String.format("%02d:%02d:00", duration / 60, duration % 60); + } + + public static String getTestTimeZone() { + ZonedDateTime currentTime = ZonedDateTime.now(); + return currentTime.getOffset().getId().replace("Z", "+00:00"); + } + + public static Map getMaintenanceWindow(String schedule, String duration, String timezone) { + Map maintenanceWindowMap = new HashMap<>(); + maintenanceWindowMap.put("schedule", schedule); + maintenanceWindowMap.put("duration", duration); + maintenanceWindowMap.put("timezone", timezone); + return maintenanceWindowMap; + } } diff --git a/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java b/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java index 79f0601a0..3933ed9e9 100644 --- a/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java +++ b/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java @@ -74,6 +74,7 @@ public class ResponseExceptionHandler { ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_CONSTRAINT_VIOLATION, HttpStatus.BAD_REQUEST); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_OPERATION_NOT_SUPPORTED, HttpStatus.GONE); 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); } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java new file mode 100644 index 000000000..28ed12b26 --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java @@ -0,0 +1,246 @@ +/** + * Copyright (c) Siemens AG, 2018 + * + * 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.ui.management.footer; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.repository.MaintenanceScheduleHelper; +import org.eclipse.hawkbit.ui.utils.SPDateTimeUtil; +import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; + +import com.vaadin.data.Property.ValueChangeEvent; +import com.vaadin.data.Property.ValueChangeListener; +import com.vaadin.data.Validator; +import com.vaadin.ui.CheckBox; +import com.vaadin.ui.ComboBox; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Notification; +import com.vaadin.ui.TextField; +import com.vaadin.ui.VerticalLayout; +import com.vaadin.ui.themes.ValoTheme; + +/** + * {@link MaintenanceWindowLayout} defines UI layout that is used to specify the + * maintenance schedule while assigning distribution set(s) to the target(s). + */ +public class MaintenanceWindowLayout extends VerticalLayout { + + private static final long serialVersionUID = 722511089585562455L; + + private final VaadinMessageSource i18n; + + private CheckBox maintenanceWindowSelection; + private TextField schedule; + private TextField duration; + private ComboBox timeZone; + + /** + * Constructor for the control to specify the maintenance schedule. + * + * @param i18n + * (@link VaadinMessageSource} to get the localized resource + * strings. + */ + public MaintenanceWindowLayout(final VaadinMessageSource i18n) { + + HorizontalLayout optionContainer; + HorizontalLayout controlContainer; + + this.i18n = i18n; + + optionContainer = new HorizontalLayout(); + controlContainer = new HorizontalLayout(); + addComponent(optionContainer); + addComponent(controlContainer); + + createMaintenanceWindowOption(); + createMaintenanceScheduleControl(); + createMaintenanceDurationControl(); + createMaintenanceTimeZoneControl(); + + optionContainer.addComponent(maintenanceWindowSelection); + controlContainer.addComponent(schedule); + controlContainer.addComponent(duration); + controlContainer.addComponent(timeZone); + + addValueChangeListener(); + maintenanceWindowSelection.setValue(false); + setStyleName("dist-window-maintenance-window-layout"); + } + + /** + * Validates if the maintenance schedule is a valid cron expression. + */ + class CronValidation implements Validator { + private static final long serialVersionUID = 1L; + + @Override + public void validate(Object value) throws InvalidValueException { + try { + String expr = (String) value; + if (!expr.isEmpty()) { + new MaintenanceScheduleHelper((String) value, "00:00:00", getClientTimeZone()); + } + } catch (IllegalArgumentException e) { + Notification.show(e.getMessage()); + throw e; + } + } + } + + /** + * Validates if the duration is specified in expected format. + */ + class DurationValidator implements Validator { + private static final long serialVersionUID = 1L; + + @Override + public void validate(Object value) throws InvalidValueException { + try { + String expr = (String) value; + if (!expr.isEmpty()) { + MaintenanceScheduleHelper.convertToISODuration((String) value); + } + } catch (DateTimeParseException e) { + Notification.show(e.getMessage()); + throw e; + } + } + } + + /** + * Create check box to enable or disable maintenance window. + */ + private void createMaintenanceWindowOption() { + maintenanceWindowSelection = new CheckBox(i18n.getMessage("caption.maintenancewindow.enable")); + maintenanceWindowSelection.addStyleName(ValoTheme.CHECKBOX_SMALL); + } + + /** + * Text field to specify the schedule. + */ + private void createMaintenanceScheduleControl() { + schedule = new TextField(); + schedule.setCaption(i18n.getMessage("caption.maintenancewindow.schedule")); + schedule.addValidator(new CronValidation()); + schedule.setEnabled(false); + schedule.addStyleName(ValoTheme.TEXTFIELD_SMALL); + } + + /** + * Text field to specify the duration. + */ + private void createMaintenanceDurationControl() { + duration = new TextField(); + duration.setCaption(i18n.getMessage("caption.maintenancewindow.duration")); + duration.addValidator(new DurationValidator()); + duration.setEnabled(false); + schedule.addStyleName(ValoTheme.TEXTFIELD_SMALL); + } + + /** + * Combo box to pick the time zone offset. + */ + private void createMaintenanceTimeZoneControl() { + timeZone = new ComboBox(); + timeZone.setCaption(i18n.getMessage("caption.maintenancewindow.timezone")); + + timeZone.addItems(getAllTimeZones()); + timeZone.setTextInputAllowed(false); + timeZone.setValue(getClientTimeZone()); + + timeZone.setEnabled(false); + timeZone.addStyleName(ValoTheme.COMBOBOX_SMALL); + } + + /** + * Get time zone of the browser client to be used as default. + */ + private static String getClientTimeZone() { + return ZonedDateTime.now(ZoneId.of(SPDateTimeUtil.getBrowserTimeZone().getID())).getOffset().getId() + .replaceAll("Z", "+00:00"); + } + + /** + * Get list of all time zone offsets supported. + */ + private static List getAllTimeZones() { + List lst = ZoneId.getAvailableZoneIds().stream() + .map(id -> ZonedDateTime.now(ZoneId.of(id)).getOffset().getId().replace("Z", "+00:00")).distinct() + .collect(Collectors.toList()); + lst.sort(null); + return lst; + } + + /** + * Create a listener to enable and disable maintenance schedule controls. + */ + private void addValueChangeListener() { + maintenanceWindowSelection.addValueChangeListener(new ValueChangeListener() { + private static final long serialVersionUID = 1L; + + @Override + public void valueChange(final ValueChangeEvent event) { + schedule.setEnabled(maintenanceWindowSelection.getValue()); + schedule.setRequired(maintenanceWindowSelection.getValue()); + schedule.setValue(""); + + duration.setEnabled(maintenanceWindowSelection.getValue()); + duration.setRequired(maintenanceWindowSelection.getValue()); + duration.setValue(""); + + timeZone.setEnabled(maintenanceWindowSelection.getValue()); + timeZone.setRequired(maintenanceWindowSelection.getValue()); + timeZone.setValue(getClientTimeZone()); + } + }); + } + + /** + * Get whether the maintenance schedule option is enabled or not. + * + * @return boolean. + */ + public boolean getMaintenanceOption() { + return maintenanceWindowSelection.getValue(); + } + + /** + * Get the cron expression for maintenance schedule. + * + * @return {@link String}. + */ + public String getMaintenanceSchedule() { + return schedule.getValue(); + } + + /** + * Get the maintenance window duration. + * + * @return {@link String}. + */ + public String getMaintenanceDuration() { + return duration.getValue(); + } + + /** + * Get the cron expression for maintenance window timezone. + * + * @return {@link String}. + */ + + public String getMaintenanceTimeZone() { + return timeZone.getValue().toString(); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java index 78708ce49..22f48e408 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/ManangementConfirmationWindowLayout.java @@ -22,6 +22,7 @@ import java.util.stream.Collectors; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement; import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.model.TargetWithActionType; import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.RepositoryModelConstants; @@ -78,6 +79,8 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin private final ActionTypeOptionGroupLayout actionTypeOptionGroupLayout; + private final MaintenanceWindowLayout maintenanceWindowLayout; + private ConfirmationTab assignmentTab; public ManangementConfirmationWindowLayout(final VaadinMessageSource i18n, final UIEventBus eventBus, @@ -90,6 +93,7 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin this.deploymentManagement = deploymentManagement; this.distributionSetManagement = distributionSetManagement; this.actionTypeOptionGroupLayout = new ActionTypeOptionGroupLayout(i18n); + this.maintenanceWindowLayout = new MaintenanceWindowLayout(i18n); } @Override @@ -138,6 +142,7 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin actionTypeOptionGroupLayout.selectDefaultOption(); assignmentTab.addComponent(actionTypeOptionGroupLayout, 1); + assignmentTab.addComponent(maintenanceWindowLayout, 1); return assignmentTab; } @@ -154,6 +159,15 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin ? actionTypeOptionGroupLayout.getForcedTimeDateField().getValue().getTime() : RepositoryModelConstants.NO_FORCE_TIME; + final String maintenanceSchedule = maintenanceWindowLayout.getMaintenanceOption() + ? maintenanceWindowLayout.getMaintenanceSchedule() : null; + + final String maintenanceDuration = maintenanceWindowLayout.getMaintenanceOption() + ? maintenanceWindowLayout.getMaintenanceDuration() : null; + + final String maintenanceTimeZone = maintenanceWindowLayout.getMaintenanceOption() + ? maintenanceWindowLayout.getMaintenanceTimeZone() : null; + final Map> saveAssignedList = Maps.newHashMapWithExpectedSize(itemIds.size()); int successAssignmentCount = 0; @@ -174,8 +188,11 @@ public class ManangementConfirmationWindowLayout extends AbstractConfirmationWin for (final Map.Entry> mapEntry : saveAssignedList.entrySet()) { tempIdList = saveAssignedList.get(mapEntry.getKey()); final DistributionSetAssignmentResult distributionSetAssignmentResult = deploymentManagement - .assignDistributionSet(mapEntry.getKey(), actionType, forcedTimeStamp, - tempIdList.stream().map(t -> t.getControllerId()).collect(Collectors.toList())); + .assignDistributionSet(mapEntry.getKey(), + tempIdList.stream() + .map(t -> new TargetWithActionType(t.getControllerId(), actionType, forcedTimeStamp, + maintenanceSchedule, maintenanceDuration, maintenanceTimeZone)) + .collect(Collectors.toList())); if (distributionSetAssignmentResult.getAssigned() > 0) { successAssignmentCount += distributionSetAssignmentResult.getAssigned(); diff --git a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss index ee912f72b..48f4b2717 100644 --- a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss +++ b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/popup-window.scss @@ -83,6 +83,42 @@ @include sp-button-icon-only-href; } + .dist-window-maintenance-window-layout { + vertical-align: middle; + horizontal-align: middle; + + .v-slot { + vertical-align: middle; + padding-left: 8px; + padding-right: 5px; + } + + .v-caption { + vertical-align: middle; + margin-top: 0; + font-size: 12px; + } + + .v-checkbox { + font-size: 12px; + font-weight: 400; + } + + .v-filterselect { + font-size: 12px; + font-weight: 400; + width: 160px; + height: 24px; + } + + .v-textfield { + font-size: 12px; + font-weight: 400; + width: 160px; + height: 24px; + } + } + ///force,soft,time forced radio button styles - start** .dist-window-actiontype-horz-layout { padding-bottom: 15px; diff --git a/hawkbit-ui/src/main/resources/messages.properties b/hawkbit-ui/src/main/resources/messages.properties index 2a3e53146..ffaab0ae2 100644 --- a/hawkbit-ui/src/main/resources/messages.properties +++ b/hawkbit-ui/src/main/resources/messages.properties @@ -139,6 +139,11 @@ caption.metadata.delete.action.confirmbox = Confirm Metadata Delete Action caption.confirm.assign.consequences = Auto assign consequences caption.auto.assignment.ds = Auto assignment +caption.maintenancewindow.enable = Maintenance Schedule +caption.maintenancewindow.schedule = Schedule (Cron Expression) +caption.maintenancewindow.duration = Duration (hh:mm:ss) +caption.maintenancewindow.timezone = Time Zone + # Labels prefix with - label label.dist.details.type = Type : label.dist.details.name = Name :