Skip to content

Commit

Permalink
Mgmt/actions confirm (eclipse-hawkbit#2271)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
vasilchev and vasilchev authored Feb 13, 2025
1 parent 91bf706 commit 64ffc6a
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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;
Expand Down Expand Up @@ -504,6 +505,51 @@ ResponseEntity<MgmtAction> updateAction(
@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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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;
Expand All @@ -27,6 +28,7 @@
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;
Expand All @@ -48,6 +50,7 @@
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;
Expand Down Expand Up @@ -226,14 +229,19 @@ public ResponseEntity<PagedList<MgmtAction>> getActionHistory(

@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
Expand Down Expand Up @@ -276,6 +284,46 @@ public ResponseEntity<MgmtAction> updateAction(final String targetId, final Long
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,
Expand Down
Loading

0 comments on commit 64ffc6a

Please sign in to comment.