From 64ffc6a27a4f4340cf0209a36dfa5a919b4d91c0 Mon Sep 17 00:00:00 2001 From: Vasil Ilchev Date: Thu, 13 Feb 2025 09:10:24 +0200 Subject: [PATCH] Mgmt/actions confirm (#2271) * Extend MGMT API to be possible to confirm/deny Actions on Targets as Operators. * Added tests * Fixed permissions in api doc * added missing license header --------- Co-authored-by: vasilchev --- .../MgmtActionConfirmationRequestBodyPut.java | 90 +++++++++++++++++ .../mgmt/rest/api/MgmtTargetRestApi.java | 46 +++++++++ .../rest/resource/MgmtTargetResource.java | 54 +++++++++- .../rest/resource/MgmtTargetResourceTest.java | 98 +++++++++++++++++++ 4 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/action/MgmtActionConfirmationRequestBodyPut.java diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/action/MgmtActionConfirmationRequestBodyPut.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/action/MgmtActionConfirmationRequestBodyPut.java new file mode 100644 index 000000000..be396e4ae --- /dev/null +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/action/MgmtActionConfirmationRequestBodyPut.java @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mgmt.json.model.action; + +import java.util.Collections; +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * New update actions require confirmation when confirmation flow is switched on. + * The confirmation message has a mandatory field confirmation with possible values: "confirmed" and "denied". + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class MgmtActionConfirmationRequestBodyPut { + + @NotNull + @Valid + @Schema(description = "Action confirmation state") + private final Confirmation confirmation; + + @Schema(description = "(Optional) Individual status code", example = "200") + private final Integer code; + + @Schema(description = "List of detailed message information", example = "[ \"Feedback message\" ]") + private final List details; + + /** + * Constructs a confirmation-feedback + * + * @param confirmation confirmation value for the action. Valid values are "Confirmed" and "Denied + * @param code code for confirmation + * @param details messages + */ + @JsonCreator + public MgmtActionConfirmationRequestBodyPut( + @JsonProperty(value = "confirmation", required = true) final Confirmation confirmation, + @JsonProperty(value = "code") final Integer code, + @JsonProperty(value = "details") final List details) { + this.confirmation = confirmation; + this.code = code; + this.details = details; + } + + public List getDetails() { + if (details == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(details); + } + + public enum Confirmation { + /** + * Confirm the action. + */ + CONFIRMED("confirmed"), + + /** + * Deny the action. + */ + DENIED("denied"); + + private final String name; + + Confirmation(final String name) { + this.name = name; + } + + @JsonValue + public String getName() { + return name; + } + } +} \ No newline at end of file diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java index da1c11e5f..967008aa0 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java @@ -24,6 +24,7 @@ import org.eclipse.hawkbit.mgmt.json.model.MgmtMetadata; import org.eclipse.hawkbit.mgmt.json.model.MgmtMetadataBodyPut; 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.MgmtActionConfirmationRequestBodyPut; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionRequestBodyPut; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionStatus; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet; @@ -504,6 +505,51 @@ public interface MgmtTargetRestApi { @PathVariable("actionId") Long actionId, @RequestBody MgmtActionRequestBodyPut actionUpdate); + /** + * Handles the PUT update request to either 'confirm' or 'deny' single action on a target. + + */ + @Operation(summary = "Controls (confirm/deny) actions waiting for confirmation", description = """ + Either confirm or deny an action which is waiting for confirmation. + The action will be transferred into the RUNNING state in case confirming it. + The action will remain in WAITING_FOR_CONFIRMATION state in case denying it. + Required Permission: READ_REPOSITORY AND UPDATE_TARGET + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully updated confirmation status of the action"), + @ApiResponse(responseCode = "400", description = "Bad Request - e.g. invalid parameters", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ExceptionInfo.class))), + @ApiResponse(responseCode = "401", description = "The request requires user authentication.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", description = "Target or Action not found", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " + + "user in another request at the same time. You may retry your modification request.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "410", description = "Action is not active anymore.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " + + "supported by the server for this resource.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.", + content = @Content(mediaType = "application/json", schema = @Schema(hidden = true))) + }) + @PutMapping(value = MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions/{actionId}/confirmation", + consumes = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }, + produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + ResponseEntity updateActionConfirmation( + @PathVariable("targetId") String targetId, + @PathVariable("actionId") Long actionId, + @Valid @RequestBody MgmtActionConfirmationRequestBodyPut actionConfirmation); + /** * Handles the GET request of retrieving the ActionStatus of a specific target and action. * diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java index 6f5828fc7..59eea21f3 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java @@ -15,6 +15,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.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -27,6 +28,7 @@ import org.eclipse.hawkbit.mgmt.json.model.MgmtMetadata; import org.eclipse.hawkbit.mgmt.json.model.MgmtMetadataBodyPut; 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.MgmtActionConfirmationRequestBodyPut; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionRequestBodyPut; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionStatus; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; @@ -48,6 +50,7 @@ import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidConfirmationFeedbackException; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.ActionStatus; import org.eclipse.hawkbit.repository.model.DeploymentRequest; @@ -226,14 +229,19 @@ public class MgmtTargetResource implements MgmtTargetRestApi { @Override public ResponseEntity getAction(final String targetId, final Long actionId) { + return getValidatedAction(targetId, actionId) + .map(action -> ResponseEntity.ok(MgmtTargetMapper.toResponseWithLinks(targetId, action))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + private Optional getValidatedAction(final String targetId, final Long actionId) { final Action action = deploymentManagement.findAction(actionId) .orElseThrow(() -> new EntityNotFoundException(Action.class, actionId)); if (!action.getTarget().getControllerId().equals(targetId)) { log.warn(ACTION_TARGET_MISSING_ASSIGN_WARN, action.getId(), targetId); - return ResponseEntity.notFound().build(); + return Optional.empty(); } - - return ResponseEntity.ok(MgmtTargetMapper.toResponseWithLinks(targetId, action)); + return Optional.of(action); } @Override @@ -276,6 +284,46 @@ public class MgmtTargetResource implements MgmtTargetRestApi { return ResponseEntity.ok(MgmtTargetMapper.toResponseWithLinks(targetId, action)); } + @Override + public ResponseEntity updateActionConfirmation(final String targetId, final Long actionId, + final MgmtActionConfirmationRequestBodyPut actionConfirmation) { + log.debug("updateActionConfirmation with data [targetId={}, actionId={}]: {}", targetId, actionId, actionConfirmation); + + return getValidatedAction(targetId, actionId).map(action -> { + try { + switch (actionConfirmation.getConfirmation()) { + case CONFIRMED: + log.info("Confirmed the action (actionId: {}, targetId: {}) as we got {} report", + actionId, targetId, actionConfirmation.getConfirmation()); + confirmationManagement.confirmAction(actionId, actionConfirmation.getCode(), actionConfirmation.getDetails()); + break; + case DENIED: + default: + log.debug("Controller denied the action (actionId: {}, controllerId: {}) as we got {} report.", + actionId, targetId, actionConfirmation.getConfirmation()); + confirmationManagement.denyAction(actionId, actionConfirmation.getCode(), actionConfirmation.getDetails()); + break; + } + return new ResponseEntity(HttpStatus.OK); + } catch (final InvalidConfirmationFeedbackException e) { + if (e.getReason() == InvalidConfirmationFeedbackException.Reason.ACTION_CLOSED) { + log.warn("Updating action {} with confirmation {} not possible since action not active anymore.", + action.getId(), actionConfirmation.getConfirmation(), e); + return new ResponseEntity(HttpStatus.GONE); + } else if (e.getReason() == InvalidConfirmationFeedbackException.Reason.NOT_AWAITING_CONFIRMATION) { + log.debug("Action is not waiting for confirmation, deny request.", e); + return new ResponseEntity(HttpStatus.NOT_FOUND); + } else { + log.debug("Action confirmation failed with unknown reason.", e); + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + } + }).orElseGet(() -> { + log.warn("Action {} not found for target {}", actionId, targetId); + return ResponseEntity.notFound().build(); + }); + } + @Override public ResponseEntity> getActionStatusList( final String targetId, final Long actionId, diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java index d479bcfb2..4f463044d 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java @@ -55,6 +55,7 @@ import io.qameta.allure.Story; import org.awaitility.Awaitility; import org.eclipse.hawkbit.exception.SpServerError; import org.eclipse.hawkbit.im.authentication.SpPermission; +import org.eclipse.hawkbit.mgmt.json.model.action.MgmtActionConfirmationRequestBodyPut; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetAutoConfirmUpdate; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; @@ -63,6 +64,8 @@ import org.eclipse.hawkbit.repository.ActionFields; import org.eclipse.hawkbit.repository.Identifiable; import org.eclipse.hawkbit.repository.builder.ActionStatusCreate; import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException; +import org.eclipse.hawkbit.repository.jpa.model.JpaAction; +import org.eclipse.hawkbit.repository.jpa.model.JpaActionStatus; import org.eclipse.hawkbit.repository.jpa.model.JpaTarget; import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository; import org.eclipse.hawkbit.repository.jpa.specifications.ActionSpecifications; @@ -280,6 +283,101 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest { .andExpect(status().isOk()); } + @Test + @Description("Test confirmation of single Action with confirm status. Check that Action goes into Running status with appropriate messages and status code") + public void updateActionConfirmationWithConfirm() throws Exception { + final int expectedStatusCode = 210; + final String expectedStatusMessage1 = "some-custom-message1"; + final String expectedStatusMessage2 = "some-custom-message2"; + final Status expectedStatusAfterActionConfirmationCall = Status.RUNNING; + final long actionId = doAssignmentAndTestConfirmation("targetId"); + testActionConfirmation("targetId", actionId, MgmtActionConfirmationRequestBodyPut.Confirmation.CONFIRMED, expectedStatusCode, new String[]{expectedStatusMessage1, expectedStatusMessage2}, HttpStatus.OK ,expectedStatusAfterActionConfirmationCall); + } + + @Test + @Description("Test confirmation of single Action with deny status. Check that Action stays in WAIT_FOR_CONFIRMATION status with appropriate messages and status code") + public void updateActionConfirmationWithDeny() throws Exception { + final int expectedStatusCode = 410; + final String expectedStatusMessage1 = "some-error-custom-message1"; + final String expectedStatusMessage2 = "some-error-custom-message2"; + final Status expectedStatusAfterActionConfirmationCall = Status.WAIT_FOR_CONFIRMATION; + final long actionId = doAssignmentAndTestConfirmation("targetId"); + testActionConfirmation("targetId", actionId, MgmtActionConfirmationRequestBodyPut.Confirmation.DENIED, expectedStatusCode, new String[]{expectedStatusMessage1, expectedStatusMessage2}, HttpStatus.OK, expectedStatusAfterActionConfirmationCall); + } + + @Test + @Description("Test confirmation of single Action with wrong ControllerId - e.g. the given Action is not assigned to the given Target - confirmation call must fail.") + public void updateActionConfirmationFailsIfActionNotAssignedToTarget() throws Exception { + final int payloadCallCode = 200; + final String payloadCallMessage1 = "random1"; + final String payloadCallMessage2 = "random2"; + final long controller1Action = doAssignmentAndTestConfirmation("controller1"); + final long controller2Action = doAssignmentAndTestConfirmation("controller2"); + // test that target id and action id are checked correctly and only actions assigned to given targets are confirmed/denied + // if action is not assigned to the target, confirmation call must fail + testActionConfirmation("controller1", controller2Action, MgmtActionConfirmationRequestBodyPut.Confirmation.CONFIRMED, + payloadCallCode, new String[]{payloadCallMessage1, payloadCallMessage2}, HttpStatus.NOT_FOUND, + Status.WAIT_FOR_CONFIRMATION); + testActionConfirmation("controller2", controller1Action, MgmtActionConfirmationRequestBodyPut.Confirmation.CONFIRMED, + payloadCallCode, new String[]{payloadCallMessage1, payloadCallMessage2}, HttpStatus.NOT_FOUND, + Status.WAIT_FOR_CONFIRMATION); + } + + long doAssignmentAndTestConfirmation(final String controllerId) { + enableConfirmationFlow(); + + final Target testTarget = testdataFactory.createTarget(controllerId); + final DistributionSet dsA = testdataFactory.createDistributionSet(controllerId); + assignDistributionSet(dsA, Collections.singletonList(testTarget)); + + // check initial status after assignment is done with Confirmation Flow enabled + // expected Actions to be in WAIT_FOR_CONFIRMATION status + List actionHistory = deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent(); + assertThat(actionHistory).hasSize(1); + Action action = actionHistory.get(0); + assertThat(action.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + return action.getId(); + } + + void testActionConfirmation(final String controllerId, final long actionId, final MgmtActionConfirmationRequestBodyPut.Confirmation payloadConfirmation, final int payloadCode, final String[] payloadMessages, final HttpStatus expectedHttpResponseStatus,final Status expectedGeneratedStatus) throws Exception { + String url = MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/" + controllerId + "/" + MgmtRestConstants.TARGET_V1_ACTIONS + "/" + actionId + "/confirmation"; + mvc.perform(put(url) + .content(String.format("{\"confirmation\":\"%s\",\"details\":[\"%s\",\"%s\"],\"code\":%d}", + payloadConfirmation.getName(), + payloadMessages[0], + payloadMessages[1], + payloadCode + )) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()) + .andExpect(status().is(expectedHttpResponseStatus.value())); + + + // check status after confirmation is done (either confirmed or denied) + final List actionHistory = deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent(); + assertThat(actionHistory).hasSize(1); + final JpaAction jpaAction = (JpaAction) actionHistory.get(0); + final List actionStatuses = new ArrayList<>(jpaAction.getActionStatus()); + + // confirmation call was successful, check if Action status ,status code and messages are updated appropriately + if (expectedHttpResponseStatus == HttpStatus.OK) { + assertThat(jpaAction.getStatus()).isEqualTo(expectedGeneratedStatus); + assertThat(jpaAction.getLastActionStatusCode().get()).isEqualTo(payloadCode); + + actionStatuses.sort(Comparator.comparingLong(Identifiable::getId)); + assertThat(actionStatuses.size()).isEqualTo(2); + assertThat((actionStatuses.get(0)).getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + assertThat((actionStatuses.get(0)).getCode().isEmpty()).isTrue(); + assertThat((actionStatuses.get(1)).getStatus()).isEqualTo(expectedGeneratedStatus); + assertThat((actionStatuses.get(1)).getCode().get()).isEqualTo(payloadCode); + assertThat(((JpaActionStatus) actionStatuses.get(1)).getMessages()).contains(payloadMessages[0], payloadMessages[1]); + } else { // confirmation call not successful, check if Action status is not updated, no new Action status added as well. + assertThat(jpaAction.getStatus()).isEqualTo(Status.WAIT_FOR_CONFIRMATION); + assertThat(jpaAction.getLastActionStatusCode().isEmpty()).isTrue(); + assertThat(jpaAction.getActionStatus()).hasSize(1); + } + } + @Test @Description("Ensures that actions list is in expected order.") void getActionStatusReturnsCorrectType() throws Exception {