diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtActionRestApi.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtActionRestApi.java index 1cc8d0c4b..1953609e7 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtActionRestApi.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtActionRestApi.java @@ -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 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 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 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 actionIds); } \ No newline at end of file diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java index d50bc15cf..27bb12f09 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetRestApi.java @@ -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 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 actionIds); + /** * Handles the GET request of retrieving a specific Actions of a specific Target. * diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResource.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResource.java index b4b896d4d..062cbb200 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResource.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResource.java @@ -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 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 deleteActions(String rsqlParam, List 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(() -> { diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java index 619d64705..422cafa60 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResource.java @@ -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 deleteActionsForTarget(final String targetId, final int keepLast, final List 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 getAction(final String targetId, final Long actionId) { return getValidatedAction(targetId, actionId) diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResourceTest.java index 9d0f14251..caa276cca 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResourceTest.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtActionResourceTest.java @@ -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 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 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 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 createTargetsAndPerformAssignment(int n) { + final List targets = new ArrayList<>(); + for (int i = 0; i < n; i++) { + targets.add(testdataFactory.createTarget("target-" + i)); + } + + final List 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; diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java index 818ff43df..b72a84591 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java @@ -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 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 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 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 remainingActions = deploymentManagement.findActionsByTarget(testTarget.getControllerId(), PAGE).getContent(); + remainingActions.forEach(action -> Assertions.assertTrue(action.getId() % 2 != 0)); + } + private static Stream 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)); diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java index 4d5e2aaea..d32a88be4 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/DeploymentManagement.java @@ -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 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 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); } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java index 6bc111d17..5cd7f1fde 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/tenancy/configuration/TenantConfigurationProperties.java @@ -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, TenantConfigurationValidator> DEFAULT_TYPE_VALIDATORS = Map.of( Boolean.class, new TenantConfigurationBooleanValidator(), Integer.class, new TenantConfigurationIntegerValidator(), diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties index 23974ba62..3138b26bb 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties +++ b/hawkbit-repository/hawkbit-repository-core/src/main/resources/hawkbit-repository-defaults.properties @@ -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 diff --git a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/SpecificationBuilder.java b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/SpecificationBuilder.java index 1bb3cda36..3810d4cf6 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/SpecificationBuilder.java +++ b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/ql/SpecificationBuilder.java @@ -345,12 +345,7 @@ public class SpecificationBuilder { } 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, "$") diff --git a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitor.java b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitor.java index b73f3d9c9..61dfcf88f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitor.java +++ b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitor.java @@ -503,13 +503,7 @@ public class JpaQueryRsqlVisitor & 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); } diff --git a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitorG2.java b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitorG2.java index 06c805f24..3d3eb6280 100644 --- a/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitorG2.java +++ b/hawkbit-repository/hawkbit-repository-jpa-ql/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/legacy/JpaQueryRsqlVisitorG2.java @@ -425,12 +425,7 @@ public class JpaQueryRsqlVisitorG2 & 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); } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutExecutor.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutExecutor.java index f51569ad0..796b504c3 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutExecutor.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutExecutor.java @@ -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); + } } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/AbstractDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/AbstractDsAssignmentStrategy.java index 764a64f63..55e344d2f 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/AbstractDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/AbstractDsAssignmentStrategy.java @@ -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 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 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); + } } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaDeploymentManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaDeploymentManagement.java index f9f9bc53c..279261a37 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaDeploymentManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaDeploymentManagement.java @@ -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 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 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 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 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 deploymentRequests) { - final int quota = quotaManagement.getMaxActionsPerTarget(); - final Map 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> targetIdsChunks) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaRolloutManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaRolloutManagement.java index 7bd3a9112..68f527438 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaRolloutManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaRolloutManagement.java @@ -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) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OfflineDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OfflineDsAssignmentStrategy.java index be031ae6d..d112a40c4 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OfflineDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OfflineDsAssignmentStrategy.java @@ -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 maxAssignmentExceededHandler) { super(targetRepository, afterCommit, actionRepository, actionStatusRepository, - quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties); + quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler); } @Override diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OnlineDsAssignmentStrategy.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OnlineDsAssignmentStrategy.java index 855c0dca1..1c20ea258 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OnlineDsAssignmentStrategy.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/OnlineDsAssignmentStrategy.java @@ -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 maxAssignmentExceededHandler) { super(targetRepository, afterCommit, actionRepository, actionStatusRepository, - quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties); + quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler); } public void sendDeploymentEvents(final long distributionSetId, final List actions) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/ActionSpecifications.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/ActionSpecifications.java index d7ac2ee3d..7ac37ee94 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/ActionSpecifications.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/ActionSpecifications.java @@ -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 byIdIn(final List actionIds) { + return ((root, query, cb) -> + root.get(AbstractJpaBaseEntity_.id).in(actionIds)); + } + public static Specification 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 byControllerIdAndIdIn(final String controllerId, final List actionIds) { + return ((root, query, cb) -> { + final Join targetJoin = root.join(JpaAction_.target); + return cb.and( + cb.equal(targetJoin.get(JpaTarget_.controllerId), controllerId), + root.get(AbstractJpaBaseEntity_.id).in(actionIds) + ); + }); + + } + public static Specification byDistributionSetIdAndActiveAndStatusIsNot(final Long distributionSetId, final Action.Status status) { return (root, query, cb) -> cb.and( cb.equal(root.get(JpaAction_.distributionSet).get(AbstractJpaBaseEntity_.id), distributionSetId), diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/DeploymentManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/DeploymentManagementTest.java index 1530d91d0..d8ad31423 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/DeploymentManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/DeploymentManagementTest.java @@ -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 firstSample = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent(); + List 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 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 firstSample = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent(); + List 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 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 createAssignmentRequests( final Collection distributionSets, final Collection targets, final int weight) { return createAssignmentRequests(distributionSets, targets, weight, false); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/ManagementSecurityTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/ManagementSecurityTest.java index 8899543af..3eb459c83 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/ManagementSecurityTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/ManagementSecurityTest.java @@ -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()