From f4278c45ef1a34c6e7add4299614c9e8e1abf0a0 Mon Sep 17 00:00:00 2001 From: stormc Date: Tue, 6 Mar 2018 16:17:31 +0100 Subject: [PATCH] 245 maintenance window (#535) * Maintenance Window: Introduced dynamic update to polling time. (#245) Resolves #245 Added new configuration parameters and controller handling that can be used to enable reduction in polling time near to start of a maintenance window. - The device may only get to know of start of maintenance window when the device polls the server. If a window is available, server responds with maintenanceWindow as 'available' and the update as 'forced'. Hence, as the time for start of a maintenance window approaches, the device should poll the server more often in order to decrease the possibility of missing the maintenance window start time due to non-alignment of polling with the start time, or due to packet losses and other network issues. - Additional configuration parameter is introduced that will be used to specify the minimum number of times the controller should poll between current time and the start of maintenance window. Using this parameter, an "inverse exponential back-off" scheme for reducing the polling time is implemented. - Implemented inverse exponential back-off for polling. For example, considering default polling-time = 00:05:00, min-polling-time = 00:00:30, maintenance-window-poll-count = 3, we expect that controller should poll the server 3 times between current time and start of a maintenance window. Thus in following scenario controller will get to know of start of maintenance 3 secs after scheduled start. Whereas without this scheme, delay may be as much as 4 minutes. time until start: 00:21:00; polling time: 00:05:00 time until start: 00:16:00; polling time: 00:05:00 time until start: 00:11:00; polling time: 00:03:40 time until start: 00:07:20; polling time: 00:02:27 time until start: 00:04:53; polling time: 00:01:38 time until start: 00:02:10; polling time: 00:00:43 time until start: 00:01:27; polling time: 00:00:30 time until start: 00:00:57; polling time: 00:00:30 time until start: 00:00:27; polling time: 00:00:30 time until start: -00:00:03; polling time: 00:05:00 - Once a maintenance window starts, the polling time is reverted to default. Polling time is not changed near the end of a maintenance window. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Updated the DMF API. Added maintenance window handling for DMF API. Currently no timer or scheduler is used to send updates to devices when a maintenance window becomes available. The device can simulate a poll behaviour by sending a message on UPDATE_ACTION_STATUS topic. Depending on whether there is a maintenance window available or not, server should send DOWNLOAD_AND_INSTALL or DOWNLOAD_AND_SKIP. - Additional action status (DOWNLOADED) and event topic (DOWNLOAD_AND_SKIP) - Server sends DOWNLOAD_AND_SKIP or DOWNLOAD_AND_INSTALL based on maintenance window using ControllerManagement Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Added tests for DDI and Management APIs. Added test cases for DDI and Management API to test for maintenance window related logic. - Tests for Management API for DistributionSet assignment with and without valid maintenance schedule specified. - Tests for lapsed maintenance schedule. - Additional test helper methods. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Added new fields, enums, APIs and helper classes. Extended the data model and Action API to support maintenance schedule. Also provided helper classes to parse cron expression and calculate maintenance window based on the schedule. - DOWNLOADED status added to Action interface for controller to record action status. - Extra fields and APIs are added to Action to set schedule, duration and time zone of maintenance window. - Maintenance schedule can be defined using: a cron expression (e.g. "0 30 10-13 ? * WED,FRI" for 30 minutes past the hour, between 10:00 AM and 01:59 PM, only on Wednesday and Friday), a duration (e.g. "00:30:00" for 30 minutes) and the time zone (e.g. "+02:00" for CET summer time or "+00:00" for UTC) relative to which maintenance window start time is calculated. - Extra APIs added to check if a maintenance schedule is defined or lapsed and if a maintenance window is available based on the schedule. - Added database fields to store the schedule, duration and timezone of maintenance window. - Added helper classes to parse and calculate the maintenance window schedule. - Added exception handling classes to handle invalid maintenance window schedule. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Extended JSON model for REST APIs. Extended the JSON model to support defining and communicating the maintenance window information using the REST APIs. - Additional JSON objects are defined to extend request and response bodies. - When requesting a distribution set assignment to a target, the maintenance schedule information can be specified. Example below specifies a maintenance schedule at 11:00 PM, only on Saturday, only in 2017, for 30 minutes. Maintenance window start time is then calculated relative to the time zone specified, in this case CET summer time. $ curl 'http://127.0.0.1:8080/rest/v1/targets/2/assignedDS' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' -d '{ "forcetime" : "0", "id" : "1", "type" : "forced", "maintenanceWindow": { "schedule": "0 0 23 ? * SAT 2017", "duration": "00:30:00", "timezone": "+02:00" } }' - When device requests the action information it gets to know if there is a 'maintenance schedule' for this action or not based on presence of maintenanceWindow field in the response. The value of maintenanceWindow 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 field is null or not present. $ curl 'http://127.0.0.1:8080/default/controller/v1/2/deploymentBase/1?c=411629670' -i -H 'Accept: application/hal+json' HTTP/1.1 200 OK Content-Type: application/hal+json;charset=UTF-8 Content-Length: 1665 { "id": "1", "deployment": { "download": "forced", "update": "forced", "chunks": [ { "part": "os", "version": "1", "name": "1", "artifacts" : [ { "filename" : "binary.tgz", "hashes" : { "sha1" : "8b71973e835cd0718827238b1cf89f0079e44dae", "md5" : "a90218782025c48e09ba0436cf30d029" }, "size" : 5, "_links" : { "download" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" }, "download-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" } } } ] } ], "maintenanceWindow": "available" } } Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Added additional UI elements. Added additional UI elements to specify maintenance window schedule in hawkBit UI while assigning distribution set(s) to target(s). - Added UI control to hawkBit UI when distribution set assignment is confirmed. - Also added required theme updates and resource strings. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * Maintenance Window: Modified DDI and Mgmt APIs for maintenance window. Modified controller management and deployment management to handle maintenance window schedule in Management API and DDI API. - Modified REST endpoints to accept and respond with maintenance window information. $ curl 'http://127.0.0.1:8080/rest/v1/targets/2/assignedDS' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' -d '{ "forcetime" : "0", "id" : "1", "type" : "forced", "maintenanceWindow": { "schedule": "0 0 23 ? * SAT 2017", "duration": "00:30:00", "timezone": "+02:00" } }' - Check for availability of a maintenance window based on the schedule. - Device gets go-ahead to install by change in ETAG and 'update' status to 'forced' when it polls during an available maintenance window. $ curl 'http://127.0.0.1:8080/default/controller/v1/2/deploymentBase/1?c=411659461' -i -H 'Accept: application/hal+json' HTTP/1.1 200 OK Content-Type: application/hal+json;charset=UTF-8 Content-Length: 1665 { "id": "1", "deployment": { "download": "forced", "update": "forced", "chunks": [ { "part": "os", "version": "1", "name": "1", "artifacts" : [ { "filename" : "binary.tgz", "hashes" : { "sha1" : "8b71973e835cd0718827238b1cf89f0079e44dae", "md5" : "a90218782025c48e09ba0436cf30d029" }, "size" : 5, "_links" : { "download" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" }, "download-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" } } } ] } ], "maintenanceWindow": "available" } } - In case the current available maintenance window lapses, the device is again sent 'update' status as 'skip'. The device may choose then to abort the update and restart during next available window. $ curl 'http://127.0.0.1:8080/default/controller/v1/2/deploymentBase/1?c=411659455' -i -H 'Accept: application/hal+json' HTTP/1.1 200 OK Content-Type: application/hal+json;charset=UTF-8 Content-Length: 1665 { "id": "1", "deployment": { "download": "forced", "update": "skip", "chunks": [ { "part": "os", "version": "1", "name": "1", "artifacts" : [ { "filename" : "binary.tgz", "hashes" : { "sha1" : "8b71973e835cd0718827238b1cf89f0079e44dae", "md5" : "a90218782025c48e09ba0436cf30d029" }, "size" : 5, "_links" : { "download" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" }, "download-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz" }, "md5sum-http" : { "href" : "http://127.0.0.1:8080/api/v1/default/download/controller/2/softwaremodules/8/filename/binary.tgz.MD5SUM" } } } ] } ], "maintenanceWindow": "unavailable" } } - In case the entire maintenance schedule lapses, before device is able to send the completed status, the action is canceled. $ curl 'http://127.0.0.1:8080/default/controller/v1/2' -i -H 'Accept: application/hal+json' HTTP/1.1 200 OK Content-Type: application/hal+json;charset=UTF-8 Content-Length: 340 { "config": { "polling": { "sleep": "00:05:00" } }, "_links": { "cancelAction": { "href": "http://127.0.0.1:8080/default/controller/v1/2/cancelAction/1" }, "configData": { "href": "http://127.0.0.1:8080/default/controller/v1/2/configData" } } } Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS --- .../hawkbit/ControllerPollProperties.java | 29 +++ .../hawkbit/exception/SpServerError.java | 7 +- .../hawkbit/ddi/json/model/DdiDeployment.java | 55 +++- .../hawkbit/ddi/json/model/DdiStatus.java | 7 +- .../rest/resource/DataConversionHelper.java | 5 +- .../ddi/rest/resource/DdiRootController.java | 50 +++- .../rest/resource/DdiRootControllerTest.java | 95 +++++++ .../src/test/resources/ddi-test.properties | 3 + .../hawkbit/amqp/AmqpConfiguration.java | 7 +- .../amqp/AmqpMessageDispatcherService.java | 40 ++- .../amqp/AmqpMessageHandlerService.java | 6 +- .../AmqpMessageDispatcherServiceTest.java | 4 +- .../hawkbit/dmf/amqp/api/EventTopic.java | 6 +- .../dmf/json/model/DmfActionStatus.java | 7 +- .../mgmt/json/model/MaintenanceWindow.java | 90 +++++++ .../MgmtTargetAssignmentRequestBody.java | 27 ++ .../target/MgmtDistributionSetAssigment.java | 25 ++ .../resource/MgmtDistributionSetResource.java | 20 +- .../rest/resource/MgmtTargetResource.java | 17 +- .../MgmtDistributionSetResourceTest.java | 140 +++++++++- .../rest/resource/MgmtTargetResourceTest.java | 70 +++++ .../hawkbit-repository-api/pom.xml | 7 +- .../repository/ControllerManagement.java | 60 +++++ .../repository/MaintenanceScheduleHelper.java | 172 ++++++++++++ .../InvalidMaintenanceScheduleException.java | 44 ++++ .../hawkbit/repository/model/Action.java | 55 +++- .../model/TargetWithActionType.java | 72 +++++ .../TenantConfigurationProperties.java | 12 + .../TenantConfigurationIntegerValidator.java | 22 ++ .../hawkbit-repository-defaults.properties | 18 ++ .../jpa/AbstractDsAssignmentStrategy.java | 3 + .../jpa/JpaControllerManagement.java | 198 ++++++++++++++ .../repository/jpa/model/JpaAction.java | 162 +++++++++++- .../repository/jpa/model/JpaActionStatus.java | 3 +- .../V1_12_4__add_maintenance_window___H2.sql | 3 + ...1_12_4__add_maintenance_window___MYSQL.sql | 3 + .../test/util/AbstractIntegrationTest.java | 76 ++++++ .../exception/ResponseExceptionHandler.java | 1 + .../footer/MaintenanceWindowLayout.java | 246 ++++++++++++++++++ .../ManangementConfirmationWindowLayout.java | 21 +- .../hawkbit/customstyles/popup-window.scss | 36 +++ .../src/main/resources/messages.properties | 5 + 42 files changed, 1884 insertions(+), 45 deletions(-) create mode 100644 hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/MaintenanceWindow.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/MaintenanceScheduleHelper.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidMaintenanceScheduleException.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/validator/TenantConfigurationIntegerValidator.java create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_4__add_maintenance_window___H2.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_4__add_maintenance_window___MYSQL.sql create mode 100644 hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/footer/MaintenanceWindowLayout.java 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 :