From 44e7a72be3b9f5bfa99df9b902a080923b71d61c Mon Sep 17 00:00:00 2001 From: Stanislav Trailov Date: Thu, 19 Oct 2023 09:58:46 +0300 Subject: [PATCH] Rollout retry (#1454) * Rollout retry mechanism initial commit Signed-off-by: Stanislav Trailov * Remove test target fields for filter query Signed-off-by: Stanislav Trailov * minor refactoring Signed-off-by: Stanislav Trailov * Fixes after review Signed-off-by: Stanislav Trailov * more refactoring after review Signed-off-by: Stanislav Trailov * skip compatibility check of dstype for retried rollout Signed-off-by: Stanislav Trailov * remove dsType from javadoc Signed-off-by: Stanislav Trailov --------- Signed-off-by: Stanislav Trailov --- .../hawkbit/repository/TargetManagement.java | 46 +++++++++ .../hawkbit/repository/RolloutHelper.java | 11 +++ .../repository/jpa/JpaRolloutExecutor.java | 20 +++- .../repository/jpa/JpaRolloutManagement.java | 57 +++++++++-- .../repository/jpa/JpaTargetManagement.java | 28 ++++++ .../specifications/TargetSpecifications.java | 13 +++ .../mgmt/rest/api/MgmtRolloutRestApi.java | 26 ++++- .../mgmt/rest/resource/MgmtRolloutMapper.java | 12 +++ .../rest/resource/MgmtRolloutResource.java | 25 ++++- .../resource/MgmtRolloutResourceTest.java | 99 +++++++++++++++++++ .../ui/rollout/rollout/RolloutGrid.java | 10 +- 11 files changed, 331 insertions(+), 16 deletions(-) diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetManagement.java index c7b28c2c0..2abab0717 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetManagement.java @@ -139,6 +139,21 @@ public interface TargetManagement { @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) long countByRsqlAndCompatible(@NotEmpty String rsqlParam, @NotNull Long dsTypeId); + /** + * Count all targets with failed actions for specific Rollout + * and that are compatible with the passed {@link DistributionSetType} + * and created after given timestamp + * + * @param rolloutId + * rolloutId of the rollout to be retried. + * @param dsTypeId + * ID of the {@link DistributionSetType} the targets need to be + * compatible with + * @return the found number of{@link Target}s + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) + long countByFailedInRollout(@NotEmpty String rolloutId, @NotNull Long dsTypeId); + /** * Count {@link TargetFilterQuery}s for given target filter query. * @@ -278,6 +293,23 @@ public interface TargetManagement { @NotEmpty Collection groups, @NotNull String rsqlParam, @NotNull DistributionSetType distributionSetType); + /** + * Finds all targets with failed actions for specific Rollout + * and that are not assigned to one of the retried {@link RolloutGroup}s and are + * compatible with the passed {@link DistributionSetType}. + * + * @param pageRequest + * the pageRequest to enhance the query for paging and sorting + * @param groups + * the list of {@link RolloutGroup}s + * @param rolloutId + * rolloutId of the rollout to be retried. + * @return a page of the found {@link Target}s + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) + Slice findByFailedRolloutAndNotInRolloutGroups(@NotNull Pageable pageRequest, + @NotEmpty Collection groups, @NotNull String rolloutId); + /** * Counts all targets for all the given parameter {@link TargetFilterQuery} * and that are not assigned to one of the {@link RolloutGroup}s and are @@ -296,6 +328,20 @@ public interface TargetManagement { long countByRsqlAndNotInRolloutGroupsAndCompatible(@NotEmpty Collection groups, @NotNull String rsqlParam, @NotNull DistributionSetType distributionSetType); + /** + * Counts all targets with failed actions for specific Rollout + * and that are not assigned to one of the {@link RolloutGroup}s and are + * compatible with the passed {@link DistributionSetType}. + * + * @param groups + * the list of {@link RolloutGroup}s + * @param rolloutId + * rolloutId of the rollout to be retried. + * @return count of the found {@link Target}s + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) + long countByFailedRolloutAndNotInRolloutGroups(@NotEmpty Collection groups, @NotNull String rolloutId); + /** * Finds all targets of the provided {@link RolloutGroup} that have no * Action for the RolloutGroup. diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/RolloutHelper.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/RolloutHelper.java index fa6409bed..9680949f6 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/RolloutHelper.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/RolloutHelper.java @@ -229,6 +229,9 @@ public final class RolloutHelper { if (StringUtils.isEmpty(group.getTargetFilterQuery())) { return baseFilter; } + if (isRolloutRetried(baseFilter)) { + return baseFilter; + } return concatAndTargetFilters(baseFilter, group.getTargetFilterQuery()); } @@ -253,4 +256,12 @@ public final class RolloutHelper { + rollout.getStatus().name().toLowerCase()); } } + + public static boolean isRolloutRetried(final String targetFilter) { + return targetFilter.contains("failedrollout"); + } + + public static String getIdFromRetriedTargetFilter(final String targetFilter) { + return targetFilter.substring("failedrollout==".length()); + } } 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 e23cf7c69..f235b729a 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 @@ -525,10 +525,18 @@ public class JpaRolloutExecutor implements RolloutExecutor { final List readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(), RolloutGroupStatus.READY, group); - final long targetsInGroupFilter = DeploymentHelper.runInNewTransaction(txManager, + long targetsInGroupFilter; + if (!RolloutHelper.isRolloutRetried(rollout.getTargetFilterQuery())) { + targetsInGroupFilter = DeploymentHelper.runInNewTransaction(txManager, "countAllTargetsByTargetFilterQueryAndNotInRolloutGroups", count -> targetManagement.countByRsqlAndNotInRolloutGroupsAndCompatible(readyGroups, groupTargetFilter, - rollout.getDistributionSet().getType())); + rollout.getDistributionSet().getType())); + } else { + targetsInGroupFilter = DeploymentHelper.runInNewTransaction(txManager, + "countByFailedRolloutAndNotInRolloutGroupsAndCompatible", + count -> targetManagement.countByFailedRolloutAndNotInRolloutGroups(readyGroups, + RolloutHelper.getIdFromRetriedTargetFilter(rollout.getTargetFilterQuery()))); + } final long expectedInGroup = Math .round((double) (group.getTargetPercentage() / 100) * (double) targetsInGroupFilter); final long currentlyInGroup = DeploymentHelper.runInNewTransaction(txManager, @@ -572,8 +580,14 @@ public class JpaRolloutExecutor implements RolloutExecutor { final PageRequest pageRequest = PageRequest.of(0, Math.toIntExact(limit)); final List readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(), RolloutGroupStatus.READY, group); - final Slice targets = targetManagement.findByTargetFilterQueryAndNotInRolloutGroupsAndCompatible( + Slice targets; + if (!RolloutHelper.isRolloutRetried(rollout.getTargetFilterQuery())) { + targets = targetManagement.findByTargetFilterQueryAndNotInRolloutGroupsAndCompatible( pageRequest, readyGroups, targetFilter, rollout.getDistributionSet().getType()); + } else { + targets = targetManagement.findByFailedRolloutAndNotInRolloutGroups( + pageRequest, readyGroups, RolloutHelper.getIdFromRetriedTargetFilter(rollout.getTargetFilterQuery())); + } createAssignmentOfTargetsToGroup(targets, group); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java index 0dee3ed46..5b3a0531c 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaRolloutManagement.java @@ -196,10 +196,20 @@ public class JpaRolloutManagement implements RolloutManagement { private JpaRollout createRollout(final JpaRollout rollout) { WeightValidationHelper.usingContext(systemSecurityContext, tenantConfigurationManagement).validate(rollout); - final Long totalTargets = targetManagement.countByRsqlAndCompatible(rollout.getTargetFilterQuery(), + long totalTargets; + String errMsg; + if (RolloutHelper.isRolloutRetried(rollout.getTargetFilterQuery())) { + totalTargets = targetManagement.countByFailedInRollout( + RolloutHelper.getIdFromRetriedTargetFilter(rollout.getTargetFilterQuery()), rollout.getDistributionSet().getType().getId()); + errMsg = "No failed targets in Rollout"; + } else { + totalTargets = targetManagement.countByRsqlAndCompatible(rollout.getTargetFilterQuery(), + rollout.getDistributionSet().getType().getId()); + errMsg = "Rollout does not match any existing targets"; + } if (totalTargets == 0) { - throw new ValidationException("Rollout does not match any existing targets"); + throw new ValidationException(errMsg); } rollout.setTotalTargets(totalTargets); return rolloutRepository.save(rollout); @@ -618,10 +628,19 @@ public class JpaRolloutManagement implements RolloutManagement { private RolloutGroupsValidation validateTargetsInGroups(final List groups, final String baseFilter, final long totalTargets, final Long dsTypeId) { final List groupTargetCounts = new ArrayList<>(groups.size()); - final Map targetFilterCounts = groups.stream() + Map targetFilterCounts; + if (!RolloutHelper.isRolloutRetried(baseFilter)) { + targetFilterCounts = groups.stream() .map(group -> RolloutHelper.getGroupTargetFilter(baseFilter, group)).distinct() .collect(Collectors.toMap(Function.identity(), - groupTargetFilter -> targetManagement.countByRsqlAndCompatible(groupTargetFilter, dsTypeId))); + groupTargetFilter -> targetManagement.countByRsqlAndCompatible(groupTargetFilter, dsTypeId))); + } else { + targetFilterCounts = groups.stream() + .map(group -> RolloutHelper.getGroupTargetFilter(baseFilter, group)).distinct() + .collect(Collectors.toMap(Function.identity(), + groupTargetFilter -> targetManagement.countByFailedInRollout( + RolloutHelper.getIdFromRetriedTargetFilter(baseFilter), dsTypeId))); + } long unusedTargetsCount = 0; @@ -675,8 +694,11 @@ public class JpaRolloutManagement implements RolloutManagement { private long calculateRemainingTargets(final List groups, final String targetFilter, final Long createdAt, final Long dsTypeId) { - final String baseFilter = RolloutHelper.getTargetFilterQuery(targetFilter, createdAt); - final long totalTargets = targetManagement.countByRsqlAndCompatible(baseFilter, dsTypeId); + + final TargetCount targets = calculateTargets(targetFilter, createdAt, dsTypeId); + long totalTargets = targets.total(); + final String baseFilter = targets.filter(); + if (totalTargets == 0) { throw new ConstraintDeclarationException("Rollout target filter does not match any targets"); } @@ -691,9 +713,9 @@ public class JpaRolloutManagement implements RolloutManagement { public ListenableFuture validateTargetsInGroups(final List groups, final String targetFilter, final Long createdAt, final Long dsTypeId) { - final String baseFilter = RolloutHelper.getTargetFilterQuery(targetFilter, createdAt); - - final long totalTargets = targetManagement.countByRsqlAndCompatible(baseFilter, dsTypeId); + final TargetCount targets = calculateTargets(targetFilter, createdAt, dsTypeId); + long totalTargets = targets.total(); + final String baseFilter = targets.filter(); if (totalTargets == 0) { throw new ConstraintDeclarationException("Rollout target filter does not match any targets"); @@ -730,4 +752,21 @@ public class JpaRolloutManagement implements RolloutManagement { startNextRolloutGroupAction.exec(rollout, latestRunning); } + private TargetCount calculateTargets(final String targetFilter, final Long createdAt, final Long dsTypeId) { + String baseFilter; + long totalTargets; + if (!RolloutHelper.isRolloutRetried(targetFilter)) { + baseFilter = RolloutHelper.getTargetFilterQuery(targetFilter, createdAt); + totalTargets = targetManagement.countByRsqlAndCompatible(baseFilter, dsTypeId); + } else { + totalTargets = targetManagement.countByFailedInRollout( + RolloutHelper.getIdFromRetriedTargetFilter(targetFilter), dsTypeId); + baseFilter = targetFilter; + } + + return new TargetCount(totalTargets, baseFilter); + } + + private record TargetCount(long total, String filter) {} + } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java index f2b648a9a..67dd5d10c 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetManagement.java @@ -693,6 +693,17 @@ public class JpaTargetManagement implements TargetManagement { return JpaManagementHelper.findAllWithoutCountBySpec(targetRepository, pageRequest, specList); } + @Override + public Slice findByFailedRolloutAndNotInRolloutGroups(Pageable pageRequest, Collection groups, + String rolloutId) { + final List> specList = Arrays.asList( + TargetSpecifications.failedActionsForRollout(rolloutId), + TargetSpecifications.isNotInRolloutGroups(groups) + ); + + return JpaManagementHelper.findAllWithCountBySpec(targetRepository, pageRequest, specList); + } + @Override public Slice findByInRolloutGroupWithoutAction(final Pageable pageRequest, final long group) { if (!rolloutGroupRepository.existsById(group)) { @@ -715,6 +726,15 @@ public class JpaTargetManagement implements TargetManagement { return JpaManagementHelper.countBySpec(targetRepository, specList); } + @Override + public long countByFailedRolloutAndNotInRolloutGroups(Collection groups, String rolloutId) { + final List> specList = Arrays.asList( + TargetSpecifications.failedActionsForRollout(rolloutId), + TargetSpecifications.isNotInRolloutGroups(groups)); + + return JpaManagementHelper.countBySpec(targetRepository, specList); + } + @Override public long countByRsqlAndNonDSAndCompatible(final long distributionSetId, final String targetFilterQuery) { final DistributionSet jpaDistributionSet = distributionSetManagement.getOrElseThrowException(distributionSetId); @@ -796,6 +816,14 @@ public class JpaTargetManagement implements TargetManagement { return JpaManagementHelper.countBySpec(targetRepository, specList); } + @Override + public long countByFailedInRollout(final String rolloutId, final Long dsTypeId) { + final List> specList = List.of( + TargetSpecifications.failedActionsForRollout(rolloutId)); + + return JpaManagementHelper.countBySpec(targetRepository, specList); + } + @Override public Optional get(final long id) { return targetRepository.findById(id).map(t -> t); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/TargetSpecifications.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/TargetSpecifications.java index d3d89ba2e..7d16666fa 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/TargetSpecifications.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/TargetSpecifications.java @@ -42,8 +42,10 @@ import org.eclipse.hawkbit.repository.jpa.model.JpaTargetType_; import org.eclipse.hawkbit.repository.jpa.model.JpaTarget_; import org.eclipse.hawkbit.repository.jpa.model.RolloutTargetGroup; import org.eclipse.hawkbit.repository.jpa.model.RolloutTargetGroup_; +import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSetType; +import org.eclipse.hawkbit.repository.model.Rollout; import org.eclipse.hawkbit.repository.model.RolloutGroup; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetTag; @@ -611,4 +613,15 @@ public final class TargetSpecifications { }; } + public static Specification failedActionsForRollout(final String rolloutId) { + return (targetRoot, query, cb) -> { + Join targetActions = + targetRoot.join("actions"); + + return cb.and( + cb.equal(targetActions.get("rollout").get("id"), rolloutId), + cb.equal(targetActions.get("status"), Action.Status.ERROR)); + }; + } + } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRolloutRestApi.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRolloutRestApi.java index 6de5fb3a6..3fe9355c4 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRolloutRestApi.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRolloutRestApi.java @@ -286,7 +286,7 @@ public interface MgmtRolloutRestApi { * @param representationModeParam * the representation mode parameter specifying whether a compact * or a full representation shall be returned - * + * * @return a list of all rollout groups referred to a rollout for a defined * or default page request with status OK. The response is always * paged. In any failure the JsonResponseExceptionHandler is @@ -404,4 +404,28 @@ public interface MgmtRolloutRestApi { @PostMapping(value = MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING + "/{rolloutId}/triggerNextGroup", produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) ResponseEntity triggerNextGroup(@PathVariable("rolloutId") Long rolloutId); + + /** + * Handles the POST request to retry a rollout + * + * @param rolloutId + * the ID of the rollout to be retried. + * @return OK response (200). In case of any exception the corresponding + * errors occur. + */ + @Operation(summary = "Retry a rollout", description = "Handles the POST request of retrying a rollout. Required Permission: CREATE_ROLLOUT") + @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 = "Rollout 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))) + }) + @PostMapping(value = MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING + "/{rolloutId}/retry", produces = { + MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE}) + ResponseEntity retryRollout(@PathVariable("rolloutId") final String rolloutId); + } diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java index c593c6017..7ed3d683d 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutMapper.java @@ -132,6 +132,18 @@ final class MgmtRolloutMapper { .weight(restRequest.getWeight()); } + static RolloutCreate fromRetriedRollout(final EntityFactory entityFactory, final Rollout rollout) { + return entityFactory.rollout().create() + .name(rollout.getName().concat("_retry")) + .description(rollout.getDescription()) + .set(rollout.getDistributionSet()) + .targetFilterQuery("failedrollout==".concat(String.valueOf(rollout.getId()))) + .actionType(rollout.getActionType()) + .forcedTime(rollout.getForcedTime()) + .startAt(rollout.getStartAt()) + .weight(null); + } + static RolloutGroupCreate fromRequest(final EntityFactory entityFactory, final MgmtRolloutGroup restRequest) { return entityFactory.rolloutGroup().create().name(restRequest.getName()) diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java index d85a76bdc..3048fae05 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResource.java @@ -31,6 +31,7 @@ import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.RolloutGroupManagement; import org.eclipse.hawkbit.repository.RolloutManagement; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; +import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.TenantConfigurationManagement; import org.eclipse.hawkbit.repository.builder.RolloutCreate; import org.eclipse.hawkbit.repository.builder.RolloutGroupCreate; @@ -39,6 +40,7 @@ import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Rollout; import org.eclipse.hawkbit.repository.model.RolloutGroup; +import org.eclipse.hawkbit.repository.model.RolloutGroupConditionBuilder; import org.eclipse.hawkbit.repository.model.RolloutGroupConditions; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.security.SystemSecurityContext; @@ -47,7 +49,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -311,6 +312,28 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi { return ResponseEntity.ok().build(); } + @Override + public ResponseEntity retryRollout(final String rolloutId) { + final Rollout rolloutForRetry = this.rolloutManagement.get(Long.parseLong(rolloutId)) + .orElseThrow(EntityNotFoundException::new); + + if (rolloutForRetry.isDeleted()) { + throw new EntityNotFoundException(Rollout.class, rolloutId); + } + + if (!rolloutForRetry.getStatus().equals(Rollout.RolloutStatus.FINISHED)) { + throw new ValidationException("Rollout must be finished in order to be retried!"); + } + + final RolloutCreate create = MgmtRolloutMapper.fromRetriedRollout(entityFactory, rolloutForRetry); + final RolloutGroupConditions groupConditions = new RolloutGroupConditionBuilder().withDefaults().build(); + + final Rollout retriedRollout = rolloutManagement.create(create, 1, false, + groupConditions); + + return ResponseEntity.status(HttpStatus.CREATED).body(MgmtRolloutMapper.toResponseRollout(retriedRollout, true)); + } + private static MgmtRepresentationMode parseRepresentationMode(final String representationModeParam) { return MgmtRepresentationMode.fromValue(representationModeParam).orElseGet(() -> { // no need for a 400, just apply a safe fallback diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java index addf0f1ed..fea2fb06b 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtRolloutResourceTest.java @@ -25,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; @@ -55,6 +56,7 @@ import org.eclipse.hawkbit.repository.test.util.WithSpringAuthorityRule; import org.eclipse.hawkbit.repository.test.util.WithUser; import org.eclipse.hawkbit.rest.util.JsonBuilder; import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter; +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; @@ -1577,6 +1579,99 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { } + @Test + @Description("Retry rollout test scenario") + public void retryRolloutTest() throws Exception { + + final DistributionSet dsA = testdataFactory.createDistributionSet(""); + final List successTargets = testdataFactory.createTargets("retryRolloutTargetSuccess-", 6); + final List failedTargets = testdataFactory.createTargets("retryRolloutTargetFailed-", 4); + + final List allTargets = new ArrayList<>(successTargets); + allTargets.addAll(failedTargets); + + postRollout("rolloutToBeRetried", 1, dsA.getId(), "id==retryRolloutTarget*", 10, Action.ActionType.FORCED); + + Rollout rollout = rolloutManagement.getByName("rolloutToBeRetried").orElseThrow(); + + // no scheduler so invoke here + rolloutHandler.handleAll(); + rolloutManagement.start(rollout.getId()); + // no scheduler so invoke here + rolloutHandler.handleAll(); + + + testdataFactory.sendUpdateActionStatusToTargets(successTargets, Status.FINISHED, "Finished successfully!"); + testdataFactory.sendUpdateActionStatusToTargets(failedTargets, Status.ERROR, "Finished error!"); + + rolloutHandler.handleAll(); + + for (Target target : allTargets) { + final List actions = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent(); + for (Action action : actions) { + if (action.getTarget().getControllerId().startsWith("retryRolloutTargetFailed")) { + Assertions.assertEquals(Status.ERROR, action.getStatus()); + } else { + Assertions.assertEquals(Status.FINISHED, action.getStatus()); + } + Assertions.assertEquals(rollout.getId(), action.getRollout().getId()); + } + } + + //retry rollout + mvc.perform(post("/rest/v1/rollouts/{rolloutId}/retry", rollout.getId())).andDo(MockMvcResultPrinter.print()) + .andExpect(status().is(201)); + + //search for _retried suffix + Rollout retriedRollout = rolloutManagement.getByName(rollout.getName() + "_retry").orElseThrow(); + //assert 4 targets involved + rolloutHandler.handleAll(); + + rolloutManagement.start(retriedRollout.getId()); + rolloutHandler.handleAll(); + + for (Target target : failedTargets) { + // for failed targets - check for 2 actions - one from old rollout and one from the retried + List actions = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent(); + Assertions.assertEquals(2, actions.size()); + Assertions.assertEquals(Status.ERROR, actions.get(0).getStatus()); + Assertions.assertEquals(rollout.getId(), actions.get(0).getRollout().getId()); + Assertions.assertEquals(Status.RUNNING, actions.get(1).getStatus()); + Assertions.assertEquals(retriedRollout.getId(), actions.get(1).getRollout().getId()); + } + + for (Target target : successTargets) { + //ensure no other actions from the success targets are created + List actions = deploymentManagement.findActionsByTarget(target.getControllerId(), PAGE).getContent(); + Assertions.assertEquals(1, actions.size()); + Assertions.assertEquals(rollout.getId(), actions.get(0).getRollout().getId()); + } + } + + @Test + @Description("Retrying a running rollout should not be allowed.") + public void retryNotFinishedRolloutShouldNotBeAllowed() throws Exception { + final DistributionSet dsA = testdataFactory.createDistributionSet(""); + testdataFactory.createTargets("retryRolloutTarget-", 10); + postRollout("rolloutToBeRetried", 1, dsA.getId(), "id==retryRolloutTarget*", 10, Action.ActionType.FORCED); + Rollout rollout = rolloutManagement.getByName("rolloutToBeRetried").orElseThrow(); + // no scheduler so invoke here + rolloutHandler.handleAll(); + rolloutManagement.start(rollout.getId()); + // no scheduler so invoke here + rolloutHandler.handleAll(); + + mvc.perform(post("/rest/v1/rollouts/{rolloutId}/retry", rollout.getId())).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()); + } + + @Test + @Description("Retrying a non-existing rollout should lead to NOT FOUND.") + public void retryNonExistingRolloutShouldLeadToNotFound() throws Exception { + mvc.perform(post("/rest/v1/rollouts/{rolloutId}/retry", 6782623)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isNotFound()); + } + private void triggerNextGroupAndExpect(final Rollout rollout, final ResultMatcher expect) throws Exception { mvc.perform(post("/rest/v1/rollouts/{rolloutId}/triggerNextGroup", rollout.getId())) .andDo(MockMvcResultPrinter.print()).andExpect(expect); @@ -1596,6 +1691,10 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest { retrieveAndCompareRolloutsContent(dsA, urlTemplate, isFullRepresentation, false, null, null); } + private Rollout getRollout(final long rolloutId) { + return rolloutManagement.get(rolloutId).orElseThrow(NoSuchElementException::new); + } + private void retrieveAndCompareRolloutsContent(final DistributionSet dsA, final String urlTemplate, final boolean isFullRepresentation, final boolean isStartTypeScheduled, final Long startAt, final Long forcetime) throws Exception { diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/RolloutGrid.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/RolloutGrid.java index 71fab08cd..d3362ca9c 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/RolloutGrid.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/RolloutGrid.java @@ -224,6 +224,10 @@ public class RolloutGrid extends AbstractGrid { return isDeletionAllowed(status) && status != RolloutStatus.CREATING; } + private static boolean isRolloutRetried(final String targetFilter) { + return targetFilter.contains("failedrollout"); + } + private static boolean isEditingAllowed(final RolloutStatus status) { final List statesThatAllowEditing = Arrays.asList(RolloutStatus.PAUSED, RolloutStatus.READY, RolloutStatus.RUNNING, RolloutStatus.STARTING, RolloutStatus.STOPPED); @@ -361,14 +365,16 @@ public class RolloutGrid extends AbstractGrid { clickEvent -> updateRollout(rollout), VaadinIcons.EDIT, UIMessageIdProvider.TOOLTIP_ROLLOUT_UPDATE, SPUIStyleDefinitions.STATUS_ICON_NEUTRAL, UIComponentIdProvider.ROLLOUT_UPDATE_BUTTON_ID + "." + rollout.getId(), - permissionChecker.hasRolloutUpdatePermission() && isEditingAllowed(rollout.getStatus())); + permissionChecker.hasRolloutUpdatePermission() && isEditingAllowed(rollout.getStatus()) + && !isRolloutRetried(rollout.getTargetFilterQuery())); actionColumns.add(GridComponentBuilder.addIconColumn(this, updateButton, UPDATE_BUTTON_ID, null)); final ValueProvider copyButton = rollout -> GridComponentBuilder.buildActionButton(i18n, clickEvent -> copyRollout(rollout), VaadinIcons.COPY, UIMessageIdProvider.TOOLTIP_ROLLOUT_COPY, SPUIStyleDefinitions.STATUS_ICON_NEUTRAL, UIComponentIdProvider.ROLLOUT_COPY_BUTTON_ID + "." + rollout.getId(), - permissionChecker.hasRolloutCreatePermission() && isCopyingAllowed(rollout.getStatus())); + permissionChecker.hasRolloutCreatePermission() && isCopyingAllowed(rollout.getStatus()) + && !isRolloutRetried(rollout.getTargetFilterQuery())); actionColumns.add(GridComponentBuilder.addIconColumn(this, copyButton, COPY_BUTTON_ID, null)); actionColumns.add(GridComponentBuilder.addDeleteColumn(this, i18n, DELETE_BUTTON_ID, rolloutDeleteSupport,