Action history cleanup/purge initial (#2728)
* Action history cleanup/purge initial Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * apply changes after review Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * fix hibernate build by annotating delete methods with transactional annotation Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * changes after review and new test cases for new requirements * accept 0 for keep last Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * Fix ManagementSecurityTest Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * apply object utils check Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * fix for oldestAction deletion Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * remove unused comment Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * rename action ids variable Signed-off-by: strailov <Stanislav.Trailov@bosch.io> * Fix access control handling Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com> --------- Signed-off-by: strailov <Stanislav.Trailov@bosch.io> Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com> Co-authored-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
committed by
GitHub
parent
9984c89183
commit
f1c3d0175e
@@ -26,10 +26,14 @@ import org.eclipse.hawkbit.rest.json.model.ExceptionInfo;
|
||||
import org.springframework.hateoas.MediaTypes;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* REST API providing (read-only) access to actions.
|
||||
*/
|
||||
@@ -125,4 +129,47 @@ public interface MgmtActionRestApi {
|
||||
@GetMapping(value = MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/{actionId}",
|
||||
produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
|
||||
ResponseEntity<MgmtAction> getAction(@PathVariable("actionId") Long actionId);
|
||||
|
||||
/**
|
||||
* Handles the DELETE request for single action
|
||||
*
|
||||
* @param actionId - the id of action about to be deleted
|
||||
* @return status OK if delete as successful.
|
||||
*/
|
||||
@Operation(summary = "Delete a single action by id", description = "Handles the DELETE request for single action within Bosch IoT Rollouts. Required Permission: DELETE_REPOSITORY")
|
||||
@ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Successfully retrieved"),
|
||||
@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 = "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 = "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))) })
|
||||
@DeleteMapping(value = MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/{actionId}")
|
||||
ResponseEntity<Void> deleteAction(@PathVariable("actionId") Long actionId);
|
||||
|
||||
/**
|
||||
* Handles the DELETE request for multiple actions
|
||||
*
|
||||
* @param actionIds - list of action ids to be deleted
|
||||
* @param rsqlParam - rsql filter matching actions to be deleted
|
||||
* @return status OK if delete as successful.
|
||||
*/
|
||||
@Operation(summary = "Delete multiple actions by list OR rsql filter", description = "Handles the DELETE request for multiple actions within Bosch IoT Rollouts. Either action id list OR rsql filter SHOULD be provided. Required Permission: DELETE_REPOSITORY")
|
||||
@ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Successfully retrieved"),
|
||||
@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 = "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 = "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))) })
|
||||
@DeleteMapping(value = MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)
|
||||
ResponseEntity<Void> deleteActions(
|
||||
@RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH, required = false, defaultValue = "")
|
||||
@Schema(description = """
|
||||
Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for
|
||||
available fields.""")
|
||||
String rsqlParam,
|
||||
@Schema(description = "List of action ids to be deleted", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "[253, 255]")
|
||||
@RequestBody(required = false) List<Long> actionIds);
|
||||
}
|
||||
@@ -403,6 +403,44 @@ public interface MgmtTargetRestApi {
|
||||
in the result.""")
|
||||
String sortParam);
|
||||
|
||||
|
||||
/**
|
||||
* Deletes all actions for the provided target by provided action IDs list
|
||||
* OR
|
||||
* Deletes all EXCEPT the latest N actions
|
||||
*
|
||||
* @param targetId - target id
|
||||
* @param keepLast - the number of last target actions to be left
|
||||
* @param actionIds - Specific action id list for actions to be deleted
|
||||
*/
|
||||
@Operation(summary = "Deletes all actions for the provided target EXCEPT the latest N actions OR by provided action IDs list.", description = "Deletes/Purges the action history of the target except the last N actions OR deletes only the actions in the provided action ids list. Required Permission: DELETE_REPOSITORY")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "Successfully deleted."),
|
||||
@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 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 = "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)))
|
||||
})
|
||||
@DeleteMapping(value = MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions",
|
||||
produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
|
||||
ResponseEntity<Void> deleteActionsForTarget(
|
||||
@PathVariable("targetId") String targetId,
|
||||
@RequestParam(name = "keepLast", required = false, defaultValue = "-1") int keepLast,
|
||||
@Schema(description = "List of action ids to be deleted", example = "[253, 255]", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
@RequestBody(required = false) List<Long> actionIds);
|
||||
|
||||
/**
|
||||
* Handles the GET request of retrieving a specific Actions of a specific Target.
|
||||
*
|
||||
|
||||
@@ -12,6 +12,7 @@ package org.eclipse.hawkbit.mgmt.rest.resource;
|
||||
import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitizeActionSortParam;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.hawkbit.audit.AuditLog;
|
||||
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
|
||||
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction;
|
||||
import org.eclipse.hawkbit.mgmt.rest.api.MgmtActionRestApi;
|
||||
@@ -26,8 +27,11 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Slice;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "hawkbit.rest.MgmtActionResource.enabled", matchIfMissing = true)
|
||||
@@ -71,6 +75,34 @@ public class MgmtActionResource implements MgmtActionRestApi {
|
||||
return ResponseEntity.ok(MgmtActionMapper.toResponse(action, MgmtRepresentationMode.FULL));
|
||||
}
|
||||
|
||||
@Override
|
||||
@AuditLog(entity = "Actions",type = AuditLog.Type.DELETE, description = "Delete Action", logResponse = true)
|
||||
public ResponseEntity<Void> deleteAction(Long actionId) {
|
||||
deploymentManagement.deleteAction(actionId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@AuditLog(entity = "Actions", type = AuditLog.Type.DELETE, description = "Delete Actions", logResponse = true)
|
||||
public ResponseEntity<Void> deleteActions(String rsqlParam, List<Long> actionIds) {
|
||||
|
||||
final boolean isActionIdsNotEmpty = !ObjectUtils.isEmpty(actionIds);
|
||||
if (!ObjectUtils.isEmpty(rsqlParam)) {
|
||||
|
||||
if (isActionIdsNotEmpty) {
|
||||
throw new IllegalArgumentException("Only one of the parameters should be provided!");
|
||||
}
|
||||
|
||||
deploymentManagement.deleteActionsByRsql(rsqlParam);
|
||||
} else if (isActionIdsNotEmpty) {
|
||||
deploymentManagement.deleteActionsByIds(actionIds);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Either action id list or rsql filter should be provided.");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private MgmtRepresentationMode getRepresentationModeFromString(final String representationModeParam) {
|
||||
return MgmtRepresentationMode.fromValue(representationModeParam)
|
||||
.orElseGet(() -> {
|
||||
|
||||
@@ -73,6 +73,7 @@ import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Slice;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
@@ -226,6 +227,26 @@ public class MgmtTargetResource implements MgmtTargetRestApi {
|
||||
return ResponseEntity.ok(new PagedList<>(MgmtTargetMapper.toResponse(targetId, activeActions.getContent()), totalActionCount));
|
||||
}
|
||||
|
||||
@Override
|
||||
@AuditLog(entity = "Target", type = AuditLog.Type.DELETE, description = "Delete Actions For Target")
|
||||
public ResponseEntity<Void> deleteActionsForTarget(final String targetId, final int keepLast, final List<Long> actionIds) {
|
||||
|
||||
if (keepLast < 0 && ObjectUtils.isEmpty(actionIds)) {
|
||||
throw new IllegalArgumentException("Either keepLast OR action ID list should be provided!");
|
||||
}
|
||||
|
||||
if (!ObjectUtils.isEmpty(actionIds)) {
|
||||
if (keepLast >= 0) {
|
||||
throw new IllegalArgumentException("Only one of the parameters should be provided!");
|
||||
}
|
||||
deploymentManagement.deleteTargetActionsByIds(targetId, actionIds);
|
||||
} else {
|
||||
deploymentManagement.deleteOldestTargetActions(targetId, keepLast);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<MgmtAction> getAction(final String targetId, final Long actionId) {
|
||||
return getValidatedAction(targetId, actionId)
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.eclipse.hawkbit.repository.model.Rollout;
|
||||
import org.eclipse.hawkbit.repository.model.Target;
|
||||
import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
/**
|
||||
@@ -479,9 +480,6 @@ class MgmtActionResourceTest extends AbstractManagementApiIntegrationTest {
|
||||
mvc.perform(put(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isMethodNotAllowed());
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isMethodNotAllowed());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,6 +508,105 @@ class MgmtActionResourceTest extends AbstractManagementApiIntegrationTest {
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSuccessfullyDeleteSingleAction() throws Exception {
|
||||
List<DistributionSetAssignmentResult> assignmentResults = createTargetsAndPerformAssignment(2);
|
||||
Action action1 = assignmentResults.get(0).getAssignedEntity().get(0);
|
||||
Action action2 = assignmentResults.get(1).getAssignedEntity().get(0);
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)
|
||||
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC"))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk())
|
||||
|
||||
// verify action 1
|
||||
.andExpect(jsonPath("content.[0].id", equalTo(action1.getId().intValue())))
|
||||
.andExpect(jsonPath("content.[1].id", equalTo(action2.getId().intValue())));
|
||||
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + action1.getId()))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + action1.getId()))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSuccessfullyDeleteMultipleActions() throws Exception {
|
||||
final List<DistributionSetAssignmentResult> assignmentResults = createTargetsAndPerformAssignment(4);
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)
|
||||
.param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC"))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk())
|
||||
|
||||
// verify action 1
|
||||
.andExpect(jsonPath("content.[0].id", equalTo(assignmentResults.get(0).getAssignedEntity().get(0).getId().intValue())))
|
||||
.andExpect(jsonPath("content.[1].id", equalTo(assignmentResults.get(1).getAssignedEntity().get(0).getId().intValue())))
|
||||
.andExpect(jsonPath("content.[2].id", equalTo(assignmentResults.get(2).getAssignedEntity().get(0).getId().intValue())))
|
||||
.andExpect(jsonPath("content.[3].id", equalTo(assignmentResults.get(3).getAssignedEntity().get(0).getId().intValue())));
|
||||
|
||||
final List<Long> actionIdsToDelete = new ArrayList<>();
|
||||
long deletedActionId1 = assignmentResults.get(2).getAssignedEntity().get(0).getId();
|
||||
actionIdsToDelete.add(deletedActionId1);
|
||||
long deletedActionId2 = assignmentResults.get(3).getAssignedEntity().get(0).getId();
|
||||
actionIdsToDelete.add(deletedActionId2);
|
||||
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)
|
||||
.content(toJson(actionIdsToDelete)).contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + deletedActionId1))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isNotFound());
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + deletedActionId2))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isNotFound());
|
||||
Action deletedAction3 = assignmentResults.get(1).getAssignedEntity().get(0);
|
||||
String rsql = "target.name==" + deletedAction3.getTarget().getName();
|
||||
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)
|
||||
.param(MgmtRestConstants.REQUEST_PARAMETER_SEARCH, rsql).contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + deletedAction3.getId()))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isNotFound());
|
||||
|
||||
mvc.perform(get(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/" + assignmentResults.get(0).getAssignedEntity().get(0).getId()))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReceiveBadRequestWhenNeeded() throws Exception {
|
||||
// bad request on both empty parameters
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING).contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
// bad request when both parameters are present
|
||||
mvc.perform(delete(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING).contentType(MediaType.APPLICATION_JSON)
|
||||
.param(MgmtRestConstants.REQUEST_PARAMETER_SEARCH, "target.name==test")
|
||||
.content(toJson(List.of(1,2,3)))
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
private List<DistributionSetAssignmentResult> createTargetsAndPerformAssignment(int n) {
|
||||
final List<Target> targets = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
targets.add(testdataFactory.createTarget("target-" + i));
|
||||
}
|
||||
|
||||
final List<DistributionSetAssignmentResult> results = new ArrayList<>();
|
||||
for (Target target : targets) {
|
||||
DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
results.add(assignDistributionSet(distributionSet.getId(), target.getControllerId()));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static String generateActionLink(final String targetId, final Long actionId) {
|
||||
return "http://localhost" + MgmtRestConstants.TARGET_V1_REQUEST_MAPPING +
|
||||
"/" + targetId + "/" + MgmtRestConstants.TARGET_V1_ACTIONS + "/" + actionId;
|
||||
|
||||
@@ -81,6 +81,7 @@ import org.eclipse.hawkbit.util.IpUtil;
|
||||
import org.hamcrest.Matchers;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
@@ -2858,6 +2859,76 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest {
|
||||
.andExpect(jsonPath("detailStatus", equalTo("error")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeletionOfLastNTargetActions() throws Exception {
|
||||
final Target testTarget = testdataFactory.createTarget("testTarget");
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
assignDistributionSet(distributionSet.getId(), testTarget.getControllerId());
|
||||
}
|
||||
|
||||
long actionsPerTarget = actionRepository.countByTargetId(testTarget.getId());
|
||||
Assertions.assertEquals(10, actionsPerTarget);
|
||||
List<Action> oldActions = deploymentManagement.findActionsByTarget(testTarget.getControllerId(), PAGE).getContent();
|
||||
|
||||
mvc.perform(delete(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions", testTarget.getControllerId())
|
||||
.param("keepLast", "5"))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk());
|
||||
|
||||
//the last 5 actions should be left
|
||||
List<Action> actions = deploymentManagement.findActionsByTarget(testTarget.getControllerId(), PAGE).getContent();
|
||||
Assertions.assertEquals(5, actions.size());
|
||||
for (int i = 0; i < 5; i++) {
|
||||
// last 5 actions should remain
|
||||
Assertions.assertEquals(oldActions.get(i + 5), actions.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testThatDeletionOfLastNTargetActionsReturnsBadRequestWhenNeeded() throws Exception {
|
||||
final Target testTarget = testdataFactory.createTarget();
|
||||
// either numberOfActions or actionIds list should be present
|
||||
mvc.perform(delete(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions", testTarget.getControllerId()))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
// both parameters present should also lead to bad request
|
||||
mvc.perform(delete(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions", testTarget.getControllerId())
|
||||
.param("keepLast", "5")
|
||||
.content(toJson(List.of(1,2,3)))
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeletionOfTargetActionsById() throws Exception {
|
||||
final Target testTarget = testdataFactory.createTarget("testTarget");
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
long dsId = distributionSet.getId();
|
||||
assignDistributionSet(dsId, testTarget.getControllerId());
|
||||
}
|
||||
|
||||
final List<Long> evenActionIds = deploymentManagement.findActionsByTarget(testTarget.getControllerId(), PAGE).getContent()
|
||||
.stream()
|
||||
.filter(action -> action.getId() % 2 == 0)
|
||||
.map(Identifiable::getId).toList();
|
||||
|
||||
mvc.perform(delete(MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + "/{targetId}/actions", testTarget.getControllerId())
|
||||
.content(toJson(evenActionIds))
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andDo(MockMvcResultPrinter.print())
|
||||
.andExpect(status().isOk());
|
||||
|
||||
long remaining = actionRepository.countByTargetId(testTarget.getId());
|
||||
Assertions.assertEquals(10 - evenActionIds.size(), remaining);
|
||||
List<Action> remainingActions = deploymentManagement.findActionsByTarget(testTarget.getControllerId(), PAGE).getContent();
|
||||
remainingActions.forEach(action -> Assertions.assertTrue(action.getId() % 2 != 0));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> confirmationOptions() {
|
||||
return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true),
|
||||
Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null));
|
||||
|
||||
@@ -298,6 +298,45 @@ public interface DeploymentManagement extends PermissionSupport {
|
||||
@PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY)
|
||||
Action forceTargetAction(long actionId);
|
||||
|
||||
/**
|
||||
* Deletes the current action by id.
|
||||
* @param actionId - action id
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('UPDATE_" + SpPermission.TARGET + "')")
|
||||
void deleteAction(long actionId);
|
||||
|
||||
/**
|
||||
* Deletes actions matching the provided rsql filter
|
||||
* @param rsql - rsql filter for actions to be deleted
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('UPDATE_" + SpPermission.TARGET + "')")
|
||||
void deleteActionsByRsql(String rsql);
|
||||
|
||||
/**
|
||||
* Deletes actions present in provided list of ids
|
||||
* @param actionIds - list of action ids to be deleted
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('UPDATE_" + SpPermission.TARGET + "')")
|
||||
void deleteActionsByIds(List<Long> actionIds);
|
||||
|
||||
/**
|
||||
* Deletes actions in scope of the target ONLY by list of action ids.
|
||||
*
|
||||
* @param target - target controllerId
|
||||
* @param actionsIds - list of action ids to be deleted
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('UPDATE_" + SpPermission.TARGET + "')")
|
||||
void deleteTargetActionsByIds(final String target, final List<Long> actionsIds);
|
||||
|
||||
/**
|
||||
* Deletes target actions and leaves the LAST N actions in the action history only.
|
||||
*
|
||||
* @param target - target controllerId
|
||||
* @param keepLast - number of actions to be left/kept (NOT deleted)
|
||||
*/
|
||||
@PreAuthorize("hasAuthority('UPDATE_" + SpPermission.TARGET + "')")
|
||||
void deleteOldestTargetActions(final String target, final int keepLast);
|
||||
|
||||
/**
|
||||
* Sets the status of inactive scheduled {@link Action}s for the specified {@link Target}s to {@link Status#CANCELED}
|
||||
*
|
||||
@@ -372,4 +411,7 @@ public interface DeploymentManagement extends PermissionSupport {
|
||||
*/
|
||||
@PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY)
|
||||
void cancelActionsForDistributionSet(final ActionCancellationType cancelationType, final DistributionSet set);
|
||||
|
||||
@PreAuthorize(SpringEvalExpressions.IS_SYSTEM_CODE)
|
||||
void handleMaxAssignmentsExceeded(Long targetId, Long requested, AssignmentQuotaExceededException ex);
|
||||
}
|
||||
@@ -133,6 +133,11 @@ public class TenantConfigurationProperties {
|
||||
*/
|
||||
public static final String IMPLICIT_LOCK_ENABLED = "implicit.lock.enabled";
|
||||
|
||||
/**
|
||||
* Configuration value for percentage of oldest actions to be cleaned if @maxActionsPerTarget quota is hit
|
||||
*/
|
||||
public static final String ACTIONS_PURGE_PERCENTAGE_ON_QUOTA_HIT = "actions.cleanup.onQuotaHit.percent";
|
||||
|
||||
private static final Map<Class<? extends Serializable>, TenantConfigurationValidator> DEFAULT_TYPE_VALIDATORS = Map.of(
|
||||
Boolean.class, new TenantConfigurationBooleanValidator(),
|
||||
Integer.class, new TenantConfigurationIntegerValidator(),
|
||||
|
||||
@@ -78,6 +78,10 @@ hawkbit.server.tenant.configuration.action-cleanup-action-expiry.dataType=java.l
|
||||
hawkbit.server.tenant.configuration.action-cleanup-action-status.keyName=action.cleanup.actionStatus
|
||||
hawkbit.server.tenant.configuration.action-cleanup-action-status.defaultValue=CANCELED,ERROR
|
||||
|
||||
hawkbit.server.tenant.configuration.actions-cleanup-on-quota-hit-percent.keyName=actions.cleanup.onQuotaHit.percent
|
||||
hawkbit.server.tenant.configuration.actions-cleanup-on-quota-hit-percent.defaultValue=0
|
||||
hawkbit.server.tenant.configuration.actions-cleanup-on-quota-hit-percent.dataType=java.lang.Integer
|
||||
|
||||
hawkbit.server.tenant.configuration.multi-assignments-enabled.keyName=multi.assignments.enabled
|
||||
hawkbit.server.tenant.configuration.multi-assignments-enabled.defaultValue=false
|
||||
hawkbit.server.tenant.configuration.multi-assignments-enabled.dataType=java.lang.Boolean
|
||||
|
||||
@@ -345,12 +345,7 @@ public class SpecificationBuilder<T> {
|
||||
}
|
||||
|
||||
private String toSqlLikeValue(final String value) {
|
||||
final String escaped;
|
||||
if (database == Database.SQL_SERVER) {
|
||||
escaped = value.replace("%", "[%]").replace("_", "[_]");
|
||||
} else {
|
||||
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
}
|
||||
final String escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
final String finalizedValue;
|
||||
if (escaped.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
|
||||
finalizedValue = escaped.replace(ESCAPE_CHAR_WITH_ASTERISK, "$")
|
||||
|
||||
@@ -503,13 +503,7 @@ public class JpaQueryRsqlVisitor<A extends Enum<A> & QueryField, T> extends Abst
|
||||
}
|
||||
|
||||
private String toSQL(final String transformedValue) {
|
||||
final String escaped;
|
||||
|
||||
if (database == Database.SQL_SERVER) {
|
||||
escaped = transformedValue.replace("%", "[%]").replace("_", "[_]");
|
||||
} else {
|
||||
escaped = transformedValue.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
}
|
||||
final String escaped = transformedValue.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
return replaceIfRequired(escaped);
|
||||
}
|
||||
|
||||
|
||||
@@ -425,12 +425,7 @@ public class JpaQueryRsqlVisitorG2<A extends Enum<A> & QueryField, T>
|
||||
}
|
||||
|
||||
private String toSQL(final String value) {
|
||||
final String escaped;
|
||||
if (database == Database.SQL_SERVER) {
|
||||
escaped = value.replace("%", "[%]").replace("_", "[_]");
|
||||
} else {
|
||||
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
}
|
||||
final String escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
|
||||
return replaceIfRequired(escaped);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ import org.eclipse.hawkbit.repository.model.Action.ActionType;
|
||||
import org.eclipse.hawkbit.repository.model.Action.Status;
|
||||
import org.eclipse.hawkbit.repository.model.ActionCancellationType;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSet;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSetInvalidation;
|
||||
import org.eclipse.hawkbit.repository.model.Rollout;
|
||||
import org.eclipse.hawkbit.repository.model.Rollout.RolloutStatus;
|
||||
import org.eclipse.hawkbit.repository.model.RolloutGroup;
|
||||
@@ -856,8 +855,12 @@ public class JpaRolloutExecutor implements RolloutExecutor {
|
||||
* @param target the target
|
||||
* @param requested number of actions to check
|
||||
*/
|
||||
private void assertActionsPerTargetQuota(final Target target, final int requested) {
|
||||
private void assertActionsPerTargetQuota(final Target target, final long requested) {
|
||||
final int quota = quotaManagement.getMaxActionsPerTarget();
|
||||
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
|
||||
try {
|
||||
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
|
||||
} catch (final AssignmentQuotaExceededException ex) {
|
||||
deploymentManagement.handleMaxAssignmentsExceeded(target.getId(), requested, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,14 @@ import java.util.function.BooleanSupplier;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.function.TriConsumer;
|
||||
import org.eclipse.hawkbit.repository.QuotaManagement;
|
||||
import org.eclipse.hawkbit.repository.RepositoryConstants;
|
||||
import org.eclipse.hawkbit.repository.RepositoryProperties;
|
||||
import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
|
||||
import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent;
|
||||
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
|
||||
import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
|
||||
import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.AbstractJpaBaseEntity_;
|
||||
@@ -65,6 +67,7 @@ public abstract class AbstractDsAssignmentStrategy {
|
||||
private final BooleanSupplier multiAssignmentsConfig;
|
||||
private final BooleanSupplier confirmationFlowConfig;
|
||||
private final RepositoryProperties repositoryProperties;
|
||||
private final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentExceededHandler;
|
||||
|
||||
@SuppressWarnings("java:S107")
|
||||
AbstractDsAssignmentStrategy(
|
||||
@@ -72,7 +75,8 @@ public abstract class AbstractDsAssignmentStrategy {
|
||||
final AfterTransactionCommitExecutor afterCommit,
|
||||
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
|
||||
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig,
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties) {
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties,
|
||||
final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentExceededHandler) {
|
||||
this.targetRepository = targetRepository;
|
||||
this.afterCommit = afterCommit;
|
||||
this.actionRepository = actionRepository;
|
||||
@@ -81,6 +85,7 @@ public abstract class AbstractDsAssignmentStrategy {
|
||||
this.multiAssignmentsConfig = multiAssignmentsConfig;
|
||||
this.confirmationFlowConfig = confirmationFlowConfig;
|
||||
this.repositoryProperties = repositoryProperties;
|
||||
this.maxAssignmentExceededHandler = maxAssignmentExceededHandler;
|
||||
}
|
||||
|
||||
public JpaAction createTargetAction(
|
||||
@@ -292,8 +297,12 @@ public abstract class AbstractDsAssignmentStrategy {
|
||||
.publishEvent(new CancelTargetAssignmentEvent(tenant, actions)));
|
||||
}
|
||||
|
||||
private void assertActionsPerTargetQuota(final Target target, final int requested) {
|
||||
private void assertActionsPerTargetQuota(final Target target, final long requested) {
|
||||
final int quota = quotaManagement.getMaxActionsPerTarget();
|
||||
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
|
||||
try {
|
||||
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
|
||||
} catch (AssignmentQuotaExceededException ex) {
|
||||
maxAssignmentExceededHandler.accept(target.getId(), requested, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import jakarta.persistence.criteria.Root;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.apache.commons.lang3.ObjectUtils;
|
||||
import org.apache.commons.lang3.function.TriConsumer;
|
||||
import org.eclipse.hawkbit.repository.ActionFields;
|
||||
import org.eclipse.hawkbit.repository.DeploymentManagement;
|
||||
import org.eclipse.hawkbit.repository.QuotaManagement;
|
||||
@@ -44,6 +46,7 @@ import org.eclipse.hawkbit.repository.RepositoryConstants;
|
||||
import org.eclipse.hawkbit.repository.RepositoryProperties;
|
||||
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
|
||||
import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent;
|
||||
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
|
||||
import org.eclipse.hawkbit.repository.exception.CancelActionNotAllowedException;
|
||||
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
|
||||
import org.eclipse.hawkbit.repository.exception.ForceQuitActionNotAllowedException;
|
||||
@@ -62,10 +65,10 @@ import org.eclipse.hawkbit.repository.jpa.model.JpaActionStatus;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget_;
|
||||
import org.eclipse.hawkbit.repository.jpa.ql.QLSupport;
|
||||
import org.eclipse.hawkbit.repository.jpa.repository.ActionRepository;
|
||||
import org.eclipse.hawkbit.repository.jpa.repository.ActionStatusRepository;
|
||||
import org.eclipse.hawkbit.repository.jpa.repository.TargetRepository;
|
||||
import org.eclipse.hawkbit.repository.jpa.ql.QLSupport;
|
||||
import org.eclipse.hawkbit.repository.jpa.specifications.ActionSpecifications;
|
||||
import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications;
|
||||
import org.eclipse.hawkbit.repository.jpa.utils.DeploymentHelper;
|
||||
@@ -87,6 +90,7 @@ import org.eclipse.hawkbit.repository.model.TargetUpdateStatus;
|
||||
import org.eclipse.hawkbit.repository.model.TargetWithActionType;
|
||||
import org.eclipse.hawkbit.security.SystemSecurityContext;
|
||||
import org.eclipse.hawkbit.tenancy.TenantAware;
|
||||
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties;
|
||||
import org.eclipse.hawkbit.utils.TenantConfigHelper;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
|
||||
@@ -126,8 +130,6 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
|
||||
static {
|
||||
QUERY_DELETE_ACTIONS_BY_STATE_AND_LAST_MODIFIED = new EnumMap<>(Database.class);
|
||||
QUERY_DELETE_ACTIONS_BY_STATE_AND_LAST_MODIFIED.put(Database.SQL_SERVER,
|
||||
"DELETE TOP (" + ACTION_PAGE_LIMIT + ") FROM sp_action " + "WHERE tenant=" + Jpa.nativeQueryParamPrefix() + "tenant" + " AND status IN (%s)" + " AND last_modified_at<" + Jpa.nativeQueryParamPrefix() + "last_modified_at ");
|
||||
QUERY_DELETE_ACTIONS_BY_STATE_AND_LAST_MODIFIED.put(Database.POSTGRESQL,
|
||||
"DELETE FROM sp_action " + "WHERE id IN (SELECT id FROM sp_action " + "WHERE tenant=" + Jpa.nativeQueryParamPrefix() + "tenant" + " AND status IN (%s)" + " AND last_modified_at<" + Jpa.nativeQueryParamPrefix() + "last_modified_at LIMIT " + ACTION_PAGE_LIMIT + ")");
|
||||
}
|
||||
@@ -161,9 +163,11 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
this.auditorProvider = auditorProvider;
|
||||
this.txManager = txManager;
|
||||
onlineDsAssignmentStrategy = new OnlineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository,
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties);
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties,
|
||||
maxAssignmentsExceededHandler);
|
||||
offlineDsAssignmentStrategy = new OfflineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository,
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties);
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties,
|
||||
maxAssignmentsExceededHandler);
|
||||
this.tenantConfigurationManagement = tenantConfigurationManagement;
|
||||
this.systemSecurityContext = systemSecurityContext;
|
||||
this.tenantAware = tenantAware;
|
||||
@@ -401,6 +405,71 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
return action;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteAction(final long actionId) {
|
||||
log.info("Deleting action {}", actionId);
|
||||
actionRepository.getAccessController().ifPresent(accessController -> {
|
||||
// check update access
|
||||
if (ObjectUtils.isEmpty(actionRepository.findAll(accessController.appendAccessRules(
|
||||
AccessController.Operation.UPDATE, ((root, q, cb) -> cb.equal(root.get(AbstractJpaBaseEntity_.id), actionId)))))) {
|
||||
// could be also InsufficientPermissionException but for security reasons we do not reveal that the entity exists
|
||||
throw new EntityNotFoundException(Action.class, actionId);
|
||||
}
|
||||
});
|
||||
actionRepository.deleteById(actionId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteActionsByRsql(final String rsql) {
|
||||
log.info("Deleting actions matching rsql {}", rsql);
|
||||
final Specification<JpaAction> specification = QLSupport.getInstance().buildSpec(rsql, ActionFields.class);
|
||||
actionRepository.delete(
|
||||
actionRepository.getAccessController()
|
||||
.map(accessController -> accessController.appendAccessRules(AccessController.Operation.UPDATE, specification))
|
||||
.orElse(specification));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteActionsByIds(final List<Long> actionIds) {
|
||||
log.info("Deleting actions with ids {}", actionIds);
|
||||
actionRepository.getAccessController().ifPresent(accessController ->
|
||||
actionRepository.findAll(
|
||||
accessController.appendAccessRules(AccessController.Operation.UPDATE, ActionSpecifications.byIdIn(actionIds))));
|
||||
actionRepository.deleteByIdIn(actionIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteTargetActionsByIds(final String target, final List<Long> actionsIds) {
|
||||
log.info("Delete actions for target {} with action ids {}", target, actionsIds);
|
||||
targetRepository.getAccessController()
|
||||
.ifPresent(accessController ->
|
||||
accessController.assertOperationAllowed(AccessController.Operation.UPDATE, targetRepository.getByControllerId(target)));
|
||||
actionRepository.delete(ActionSpecifications.byControllerIdAndIdIn(target, actionsIds));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteOldestTargetActions(final String target, final int keepLast) {
|
||||
final JpaTarget jpaTarget = targetRepository.findByControllerId(target)
|
||||
.orElseThrow(EntityNotFoundException::new);
|
||||
targetRepository.getAccessController().ifPresent(accessController ->
|
||||
accessController.assertOperationAllowed(AccessController.Operation.UPDATE, jpaTarget));
|
||||
|
||||
final long targetActions = actionRepository.countByTargetId(jpaTarget.getId());
|
||||
|
||||
long oldestToDelete;
|
||||
if (targetActions > keepLast) {
|
||||
oldestToDelete = targetActions - keepLast;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
deleteOldestTargetActions(jpaTarget.getId(), (int) oldestToDelete);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
@Retryable(retryFor = {
|
||||
@@ -438,7 +507,7 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
startScheduledActions(groupScheduledActions.getContent());
|
||||
return groupScheduledActions.getTotalElements();
|
||||
}
|
||||
}) > 0);
|
||||
}) > 0) ;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -529,6 +598,58 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMaxAssignmentsExceeded(final Long targetId, final Long requested, final AssignmentQuotaExceededException ex) {
|
||||
maxAssignmentsExceededHandler.accept(targetId, requested, ex);
|
||||
}
|
||||
|
||||
private final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentsExceededHandler = (targetId, requested, quotaExceededException) -> {
|
||||
int actionsPurgePercentage = getActionsPurgePercentage();
|
||||
int quota = quotaManagement.getMaxActionsPerTarget();
|
||||
if (actionsPurgePercentage > 0 && actionsPurgePercentage < 100) {
|
||||
int numberOfActions = (int) ((actionsPurgePercentage / 100.0) * quota);
|
||||
if (requested > numberOfActions) {
|
||||
log.warn("Requested number of actions {} bigger than configured for deletion {}", requested, numberOfActions);
|
||||
throw quotaExceededException;
|
||||
}
|
||||
int totalTargetActions = Math.toIntExact(actionRepository.countByTargetId(targetId));
|
||||
if (totalTargetActions < quota) {
|
||||
numberOfActions = totalTargetActions - (quota - numberOfActions);
|
||||
}
|
||||
log.info("Actions purge percentage {}, will delete {} oldest actions for target {}",
|
||||
actionsPurgePercentage, numberOfActions, targetId);
|
||||
deleteOldestTargetActions(targetId, numberOfActions);
|
||||
} else {
|
||||
throw quotaExceededException;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the first n target actions of a target
|
||||
*
|
||||
* @param targetId - target id
|
||||
* @param oldestToDelete - number of oldest actions to be deleted
|
||||
*/
|
||||
public void deleteOldestTargetActions(long targetId, int oldestToDelete) {
|
||||
// Workaround for the case where JPQL or Criteria API do not support LIMIT
|
||||
log.info("Deleting last {} actions of target {}", oldestToDelete, targetId);
|
||||
final String SQL = "DELETE FROM sp_action WHERE id IN(" +
|
||||
"SELECT id FROM (" +
|
||||
"SELECT id FROM sp_action" +
|
||||
" WHERE target=" + Jpa.nativeQueryParamPrefix() + "target" +
|
||||
" ORDER BY id ASC" +
|
||||
" LIMIT " + oldestToDelete
|
||||
+ ") AS sub"
|
||||
+ ")";
|
||||
Query query = entityManager.createNativeQuery(SQL);
|
||||
query.setParameter("target", targetId);
|
||||
query.executeUpdate();
|
||||
}
|
||||
|
||||
private int getActionsPurgePercentage() {
|
||||
return getConfigValue(TenantConfigurationProperties.TenantConfigurationKey.ACTIONS_PURGE_PERCENTAGE_ON_QUOTA_HIT, Integer.class);
|
||||
}
|
||||
|
||||
protected boolean isActionsAutocloseEnabled() {
|
||||
return getConfigValue(REPOSITORY_ACTIONS_AUTOCLOSE_ENABLED, Boolean.class);
|
||||
}
|
||||
@@ -777,14 +898,25 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
|
||||
}
|
||||
|
||||
private void enforceMaxActionsPerTarget(final Collection<DeploymentRequest> deploymentRequests) {
|
||||
final int quota = quotaManagement.getMaxActionsPerTarget();
|
||||
|
||||
final Map<String, Long> countOfTargetInRequest = deploymentRequests.stream().map(DeploymentRequest::getControllerId)
|
||||
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
|
||||
|
||||
countOfTargetInRequest.forEach(
|
||||
(controllerId, count) -> QuotaHelper.assertAssignmentQuota(controllerId, count, quota, Action.class, Target.class,
|
||||
actionRepository::countByTargetControllerId));
|
||||
countOfTargetInRequest.forEach(this::checkMaxAssignmentQuota);
|
||||
}
|
||||
|
||||
private void checkMaxAssignmentQuota(final String controllerId, final long requested) {
|
||||
final int quota = quotaManagement.getMaxActionsPerTarget();
|
||||
try {
|
||||
systemSecurityContext.runAsSystem(() -> QuotaHelper.assertAssignmentQuota(
|
||||
controllerId, requested, quota, Action.class, Target.class, actionRepository::countByTargetControllerId));
|
||||
} catch (final AssignmentQuotaExceededException ex) {
|
||||
targetRepository.findByControllerId(controllerId).ifPresentOrElse(
|
||||
// assume requested are always smaller than int size
|
||||
target -> maxAssignmentsExceededHandler.accept(target.getId(), requested, ex),
|
||||
() -> {
|
||||
throw new EntityNotFoundException(Target.class, controllerId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void closeOrCancelActiveActions(final AbstractDsAssignmentStrategy assignmentStrategy, final List<List<Long>> targetIdsChunks) {
|
||||
|
||||
@@ -179,7 +179,7 @@ public class JpaRolloutManagement implements RolloutManagement {
|
||||
this.repositoryProperties = repositoryProperties;
|
||||
|
||||
this.onlineDsAssignmentStrategy = new OnlineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository,
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties);
|
||||
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties, null);
|
||||
}
|
||||
|
||||
public static String createRolloutLockKey(final String tenant) {
|
||||
|
||||
@@ -12,13 +12,17 @@ package org.eclipse.hawkbit.repository.jpa.management;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.apache.commons.lang3.function.TriConsumer;
|
||||
import org.eclipse.hawkbit.repository.QuotaManagement;
|
||||
import org.eclipse.hawkbit.repository.RepositoryConstants;
|
||||
import org.eclipse.hawkbit.repository.RepositoryProperties;
|
||||
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
|
||||
import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException;
|
||||
import org.eclipse.hawkbit.repository.jpa.JpaManagementHelper;
|
||||
import org.eclipse.hawkbit.repository.jpa.acm.AccessController;
|
||||
@@ -48,9 +52,10 @@ class OfflineDsAssignmentStrategy extends AbstractDsAssignmentStrategy {
|
||||
final AfterTransactionCommitExecutor afterCommit,
|
||||
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
|
||||
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig,
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties) {
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties,
|
||||
final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentExceededHandler) {
|
||||
super(targetRepository, afterCommit, actionRepository, actionStatusRepository,
|
||||
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties);
|
||||
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -13,18 +13,22 @@ import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.collections4.ListUtils;
|
||||
import org.apache.commons.lang3.function.TriConsumer;
|
||||
import org.eclipse.hawkbit.repository.QuotaManagement;
|
||||
import org.eclipse.hawkbit.repository.RepositoryProperties;
|
||||
import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
|
||||
import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent;
|
||||
import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException;
|
||||
import org.eclipse.hawkbit.repository.exception.InsufficientPermissionException;
|
||||
import org.eclipse.hawkbit.repository.jpa.acm.AccessController;
|
||||
import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
|
||||
@@ -54,9 +58,10 @@ class OnlineDsAssignmentStrategy extends AbstractDsAssignmentStrategy {
|
||||
final AfterTransactionCommitExecutor afterCommit,
|
||||
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
|
||||
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig,
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties) {
|
||||
final BooleanSupplier confirmationFlowConfig, final RepositoryProperties repositoryProperties,
|
||||
final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentExceededHandler) {
|
||||
super(targetRepository, afterCommit, actionRepository, actionStatusRepository,
|
||||
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties);
|
||||
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler);
|
||||
}
|
||||
|
||||
public void sendDeploymentEvents(final long distributionSetId, final List<Action> actions) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet_;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModule;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModule_;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
|
||||
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget_;
|
||||
import org.eclipse.hawkbit.repository.model.Action;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
@@ -42,6 +43,11 @@ public final class ActionSpecifications {
|
||||
cb.equal(root.get(JpaAction_.active), true));
|
||||
}
|
||||
|
||||
public static Specification<JpaAction> byIdIn(final List<Long> actionIds) {
|
||||
return ((root, query, cb) ->
|
||||
root.get(AbstractJpaBaseEntity_.id).in(actionIds));
|
||||
}
|
||||
|
||||
public static Specification<JpaAction> byTargetControllerId(final String controllerId) {
|
||||
return (root, query, cb) -> cb.equal(root.get(JpaAction_.target).get(JpaTarget_.controllerId), controllerId);
|
||||
}
|
||||
@@ -101,6 +107,17 @@ public final class ActionSpecifications {
|
||||
);
|
||||
}
|
||||
|
||||
public static Specification<JpaAction> byControllerIdAndIdIn(final String controllerId, final List<Long> actionIds) {
|
||||
return ((root, query, cb) -> {
|
||||
final Join<JpaAction, JpaTarget> targetJoin = root.join(JpaAction_.target);
|
||||
return cb.and(
|
||||
cb.equal(targetJoin.get(JpaTarget_.controllerId), controllerId),
|
||||
root.get(AbstractJpaBaseEntity_.id).in(actionIds)
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public static Specification<JpaAction> byDistributionSetIdAndActiveAndStatusIsNot(final Long distributionSetId, final Action.Status status) {
|
||||
return (root, query, cb) -> cb.and(
|
||||
cb.equal(root.get(JpaAction_.distributionSet).get(AbstractJpaBaseEntity_.id), distributionSetId),
|
||||
|
||||
@@ -14,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.eclipse.hawkbit.repository.model.Action.Status.RUNNING;
|
||||
import static org.eclipse.hawkbit.repository.model.Action.Status.WAIT_FOR_CONFIRMATION;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.AbstractMap.SimpleEntry;
|
||||
import java.util.ArrayList;
|
||||
@@ -34,6 +35,7 @@ import org.assertj.core.api.Assertions;
|
||||
import org.eclipse.hawkbit.repository.ActionStatusFields;
|
||||
import org.eclipse.hawkbit.repository.DistributionSetManagement;
|
||||
import org.eclipse.hawkbit.repository.DistributionSetTagManagement;
|
||||
import org.eclipse.hawkbit.repository.Identifiable;
|
||||
import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent;
|
||||
import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent;
|
||||
@@ -75,6 +77,7 @@ import org.eclipse.hawkbit.repository.model.DeploymentRequest;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSet;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult;
|
||||
import org.eclipse.hawkbit.repository.model.DistributionSetType;
|
||||
import org.eclipse.hawkbit.repository.model.Rollout;
|
||||
import org.eclipse.hawkbit.repository.model.SoftwareModule;
|
||||
import org.eclipse.hawkbit.repository.model.Target;
|
||||
import org.eclipse.hawkbit.repository.model.TargetType;
|
||||
@@ -1673,6 +1676,105 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest {
|
||||
.isThrownBy(() -> deploymentManagement.assignDistributionSets(deploymentRequests));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManualAssignmentsActionsPurge() {
|
||||
Target target = testdataFactory.createTarget();
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
DistributionSet distributionSet = testdataFactory.createDistributionSet("ds_" + i);
|
||||
assignDistributionSet(distributionSet.getId(), target.getControllerId());
|
||||
}
|
||||
|
||||
long actions = deploymentManagement.countActionsByTarget(target.getControllerId());
|
||||
// quota in tests is set to 20 ...
|
||||
assertEquals(20, actions);
|
||||
|
||||
// extract the first 5 action ids
|
||||
List<Action> firstSample = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent();
|
||||
List<Long> shouldBePurgedActionsList = firstSample.stream().map(Identifiable::getId).limit(5).toList();
|
||||
|
||||
DistributionSet exceededQuotaDsAssign = testdataFactory.createDistributionSet("exceededQuotaAssignment");
|
||||
|
||||
// should throw quota exception if not explicitly configured to purge actions
|
||||
assertThrows(AssignmentQuotaExceededException.class,
|
||||
() -> assignDistributionSet(exceededQuotaDsAssign.getId(), target.getControllerId()));
|
||||
|
||||
// set purge config to 25 %
|
||||
tenantConfigurationManagement.addOrUpdateConfiguration("actions.cleanup.onQuotaHit.percent", 25);
|
||||
|
||||
// assign again
|
||||
assignDistributionSet(exceededQuotaDsAssign.getId(), target.getControllerId());
|
||||
// 16 actions should be present
|
||||
actions = deploymentManagement.countActionsByTarget(target.getControllerId());
|
||||
assertEquals(16, actions);
|
||||
|
||||
List<Action> actionsList = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent();
|
||||
// first 5 should have been purged so the first actionId should be the last purged action id + 1
|
||||
assertEquals(shouldBePurgedActionsList.get(shouldBePurgedActionsList.size() - 1) + 1, actionsList.get(0).getId());
|
||||
assertEquals(firstSample.get(firstSample.size() - 1).getId() + 1, actionsList.get(15).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRolloutAssignmentsActionsPurge() {
|
||||
final Target target = testdataFactory.createTarget();
|
||||
for (int i = 0; i < 20; i++) {
|
||||
DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
Rollout rollout = testdataFactory.createRolloutByVariables("rollout-" + i, "Description", 1, "controllerId==" + target
|
||||
.getControllerId(),
|
||||
distributionSet, "50", "50");
|
||||
rolloutManagement.start(rollout.getId());
|
||||
rolloutHandler.handleAll();
|
||||
}
|
||||
|
||||
assertEquals(20, deploymentManagement.countActionsByTarget(target.getControllerId()));
|
||||
List<Action> firstSample = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent();
|
||||
List<Long> shouldBePurgedActionsList = firstSample.stream().map(Identifiable::getId).limit(5).toList();
|
||||
|
||||
DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
Rollout rollout = testdataFactory.createRolloutByVariables("rollout-quota", "Description", 1, "controllerId==" + target
|
||||
.getControllerId(),
|
||||
distributionSet, "50", "50");
|
||||
rolloutManagement.start(rollout.getId());
|
||||
// don't assert quota exception here because rollout executor does not throw such in order to not interrupt other executions
|
||||
rolloutHandler.handleAll();
|
||||
//check that the old number of actions remain instead
|
||||
assertEquals(20, deploymentManagement.countActionsByTarget(target.getControllerId()));
|
||||
|
||||
// set purge config to 25 %
|
||||
tenantConfigurationManagement.addOrUpdateConfiguration("actions.cleanup.onQuotaHit.percent", 25);
|
||||
rolloutHandler.handleAll();
|
||||
assertEquals(16, deploymentManagement.countActionsByTarget(target.getControllerId()));
|
||||
|
||||
List<Action> actionsList = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent();
|
||||
// first 5 should have been purged so the first actionId should be the last purged action id + 1
|
||||
assertEquals(shouldBePurgedActionsList.get(shouldBePurgedActionsList.size() - 1) + 1, actionsList.get(0).getId());
|
||||
assertEquals(firstSample.get(firstSample.size() - 1).getId() + 1, actionsList.get(15).getId());
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void testThatOnlyNeededNumberOfActionsIsPurged() {
|
||||
final Target target = testdataFactory.createTarget();
|
||||
for (int i = 0; i < 18; i++) {
|
||||
DistributionSet distributionSet = testdataFactory.createDistributionSet();
|
||||
Rollout rollout = testdataFactory.createRolloutByVariables("rollout-" + i, "Description", 1, "controllerId==" + target
|
||||
.getControllerId(),
|
||||
distributionSet, "50", "50");
|
||||
rolloutManagement.start(rollout.getId());
|
||||
rolloutHandler.handleAll();
|
||||
}
|
||||
|
||||
tenantConfigurationManagement.addOrUpdateConfiguration("actions.cleanup.onQuotaHit.percent", 25);
|
||||
deploymentManagement.handleMaxAssignmentsExceeded(target.getId(), 5L, new AssignmentQuotaExceededException());
|
||||
// only 3 actions should be deleted in such case :
|
||||
assertEquals(15, deploymentManagement.countActionsByTarget(target.getControllerId()));
|
||||
|
||||
// should throw the quota exception if requested is bigger than the configured limit of actions purge
|
||||
assertThrows(AssignmentQuotaExceededException.class, () ->
|
||||
deploymentManagement.handleMaxAssignmentsExceeded(target.getId(), 10L, new AssignmentQuotaExceededException()));
|
||||
|
||||
}
|
||||
|
||||
private List<DeploymentRequest> createAssignmentRequests(
|
||||
final Collection<DistributionSet> distributionSets, final Collection<Target> targets, final int weight) {
|
||||
return createAssignmentRequests(distributionSets, targets, weight, false);
|
||||
|
||||
@@ -139,6 +139,10 @@ class ManagementSecurityTest extends AbstractJpaIntegrationTest {
|
||||
method.getReturnType() != String.class)
|
||||
// jacoco adds some methods with bytecode instrumentation
|
||||
.filter(method -> !"$jacocoInit".equals(method.getName()))
|
||||
// skip maxAssignmentsExceededHandler in DeploymentManagement since it throws quota exception
|
||||
// because of actions.cleanup.onQuotaHit.percent not configured
|
||||
// other option would be to configure it for all tests
|
||||
.filter(method -> !"handleMaxAssignmentsExceeded".equals(method.getName()))
|
||||
.map(method -> Arguments.of(clazz, method)))
|
||||
// consumes the stream because scan result couldn't be used after being closed
|
||||
.toList()
|
||||
|
||||
Reference in New Issue
Block a user