From b69bedd8f9ff850a4fb95909999ccebcebad698a Mon Sep 17 00:00:00 2001 From: stormc Date: Tue, 6 Jun 2017 08:26:01 +0200 Subject: [PATCH] DDI API: Extend API response to report previous update execution status. (#506) * DDI API: Limit number of feedback messages for an action status. Even though the number of ActionStatus entries are limited to 1000 per action using QuotaManagement, there is no limit on the number of messages that can be sent as part of a single ActionStatus. This allows a controller to potentially send large number of messages for a single action. Limiting the number of allowed messages to 50 using the javax.validation.constraints.Size within DdiStatus. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS * DDI API: Extend API response to retrieve controller feedback. (#381) Resolves #381 The mechanism is useful for example, when the client software running on the device loses this information prior to reporting a final execution status such as 'closed' to hawkBit. This may happen, e.g., due to a power cycle or simply a crash. Upon the client software restarting, it installs the same payload again as advertised by hawkBit (as the device has not sent a final update execution status). Instead, if the last feedback sent to hawkBit would be reported back to the device, the client may resume installation. Feedback messages sent as part of POST /{tenant}/controller/v1/{targetid}/deploymentBase/{actionId}/feedback, are sent back to controller as part of response to GET /{tenant}/controller/v1/{targetid}/deploymentBase/{actionId}. Following example illustrates the API changes: 1. After retrieving the action from server, controller starts download and sends a feedback. curl 'http://127.0.0.1:8080/default/controller/v1/1/deploymentBase/1/feedback' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/hal+json' -d '{ "id" : "1", "time" : "20170406T121500", "status" : { "result" : { "progress" : { "of" : 1, "cnt" : 0 }, "finished" : "none" }, "execution" : "proceeding", "details" : [ "proceeding with download" ] } }' 2. Once artifact download has finished, controller sends another feedback. curl 'http://127.0.0.1:8080/default/controller/v1/1/deploymentBase/1/feedback' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/hal+json' -d '{ { "id" : "1", "time" : "20170406T123000", "status" : { "result" : { "progress" : { "of" : 1, "cnt" : 0 }, "finished" : "none" }, "execution" : "proceeding", "details" : [ "downloaded artifacts for update" ] } }' 3. If there is a power outage now, the controller can retrieve the messages posted earlier from the action history when it restarts again. curl 'http://127.0.0.1:8080/default/controller/v1/1/deploymentBase/1?c=411599879&actionHistory=-1' -i -H 'Accept: application/hal+json' Response will be like below { "id": "1", "deployment": { "download": "forced", "update": "forced", "chunks": [ { "part": "os", "version": "1", "name": "1", "artifacts": [....], } ], }, "actionHistory": { "status": "RETRIEVED", "messages": [ "downloaded artifacts for update", "proceeding with download" ] } } 4. Based on the feedback messages, controller may be able to skip the download and resume with installation and send additional feedback. curl 'http://127.0.0.1:8080/default/controller/v1/1/deploymentBase/1/feedback' -i -X POST -H 'Content-Type: application/json;charset=UTF-8' -H 'Accept: application/hal+json' -d '{ "id" : "1", "time" : "20170406T121314", "status" : { "result" : { "progress" : { "of" : 1, "cnt" : 0 }, "finished" : "none" }, "execution" : "resumed", "details" : [ "resuming installation based on previous feedback, download skipped" ] } }' Note: The maximum number of messages to be retrieved from the database by a controller is limited to 100. The actionHistory parameter's value has the following meaning: Input Value | Output ----------------------------- <0 | Retrieve maximum allowed number of messages from | action history. unspecified or =0 | Do not retrieve any message (default). >0 | Retrieve specified number of messages, limited by | maximum allowed number. Signed-off-by: Christian Storm Signed-off-by: Himanshu Kumar Singh Signed-off-by: Raju HS --- .../documentation/interfaces/ddi-api.md | 9 +- .../ddi/json/model/DdiActionHistory.java | 55 +++++++ .../ddi/json/model/DdiDeploymentBase.java | 34 ++++- .../hawkbit/ddi/json/model/DdiStatus.java | 2 + .../ddi/rest/api/DdiRestConstants.java | 7 + .../rest/api/DdiRootControllerRestApi.java | 14 +- .../rest/resource/DataConversionHelper.java | 2 +- .../ddi/rest/resource/DdiRootController.java | 14 +- .../rest/resource/DdiRootControllerTest.java | 143 ++++++++++++++++++ .../repository/ControllerManagement.java | 27 ++++ .../repository/RepositoryConstants.java | 6 + .../jpa/ActionStatusRepository.java | 19 +++ .../jpa/JpaControllerManagement.java | 23 +++ .../jpa/ControllerManagementTest.java | 22 +++ 14 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java diff --git a/docs/src/main/resources/documentation/interfaces/ddi-api.md b/docs/src/main/resources/documentation/interfaces/ddi-api.md index 132a60cdf..918651959 100644 --- a/docs/src/main/resources/documentation/interfaces/ddi-api.md +++ b/docs/src/main/resources/documentation/interfaces/ddi-api.md @@ -97,7 +97,14 @@ _Example Response_ } ] }, - "id": "1" + "id": "1", + "actionHistory": { + "status": "RETRIEVED", + "messages": [ + "Installing update", + "Downloading artifacts" + ] + } } ``` diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java new file mode 100644 index 000000000..8bc8ce6d6 --- /dev/null +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiActionHistory.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) Siemens AG, 2017 + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ddi.json.model; + +import java.util.List; + +import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +/** + * Provide action history information to the controller as part of response to + * {@link DdiRootControllerRestApi#getControllerBasedeploymentAction}: 1. + * Current action status at the server; 2. List of messages from action history + * that were sent to server earlier by the controller using + * {@link DdiActionFeedback}. + */ + +@JsonPropertyOrder({ "status", "messages" }) +public class DdiActionHistory { + + @JsonProperty("status") + private final String actionStatus; + + @JsonProperty("messages") + private final List messages; + + /** + * Parameterized constructor for creating {@link DdiActionHistory}. + * + * @param actionStatus + * is the current action status at the server + * @param messages + * is a list of messages retrieved from action history. + */ + @JsonCreator + public DdiActionHistory(@JsonProperty("status") final String actionStatus, + @JsonProperty("messages") List messages) { + this.actionStatus = actionStatus; + this.messages = messages; + } + + @Override + public String toString() { + return "Action history [" + "status=" + actionStatus + ", messages={" + messages.toString() + "}]"; + } +} diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeploymentBase.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeploymentBase.java index a8f5ee72b..c5a1630b4 100644 --- a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeploymentBase.java +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/json/model/DdiDeploymentBase.java @@ -13,42 +13,70 @@ import javax.validation.constraints.NotNull; import org.springframework.hateoas.ResourceSupport; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; /** * Update action resource. */ +@JsonPropertyOrder({ "id", "deployment", "actionHistory" }) public class DdiDeploymentBase extends ResourceSupport { @JsonProperty("id") @NotNull private final String deplyomentId; + @JsonProperty("deployment") @NotNull private final DdiDeployment deployment; + /** + * Action history containing current action status and a list of feedback + * messages received earlier from the controller. + */ + @JsonProperty("actionHistory") + @JsonInclude(JsonInclude.Include.NON_NULL) + private final DdiActionHistory actionHistory; + /** * Constructor. * * @param id * of the update action * @param deployment - * details. + * details + * @param actionHistory + * containing current action status and a list of feedback + * messages received earlier from the controller. */ @JsonCreator public DdiDeploymentBase(@JsonProperty("id") final String id, - @JsonProperty("deplyomentId") final DdiDeployment deployment) { + @JsonProperty("deplyomentId") final DdiDeployment deployment, + @JsonProperty("actionHistory") final DdiActionHistory actionHistory) { this.deplyomentId = id; this.deployment = deployment; + this.actionHistory = actionHistory; } public DdiDeployment getDeployment() { return deployment; } + /** + * Returns the action history containing current action status and a list of + * feedback messages received earlier from the controller. + * + * @return {@link DdiActionHistory} + */ + public DdiActionHistory getActionHistory() { + return actionHistory; + } + @Override public String toString() { - return "DeploymentBase [id=" + deplyomentId + ", deployment=" + deployment + "]"; + return "DeploymentBase [id=" + deplyomentId + ", deployment=" + deployment + " actionHistory=" + + actionHistory.toString() + "]"; } } 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..64d360b9d 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 @@ -13,6 +13,7 @@ import java.util.List; import javax.validation.Valid; import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -31,6 +32,7 @@ public class DdiStatus { @Valid private final DdiResult result; + @Size(min = 0, max = 50) private final List details; /** diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java index 25be5b1f8..bacb70ee4 100644 --- a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRestConstants.java @@ -43,6 +43,13 @@ public final class DdiRestConstants { */ public static final String CONFIG_DATA_ACTION = "configData"; + /** + * Default value specifying that no action history to be sent as part of + * response to deploymentBase + * {@link DdiRootControllerRestApi#getControllerBasedeploymentAction}. + */ + public static final String NO_ACTION_HISTORY = "0"; + private DdiRestConstants() { // constant class, private constructor. } diff --git a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java index ef434914d..90607a426 100644 --- a/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java +++ b/hawkbit-ddi-api/src/main/java/org/eclipse/hawkbit/ddi/rest/api/DdiRootControllerRestApi.java @@ -136,6 +136,17 @@ public interface DdiRootControllerRestApi { * an hashcode of the resource which indicates if the action has * been changed, e.g. from 'soft' to 'force' and the eTag needs * to be re-generated + * @param actionHistoryMessageCount + * specifies the number of messages to be returned from action + * history. Regardless of the passed value, in order to restrict + * resource utilization by controllers, maximum number of + * messages that are retrieved from database is limited by + * {@link RepositoryConstants#MAX_ACTION_HISTORY_MSG_COUNT}. + * actionHistoryMessageCount < 0, retrieves the maximum allowed + * number of action status messages from history; + * actionHistoryMessageCount = 0, does not retrieve any message; + * and actionHistoryMessageCount > 0, retrieves the specified + * number of messages, limited by maximum allowed number. * @param request * the HTTP request injected by spring * @return the response @@ -146,7 +157,8 @@ public interface DdiRootControllerRestApi { ResponseEntity getControllerBasedeploymentAction(@PathVariable("tenant") final String tenant, @PathVariable("controllerId") @NotEmpty final String controllerId, @PathVariable("actionId") @NotEmpty final Long actionId, - @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource); + @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource, + @RequestParam(value = "actionHistory", defaultValue = DdiRestConstants.NO_ACTION_HISTORY) final Integer actionHistoryMessageCount); /** * This is the feedback channel for the {@link DdiDeploymentBase} action. 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 4b5f664c3..a04ba535a 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 @@ -118,7 +118,7 @@ public final class DataConversionHelper { result.add(ControllerLinkBuilder .linkTo(ControllerLinkBuilder.methodOn(DdiRootController.class, tenantAware.getCurrentTenant()) .getControllerBasedeploymentAction(tenantAware.getCurrentTenant(), - target.getControllerId(), action.getId(), calculateEtag(action))) + target.getControllerId(), action.getId(), calculateEtag(action), null)) .withRel(DdiRestConstants.DEPLOYMENT_BASE_ACTION)); } } 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 63a2896a6..4ff0eae9a 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 @@ -19,6 +19,7 @@ import javax.validation.Valid; import org.eclipse.hawkbit.api.ArtifactUrlHandler; import org.eclipse.hawkbit.artifact.repository.model.DbArtifact; import org.eclipse.hawkbit.ddi.json.model.DdiActionFeedback; +import org.eclipse.hawkbit.ddi.json.model.DdiActionHistory; import org.eclipse.hawkbit.ddi.json.model.DdiCancel; import org.eclipse.hawkbit.ddi.json.model.DdiCancelActionToStop; import org.eclipse.hawkbit.ddi.json.model.DdiChunk; @@ -28,6 +29,7 @@ import org.eclipse.hawkbit.ddi.json.model.DdiDeployment; import org.eclipse.hawkbit.ddi.json.model.DdiDeployment.HandlingType; 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; import org.eclipse.hawkbit.ddi.rest.api.DdiRootControllerRestApi; import org.eclipse.hawkbit.repository.ArtifactManagement; import org.eclipse.hawkbit.repository.ControllerManagement; @@ -231,7 +233,8 @@ public class DdiRootController implements DdiRootControllerRestApi { public ResponseEntity getControllerBasedeploymentAction( @PathVariable("tenant") final String tenant, @PathVariable("controllerId") final String controllerId, @PathVariable("actionId") final Long actionId, - @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource) { + @RequestParam(value = "c", required = false, defaultValue = "-1") final int resource, + @RequestParam(value = "actionHistory", defaultValue = DdiRestConstants.NO_ACTION_HISTORY) final Integer actionHistoryMessageCount) { LOG.debug("getControllerBasedeploymentAction({},{})", controllerId, resource); final Target target = controllerManagement.findByControllerId(controllerId) @@ -251,8 +254,15 @@ public class DdiRootController implements DdiRootControllerRestApi { final HandlingType handlingType = action.isForce() ? HandlingType.FORCED : HandlingType.ATTEMPT; + List actionHistoryMsgs = controllerManagement.getActionHistoryMessages(action.getId(), + actionHistoryMessageCount == null ? Integer.parseInt(DdiRestConstants.NO_ACTION_HISTORY) + : actionHistoryMessageCount); + + DdiActionHistory actionHistory = actionHistoryMsgs.isEmpty() ? null + : new DdiActionHistory(action.getStatus().name(), actionHistoryMsgs); + final DdiDeploymentBase base = new DdiDeploymentBase(Long.toString(action.getId()), - new DdiDeployment(handlingType, handlingType, chunks)); + new DdiDeployment(handlingType, handlingType, chunks), actionHistory); LOG.debug("Found an active UpdateAction for target {}. returning deyploment: {}", controllerId, base); 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 0e742e380..10fb35e3a 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 @@ -14,6 +14,8 @@ import static org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpre import static org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpressions.HAS_AUTH_TENANT_CONFIGURATION; import static org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpressions.SYSTEM_ROLE; import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; 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; @@ -60,6 +62,9 @@ import ru.yandex.qatools.allure.annotations.Stories; @Stories("Root Poll Resource") public class DdiRootControllerTest extends AbstractDDiApiIntegrationTest { + private static final String TARGET_COMPLETED_INSTALLATION_MSG = "Target completed installation."; + private static final String TARGET_PROCEEDING_INSTALLATION_MSG = "Target proceeding installation."; + private static final String TARGET_SCHEDULED_INSTALLATION_MSG = "Target scheduled installation."; @Autowired private HawkbitSecurityProperties securityProperties; @@ -352,4 +357,142 @@ public class DdiRootControllerTest extends AbstractDDiApiIntegrationTest { .andDo(MockMvcResultPrinter.print()).andExpect(status().isGone()); } + @Test + @Description("Test to verify that only a specific count of messages are returned based on the input actionHistory for getControllerDeploymentActionFeedback endpoint.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3) }) + public void testActionHistoryCount() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("911"); + savedTarget = assignDistributionSet(ds.getId(), savedTarget.getControllerId()).getAssignedEntity().iterator() + .next(); + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "scheduled", + TARGET_SCHEDULED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "proceeding", + TARGET_PROCEEDING_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "closed", + "success", TARGET_COMPLETED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(get("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "?actionHistory=3", + tenantAware.getCurrentTenant()).contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(TARGET_PROCEEDING_INSTALLATION_MSG)))) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(TARGET_SCHEDULED_INSTALLATION_MSG)))); + } + + @Test + @Description("Test to verify that a zero input value of actionHistory results in no action history appended for getControllerDeploymentActionFeedback endpoint.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3) }) + public void testActionHistoryZeroInput() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("911"); + savedTarget = assignDistributionSet(ds.getId(), savedTarget.getControllerId()).getAssignedEntity().iterator() + .next(); + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "scheduled", + TARGET_SCHEDULED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "proceeding", + TARGET_PROCEEDING_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "closed", + "success", TARGET_COMPLETED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(get("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "?actionHistory=-2", + tenantAware.getCurrentTenant()).contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + } + + @Test + @Description("Test to verify that entire action history is returned if the input value for actionHistory is -1, for getControllerDeploymentActionFeedback endpoint.") + @ExpectEvents({ @Expect(type = TargetCreatedEvent.class, count = 1), + @Expect(type = DistributionSetCreatedEvent.class, count = 1), + @Expect(type = TargetAssignDistributionSetEvent.class, count = 1), + @Expect(type = ActionCreatedEvent.class, count = 1), @Expect(type = ActionUpdatedEvent.class, count = 2), + @Expect(type = TargetUpdatedEvent.class, count = 2), + @Expect(type = SoftwareModuleCreatedEvent.class, count = 3) }) + public void testActionHistoryNegativeInput() throws Exception { + final DistributionSet ds = testdataFactory.createDistributionSet(""); + Target savedTarget = testdataFactory.createTarget("911"); + savedTarget = assignDistributionSet(ds.getId(), savedTarget.getControllerId()).getAssignedEntity().iterator() + .next(); + final Action savedAction = deploymentManagement.findActiveActionsByTarget(PAGE, savedTarget.getControllerId()) + .getContent().get(0); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "scheduled", + TARGET_SCHEDULED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "proceeding", + TARGET_PROCEEDING_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(post("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "/feedback", + tenantAware.getCurrentTenant()) + .content(JsonBuilder.deploymentActionFeedback(savedAction.getId().toString(), "closed", + "success", TARGET_COMPLETED_INSTALLATION_MSG)) + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + + mvc.perform(get("/{tenant}/controller/v1/911/deploymentBase/" + savedAction.getId() + "?actionHistory=-1", + tenantAware.getCurrentTenant()).contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(TARGET_SCHEDULED_INSTALLATION_MSG)))) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(TARGET_PROCEEDING_INSTALLATION_MSG)))) + .andExpect(jsonPath("$.actionHistory.messages", + hasItem(containsString(TARGET_COMPLETED_INSTALLATION_MSG)))); + } } 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 3211eee8a..7c29b4e19 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 @@ -9,6 +9,7 @@ package org.eclipse.hawkbit.repository; import java.net.URI; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -317,4 +318,30 @@ public interface ControllerManagement { + SpringEvalExpressions.IS_SYSTEM_CODE) Optional findByTargetId(@NotNull Long targetId); + /** + * Retrieves the specified number of messages from action history of the + * given {@link Action} based on messageCount. Regardless of the value of + * messageCount, in order to restrict resource utilization by controllers, + * maximum number of messages that are retrieved from database is limited by + * {@link RepositoryConstants#MAX_ACTION_HISTORY_MSG_COUNT}. messageCount < + * 0, retrieves the maximum allowed number of action status messages from + * history; messageCount = 0, does not retrieve any message; and + * messageCount > 0, retrieves the specified number of messages, limited by + * maximum allowed number. A controller sends the feedback for an + * {@link ActionStatus} as a list of messages; while returning the messages, + * even though the messages from multiple {@link ActionStatus} are retrieved + * in descending order by the reported time + * ({@link ActionStatus#getOccurredAt()}), i.e. latest ActionStatus first, + * the sub-ordering of messages from within single {@link ActionStatus} is + * unspecified. + * + * @param actionId + * to be filtered on + * @param messageCount + * is the number of messages to return from history + * + * @return action history. + */ + @PreAuthorize(SpringEvalExpressions.IS_CONTROLLER) + List getActionHistoryMessages(@NotNull Long actionId, final int messageCount); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryConstants.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryConstants.java index f2cd3f9f6..6a099cd48 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryConstants.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryConstants.java @@ -30,6 +30,12 @@ public final class RepositoryConstants { */ public static final int DEFAULT_DS_TYPES_IN_TENANT = 2; + /** + * Maximum number of messages that can be retrieved by a controller for an + * {@link Action}. + */ + public static final int MAX_ACTION_HISTORY_MSG_COUNT = 100; + private RepositoryConstants() { // Utility class. } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionStatusRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionStatusRepository.java index 8503f7ed0..5fda8b9a3 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionStatusRepository.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/ActionStatusRepository.java @@ -17,6 +17,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** @@ -63,4 +65,21 @@ public interface ActionStatusRepository */ @EntityGraph(value = "ActionStatus.withMessages", type = EntityGraphType.LOAD) Page getByActionId(Pageable pageReq, Long actionId); + + /** + * Finds a filtered list of status messages for an action. + * + * @param pageable + * for page configuration + * @param actionId + * for which to get the status messages + * @param filter + * is the SQL like pattern to use for filtering out or excluding + * the messages + * + * @return Page with found status messages. + */ + @Query("SELECT message FROM JpaActionStatus actionstatus JOIN actionstatus.messages message WHERE actionstatus.action.id = :actionId AND message NOT LIKE :filter") + Page findMessagesByActionIdAndMessageNotLike(final Pageable pageable, @Param("actionId") Long actionId, + @Param("filter") String filter); } 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 99ffba6ca..cd5fedb5e 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,6 +9,7 @@ package org.eclipse.hawkbit.repository.jpa; import java.net.URI; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -59,6 +60,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.ConcurrencyFailureException; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -522,4 +524,25 @@ public class JpaControllerManagement implements ControllerManagement { return actionStatusRepository.findByActionId(pageReq, actionId); } + @Override + public List getActionHistoryMessages(final Long actionId, final int messageCount) { + // Just return empty list in case messageCount is zero. + if (messageCount == 0) { + return new ArrayList<>(); + } + + // For negative and large value of messageCount, limit the number of + // messages. + int limit = messageCount < 0 || messageCount >= RepositoryConstants.MAX_ACTION_HISTORY_MSG_COUNT + ? RepositoryConstants.MAX_ACTION_HISTORY_MSG_COUNT : messageCount; + + PageRequest pageable = new PageRequest(0, limit, new Sort(Direction.DESC, "occurredAt")); + Page messages = actionStatusRepository.findMessagesByActionIdAndMessageNotLike(pageable, actionId, + RepositoryConstants.SERVER_MESSAGE_PREFIX + "%"); + + LOG.debug("Retrieved {} message(s) from action history for action {}.", messages.getNumberOfElements(), + actionId); + + return messages.getContent(); + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java index 05a6b81d5..7e4abfe9d 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/ControllerManagementTest.java @@ -46,6 +46,7 @@ import org.eclipse.hawkbit.repository.test.util.WithSpringAuthorityRule; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import com.google.common.collect.Lists; import com.google.common.collect.Maps; import ru.yandex.qatools.allure.annotations.Description; @@ -620,4 +621,25 @@ public class ControllerManagementTest extends AbstractJpaIntegrationTest { assertThat(targetManagement.getControllerAttributes(controllerId)).as("Controller Attributes are wrong") .isEqualTo(testData); } + + @Test + @Description("Test to verify the storage and retrieval of action history.") + public void findMessagesByActionStatusId() { + final DistributionSet testDs = testdataFactory.createDistributionSet("1"); + final List testTarget = testdataFactory.createTargets(1); + + final Long actionId = assignDistributionSet(testDs, testTarget).getActions().get(0); + + controllerManagement.addUpdateActionStatus(entityFactory.actionStatus().create(actionId) + .status(Action.Status.RUNNING).messages(Lists.newArrayList("proceeding message 1"))); + controllerManagement.addUpdateActionStatus(entityFactory.actionStatus().create(actionId) + .status(Action.Status.RUNNING).messages(Lists.newArrayList("proceeding message 2"))); + + final List messages = controllerManagement.getActionHistoryMessages(actionId, 2); + + assertThat(deploymentManagement.findActionStatusByAction(PAGE, actionId).getTotalElements()) + .as("Two action-states in total").isEqualTo(3L); + assertThat(messages.get(0)).as("Message of action-status").isEqualTo("proceeding message 2"); + assertThat(messages.get(1)).as("Message of action-status").isEqualTo("proceeding message 1"); + } }