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:
Stanislav Trailov
2025-10-21 10:34:58 +03:00
committed by GitHub
parent 9984c89183
commit f1c3d0175e
21 changed files with 662 additions and 44 deletions

View File

@@ -26,10 +26,14 @@ import org.eclipse.hawkbit.rest.json.model.ExceptionInfo;
import org.springframework.hateoas.MediaTypes; import org.springframework.hateoas.MediaTypes;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/** /**
* REST API providing (read-only) access to actions. * REST API providing (read-only) access to actions.
*/ */
@@ -125,4 +129,47 @@ public interface MgmtActionRestApi {
@GetMapping(value = MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/{actionId}", @GetMapping(value = MgmtRestConstants.ACTION_V1_REQUEST_MAPPING + "/{actionId}",
produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
ResponseEntity<MgmtAction> getAction(@PathVariable("actionId") Long actionId); 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);
} }

View File

@@ -403,6 +403,44 @@ public interface MgmtTargetRestApi {
in the result.""") in the result.""")
String sortParam); 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. * Handles the GET request of retrieving a specific Actions of a specific Target.
* *

View File

@@ -12,6 +12,7 @@ package org.eclipse.hawkbit.mgmt.rest.resource;
import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitizeActionSortParam; import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitizeActionSortParam;
import lombok.extern.slf4j.Slf4j; 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.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction; import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtActionRestApi; 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.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j @Slf4j
@RestController @RestController
@ConditionalOnProperty(name = "hawkbit.rest.MgmtActionResource.enabled", matchIfMissing = true) @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)); 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) { private MgmtRepresentationMode getRepresentationModeFromString(final String representationModeParam) {
return MgmtRepresentationMode.fromValue(representationModeParam) return MgmtRepresentationMode.fromValue(representationModeParam)
.orElseGet(() -> { .orElseGet(() -> {

View File

@@ -73,6 +73,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.RestController; 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)); 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 @Override
public ResponseEntity<MgmtAction> getAction(final String targetId, final Long actionId) { public ResponseEntity<MgmtAction> getAction(final String targetId, final Long actionId) {
return getValidatedAction(targetId, actionId) return getValidatedAction(targetId, actionId)

View File

@@ -35,6 +35,7 @@ import org.eclipse.hawkbit.repository.model.Rollout;
import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.Target;
import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter; import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
/** /**
@@ -479,9 +480,6 @@ class MgmtActionResourceTest extends AbstractManagementApiIntegrationTest {
mvc.perform(put(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING)) mvc.perform(put(MgmtRestConstants.ACTION_V1_REQUEST_MAPPING))
.andDo(MockMvcResultPrinter.print()) .andDo(MockMvcResultPrinter.print())
.andExpect(status().isMethodNotAllowed()); .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()); .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) { private static String generateActionLink(final String targetId, final Long actionId) {
return "http://localhost" + MgmtRestConstants.TARGET_V1_REQUEST_MAPPING + return "http://localhost" + MgmtRestConstants.TARGET_V1_REQUEST_MAPPING +
"/" + targetId + "/" + MgmtRestConstants.TARGET_V1_ACTIONS + "/" + actionId; "/" + targetId + "/" + MgmtRestConstants.TARGET_V1_ACTIONS + "/" + actionId;

View File

@@ -81,6 +81,7 @@ import org.eclipse.hawkbit.util.IpUtil;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
@@ -2858,6 +2859,76 @@ class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest {
.andExpect(jsonPath("detailStatus", equalTo("error"))); .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() { private static Stream<Arguments> confirmationOptions() {
return Stream.of(Arguments.of(true, true), Arguments.of(true, false), Arguments.of(false, true), 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)); Arguments.of(false, false), Arguments.of(true, null), Arguments.of(false, null));

View File

@@ -298,6 +298,45 @@ public interface DeploymentManagement extends PermissionSupport {
@PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY) @PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY)
Action forceTargetAction(long actionId); 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} * 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) @PreAuthorize(SpringEvalExpressions.HAS_UPDATE_REPOSITORY)
void cancelActionsForDistributionSet(final ActionCancellationType cancelationType, final DistributionSet set); void cancelActionsForDistributionSet(final ActionCancellationType cancelationType, final DistributionSet set);
@PreAuthorize(SpringEvalExpressions.IS_SYSTEM_CODE)
void handleMaxAssignmentsExceeded(Long targetId, Long requested, AssignmentQuotaExceededException ex);
} }

View File

@@ -133,6 +133,11 @@ public class TenantConfigurationProperties {
*/ */
public static final String IMPLICIT_LOCK_ENABLED = "implicit.lock.enabled"; 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( private static final Map<Class<? extends Serializable>, TenantConfigurationValidator> DEFAULT_TYPE_VALIDATORS = Map.of(
Boolean.class, new TenantConfigurationBooleanValidator(), Boolean.class, new TenantConfigurationBooleanValidator(),
Integer.class, new TenantConfigurationIntegerValidator(), Integer.class, new TenantConfigurationIntegerValidator(),

View File

@@ -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.keyName=action.cleanup.actionStatus
hawkbit.server.tenant.configuration.action-cleanup-action-status.defaultValue=CANCELED,ERROR 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.keyName=multi.assignments.enabled
hawkbit.server.tenant.configuration.multi-assignments-enabled.defaultValue=false hawkbit.server.tenant.configuration.multi-assignments-enabled.defaultValue=false
hawkbit.server.tenant.configuration.multi-assignments-enabled.dataType=java.lang.Boolean hawkbit.server.tenant.configuration.multi-assignments-enabled.dataType=java.lang.Boolean

View File

@@ -345,12 +345,7 @@ public class SpecificationBuilder<T> {
} }
private String toSqlLikeValue(final String value) { private String toSqlLikeValue(final String value) {
final String escaped; final String escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
if (database == Database.SQL_SERVER) {
escaped = value.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
final String finalizedValue; final String finalizedValue;
if (escaped.contains(ESCAPE_CHAR_WITH_ASTERISK)) { if (escaped.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
finalizedValue = escaped.replace(ESCAPE_CHAR_WITH_ASTERISK, "$") finalizedValue = escaped.replace(ESCAPE_CHAR_WITH_ASTERISK, "$")

View File

@@ -503,13 +503,7 @@ public class JpaQueryRsqlVisitor<A extends Enum<A> & QueryField, T> extends Abst
} }
private String toSQL(final String transformedValue) { private String toSQL(final String transformedValue) {
final String escaped; final String escaped = transformedValue.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
if (database == Database.SQL_SERVER) {
escaped = transformedValue.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = transformedValue.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
return replaceIfRequired(escaped); return replaceIfRequired(escaped);
} }

View File

@@ -425,12 +425,7 @@ public class JpaQueryRsqlVisitorG2<A extends Enum<A> & QueryField, T>
} }
private String toSQL(final String value) { private String toSQL(final String value) {
final String escaped; final String escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
if (database == Database.SQL_SERVER) {
escaped = value.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
return replaceIfRequired(escaped); return replaceIfRequired(escaped);
} }

View File

@@ -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.Action.Status;
import org.eclipse.hawkbit.repository.model.ActionCancellationType; import org.eclipse.hawkbit.repository.model.ActionCancellationType;
import org.eclipse.hawkbit.repository.model.DistributionSet; 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;
import org.eclipse.hawkbit.repository.model.Rollout.RolloutStatus; import org.eclipse.hawkbit.repository.model.Rollout.RolloutStatus;
import org.eclipse.hawkbit.repository.model.RolloutGroup; import org.eclipse.hawkbit.repository.model.RolloutGroup;
@@ -856,8 +855,12 @@ public class JpaRolloutExecutor implements RolloutExecutor {
* @param target the target * @param target the target
* @param requested number of actions to check * @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(); final int quota = quotaManagement.getMaxActionsPerTarget();
try {
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId); QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
} catch (final AssignmentQuotaExceededException ex) {
deploymentManagement.handleMaxAssignmentsExceeded(target.getId(), requested, ex);
}
} }
} }

View File

@@ -19,12 +19,14 @@ import java.util.function.BooleanSupplier;
import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.JoinType;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.function.TriConsumer;
import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.RepositoryConstants; import org.eclipse.hawkbit.repository.RepositoryConstants;
import org.eclipse.hawkbit.repository.RepositoryProperties; import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.repository.event.EventPublisherHolder; import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent; import org.eclipse.hawkbit.repository.event.remote.CancelTargetAssignmentEvent;
import org.eclipse.hawkbit.repository.event.remote.entity.TargetUpdatedEvent; 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.configuration.Constants;
import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor; import org.eclipse.hawkbit.repository.jpa.executor.AfterTransactionCommitExecutor;
import org.eclipse.hawkbit.repository.jpa.model.AbstractJpaBaseEntity_; import org.eclipse.hawkbit.repository.jpa.model.AbstractJpaBaseEntity_;
@@ -65,6 +67,7 @@ public abstract class AbstractDsAssignmentStrategy {
private final BooleanSupplier multiAssignmentsConfig; private final BooleanSupplier multiAssignmentsConfig;
private final BooleanSupplier confirmationFlowConfig; private final BooleanSupplier confirmationFlowConfig;
private final RepositoryProperties repositoryProperties; private final RepositoryProperties repositoryProperties;
private final TriConsumer<Long, Long, AssignmentQuotaExceededException> maxAssignmentExceededHandler;
@SuppressWarnings("java:S107") @SuppressWarnings("java:S107")
AbstractDsAssignmentStrategy( AbstractDsAssignmentStrategy(
@@ -72,7 +75,8 @@ public abstract class AbstractDsAssignmentStrategy {
final AfterTransactionCommitExecutor afterCommit, final AfterTransactionCommitExecutor afterCommit,
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, 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.targetRepository = targetRepository;
this.afterCommit = afterCommit; this.afterCommit = afterCommit;
this.actionRepository = actionRepository; this.actionRepository = actionRepository;
@@ -81,6 +85,7 @@ public abstract class AbstractDsAssignmentStrategy {
this.multiAssignmentsConfig = multiAssignmentsConfig; this.multiAssignmentsConfig = multiAssignmentsConfig;
this.confirmationFlowConfig = confirmationFlowConfig; this.confirmationFlowConfig = confirmationFlowConfig;
this.repositoryProperties = repositoryProperties; this.repositoryProperties = repositoryProperties;
this.maxAssignmentExceededHandler = maxAssignmentExceededHandler;
} }
public JpaAction createTargetAction( public JpaAction createTargetAction(
@@ -292,8 +297,12 @@ public abstract class AbstractDsAssignmentStrategy {
.publishEvent(new CancelTargetAssignmentEvent(tenant, actions))); .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(); final int quota = quotaManagement.getMaxActionsPerTarget();
try {
QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId); QuotaHelper.assertAssignmentQuota(target.getId(), requested, quota, Action.class, Target.class, actionRepository::countByTargetId);
} catch (AssignmentQuotaExceededException ex) {
maxAssignmentExceededHandler.accept(target.getId(), requested, ex);
}
} }
} }

View File

@@ -37,6 +37,8 @@ import jakarta.persistence.criteria.Root;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.ListUtils; 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.ActionFields;
import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.DeploymentManagement;
import org.eclipse.hawkbit.repository.QuotaManagement; 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.RepositoryProperties;
import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; 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.CancelActionNotAllowedException;
import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException;
import org.eclipse.hawkbit.repository.exception.ForceQuitActionNotAllowedException; 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.JpaDistributionSet;
import org.eclipse.hawkbit.repository.jpa.model.JpaTarget; import org.eclipse.hawkbit.repository.jpa.model.JpaTarget;
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.ActionRepository;
import org.eclipse.hawkbit.repository.jpa.repository.ActionStatusRepository; import org.eclipse.hawkbit.repository.jpa.repository.ActionStatusRepository;
import org.eclipse.hawkbit.repository.jpa.repository.TargetRepository; 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.ActionSpecifications;
import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications; import org.eclipse.hawkbit.repository.jpa.specifications.TargetSpecifications;
import org.eclipse.hawkbit.repository.jpa.utils.DeploymentHelper; 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.repository.model.TargetWithActionType;
import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.security.SystemSecurityContext;
import org.eclipse.hawkbit.tenancy.TenantAware; import org.eclipse.hawkbit.tenancy.TenantAware;
import org.eclipse.hawkbit.tenancy.configuration.TenantConfigurationProperties;
import org.eclipse.hawkbit.utils.TenantConfigHelper; import org.eclipse.hawkbit.utils.TenantConfigHelper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
@@ -126,8 +130,6 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
static { static {
QUERY_DELETE_ACTIONS_BY_STATE_AND_LAST_MODIFIED = new EnumMap<>(Database.class); 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, 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 + ")"); "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.auditorProvider = auditorProvider;
this.txManager = txManager; this.txManager = txManager;
onlineDsAssignmentStrategy = new OnlineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository, 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, offlineDsAssignmentStrategy = new OfflineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository,
quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties); quotaManagement, this::isMultiAssignmentsEnabled, this::isConfirmationFlowEnabled, repositoryProperties,
maxAssignmentsExceededHandler);
this.tenantConfigurationManagement = tenantConfigurationManagement; this.tenantConfigurationManagement = tenantConfigurationManagement;
this.systemSecurityContext = systemSecurityContext; this.systemSecurityContext = systemSecurityContext;
this.tenantAware = tenantAware; this.tenantAware = tenantAware;
@@ -401,6 +405,71 @@ public class JpaDeploymentManagement extends JpaActionManagement implements Depl
return action; 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 @Override
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
@Retryable(retryFor = { @Retryable(retryFor = {
@@ -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() { protected boolean isActionsAutocloseEnabled() {
return getConfigValue(REPOSITORY_ACTIONS_AUTOCLOSE_ENABLED, Boolean.class); 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) { private void enforceMaxActionsPerTarget(final Collection<DeploymentRequest> deploymentRequests) {
final int quota = quotaManagement.getMaxActionsPerTarget();
final Map<String, Long> countOfTargetInRequest = deploymentRequests.stream().map(DeploymentRequest::getControllerId) final Map<String, Long> countOfTargetInRequest = deploymentRequests.stream().map(DeploymentRequest::getControllerId)
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
countOfTargetInRequest.forEach( countOfTargetInRequest.forEach(this::checkMaxAssignmentQuota);
(controllerId, count) -> QuotaHelper.assertAssignmentQuota(controllerId, count, quota, Action.class, Target.class, }
actionRepository::countByTargetControllerId));
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) { private void closeOrCancelActiveActions(final AbstractDsAssignmentStrategy assignmentStrategy, final List<List<Long>> targetIdsChunks) {

View File

@@ -179,7 +179,7 @@ public class JpaRolloutManagement implements RolloutManagement {
this.repositoryProperties = repositoryProperties; this.repositoryProperties = repositoryProperties;
this.onlineDsAssignmentStrategy = new OnlineDsAssignmentStrategy(targetRepository, afterCommit, actionRepository, actionStatusRepository, 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) { public static String createRolloutLockKey(final String tenant) {

View File

@@ -12,13 +12,17 @@ package org.eclipse.hawkbit.repository.jpa.management;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.Function; import java.util.function.Function;
import org.apache.commons.collections4.ListUtils; import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.function.TriConsumer;
import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.RepositoryConstants; import org.eclipse.hawkbit.repository.RepositoryConstants;
import org.eclipse.hawkbit.repository.RepositoryProperties; 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.exception.InsufficientPermissionException;
import org.eclipse.hawkbit.repository.jpa.JpaManagementHelper; import org.eclipse.hawkbit.repository.jpa.JpaManagementHelper;
import org.eclipse.hawkbit.repository.jpa.acm.AccessController; import org.eclipse.hawkbit.repository.jpa.acm.AccessController;
@@ -48,9 +52,10 @@ class OfflineDsAssignmentStrategy extends AbstractDsAssignmentStrategy {
final AfterTransactionCommitExecutor afterCommit, final AfterTransactionCommitExecutor afterCommit,
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, 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, super(targetRepository, afterCommit, actionRepository, actionStatusRepository,
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties); quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler);
} }
@Override @Override

View File

@@ -13,18 +13,22 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.apache.commons.collections4.ListUtils; import org.apache.commons.collections4.ListUtils;
import org.apache.commons.lang3.function.TriConsumer;
import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.QuotaManagement;
import org.eclipse.hawkbit.repository.RepositoryProperties; import org.eclipse.hawkbit.repository.RepositoryProperties;
import org.eclipse.hawkbit.repository.event.EventPublisherHolder; import org.eclipse.hawkbit.repository.event.EventPublisherHolder;
import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent;
import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent;
import org.eclipse.hawkbit.repository.event.remote.TargetAssignDistributionSetEvent; 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.exception.InsufficientPermissionException;
import org.eclipse.hawkbit.repository.jpa.acm.AccessController; import org.eclipse.hawkbit.repository.jpa.acm.AccessController;
import org.eclipse.hawkbit.repository.jpa.configuration.Constants; import org.eclipse.hawkbit.repository.jpa.configuration.Constants;
@@ -54,9 +58,10 @@ class OnlineDsAssignmentStrategy extends AbstractDsAssignmentStrategy {
final AfterTransactionCommitExecutor afterCommit, final AfterTransactionCommitExecutor afterCommit,
final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository, final ActionRepository actionRepository, final ActionStatusRepository actionStatusRepository,
final QuotaManagement quotaManagement, final BooleanSupplier multiAssignmentsConfig, 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, super(targetRepository, afterCommit, actionRepository, actionStatusRepository,
quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties); quotaManagement, multiAssignmentsConfig, confirmationFlowConfig, repositoryProperties, maxAssignmentExceededHandler);
} }
public void sendDeploymentEvents(final long distributionSetId, final List<Action> actions) { public void sendDeploymentEvents(final long distributionSetId, final List<Action> actions) {

View File

@@ -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.JpaDistributionSet_;
import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModule; import org.eclipse.hawkbit.repository.jpa.model.JpaSoftwareModule;
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.jpa.model.JpaTarget_;
import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
@@ -42,6 +43,11 @@ public final class ActionSpecifications {
cb.equal(root.get(JpaAction_.active), true)); 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) { public static Specification<JpaAction> byTargetControllerId(final String controllerId) {
return (root, query, cb) -> cb.equal(root.get(JpaAction_.target).get(JpaTarget_.controllerId), 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) { public static Specification<JpaAction> byDistributionSetIdAndActiveAndStatusIsNot(final Long distributionSetId, final Action.Status status) {
return (root, query, cb) -> cb.and( return (root, query, cb) -> cb.and(
cb.equal(root.get(JpaAction_.distributionSet).get(AbstractJpaBaseEntity_.id), distributionSetId), cb.equal(root.get(JpaAction_.distributionSet).get(AbstractJpaBaseEntity_.id), distributionSetId),

View File

@@ -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.RUNNING;
import static org.eclipse.hawkbit.repository.model.Action.Status.WAIT_FOR_CONFIRMATION; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.AbstractMap.SimpleEntry; import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList; 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.ActionStatusFields;
import org.eclipse.hawkbit.repository.DistributionSetManagement; import org.eclipse.hawkbit.repository.DistributionSetManagement;
import org.eclipse.hawkbit.repository.DistributionSetTagManagement; 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.CancelTargetAssignmentEvent;
import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent; import org.eclipse.hawkbit.repository.event.remote.MultiActionAssignEvent;
import org.eclipse.hawkbit.repository.event.remote.MultiActionCancelEvent; 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.DistributionSet;
import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult; import org.eclipse.hawkbit.repository.model.DistributionSetAssignmentResult;
import org.eclipse.hawkbit.repository.model.DistributionSetType; 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.SoftwareModule;
import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.Target;
import org.eclipse.hawkbit.repository.model.TargetType; import org.eclipse.hawkbit.repository.model.TargetType;
@@ -1673,6 +1676,105 @@ class DeploymentManagementTest extends AbstractJpaIntegrationTest {
.isThrownBy(() -> deploymentManagement.assignDistributionSets(deploymentRequests)); .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( private List<DeploymentRequest> createAssignmentRequests(
final Collection<DistributionSet> distributionSets, final Collection<Target> targets, final int weight) { final Collection<DistributionSet> distributionSets, final Collection<Target> targets, final int weight) {
return createAssignmentRequests(distributionSets, targets, weight, false); return createAssignmentRequests(distributionSets, targets, weight, false);

View File

@@ -139,6 +139,10 @@ class ManagementSecurityTest extends AbstractJpaIntegrationTest {
method.getReturnType() != String.class) method.getReturnType() != String.class)
// jacoco adds some methods with bytecode instrumentation // jacoco adds some methods with bytecode instrumentation
.filter(method -> !"$jacocoInit".equals(method.getName())) .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))) .map(method -> Arguments.of(clazz, method)))
// consumes the stream because scan result couldn't be used after being closed // consumes the stream because scan result couldn't be used after being closed
.toList() .toList()