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 <vasil.ilchev@bosch.com>
This commit is contained in:
@@ -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<String> 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<String> details) {
|
||||
this.confirmation = confirmation;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Void> 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.
|
||||
*
|
||||
|
||||
@@ -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<MgmtAction> 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<Action> 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<Void> 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<Void>(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<Void>(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<Void>(HttpStatus.NOT_FOUND);
|
||||
} else {
|
||||
log.debug("Action confirmation failed with unknown reason.", e);
|
||||
return new ResponseEntity<Void>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}).orElseGet(() -> {
|
||||
log.warn("Action {} not found for target {}", actionId, targetId);
|
||||
return ResponseEntity.notFound().build();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<PagedList<MgmtActionStatus>> getActionStatusList(
|
||||
final String targetId, final Long actionId,
|
||||
|
||||
@@ -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<Action> 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<Action> actionHistory = deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent();
|
||||
assertThat(actionHistory).hasSize(1);
|
||||
final JpaAction jpaAction = (JpaAction) actionHistory.get(0);
|
||||
final List<ActionStatus> 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 {
|
||||
|
||||
Reference in New Issue
Block a user