Rollout retry (#1454)
* Rollout retry mechanism initial commit Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * Remove test target fields for filter query Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * minor refactoring Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * Fixes after review Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * more refactoring after review Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * skip compatibility check of dstype for retried rollout Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> * remove dsType from javadoc Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io> --------- Signed-off-by: Stanislav Trailov <Stanislav.Trailov@bosch.io>
This commit is contained in:
committed by
GitHub
parent
22ce1c27a4
commit
44e7a72be3
@@ -139,6 +139,21 @@ public interface TargetManagement {
|
|||||||
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET)
|
@PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET)
|
||||||
long countByRsqlAndCompatible(@NotEmpty String rsqlParam, @NotNull Long dsTypeId);
|
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.
|
* Count {@link TargetFilterQuery}s for given target filter query.
|
||||||
*
|
*
|
||||||
@@ -278,6 +293,23 @@ public interface TargetManagement {
|
|||||||
@NotEmpty Collection<Long> groups, @NotNull String rsqlParam,
|
@NotEmpty Collection<Long> groups, @NotNull String rsqlParam,
|
||||||
@NotNull DistributionSetType distributionSetType);
|
@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<Target> findByFailedRolloutAndNotInRolloutGroups(@NotNull Pageable pageRequest,
|
||||||
|
@NotEmpty Collection<Long> groups, @NotNull String rolloutId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts all targets for all the given parameter {@link TargetFilterQuery}
|
* Counts all targets for all the given parameter {@link TargetFilterQuery}
|
||||||
* and that are not assigned to one of the {@link RolloutGroup}s and are
|
* 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<Long> groups, @NotNull String rsqlParam,
|
long countByRsqlAndNotInRolloutGroupsAndCompatible(@NotEmpty Collection<Long> groups, @NotNull String rsqlParam,
|
||||||
@NotNull DistributionSetType distributionSetType);
|
@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<Long> groups, @NotNull String rolloutId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds all targets of the provided {@link RolloutGroup} that have no
|
* Finds all targets of the provided {@link RolloutGroup} that have no
|
||||||
* Action for the RolloutGroup.
|
* Action for the RolloutGroup.
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ public final class RolloutHelper {
|
|||||||
if (StringUtils.isEmpty(group.getTargetFilterQuery())) {
|
if (StringUtils.isEmpty(group.getTargetFilterQuery())) {
|
||||||
return baseFilter;
|
return baseFilter;
|
||||||
}
|
}
|
||||||
|
if (isRolloutRetried(baseFilter)) {
|
||||||
|
return baseFilter;
|
||||||
|
}
|
||||||
return concatAndTargetFilters(baseFilter, group.getTargetFilterQuery());
|
return concatAndTargetFilters(baseFilter, group.getTargetFilterQuery());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,4 +256,12 @@ public final class RolloutHelper {
|
|||||||
+ rollout.getStatus().name().toLowerCase());
|
+ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,10 +525,18 @@ public class JpaRolloutExecutor implements RolloutExecutor {
|
|||||||
final List<Long> readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(),
|
final List<Long> readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(),
|
||||||
RolloutGroupStatus.READY, group);
|
RolloutGroupStatus.READY, group);
|
||||||
|
|
||||||
final long targetsInGroupFilter = DeploymentHelper.runInNewTransaction(txManager,
|
long targetsInGroupFilter;
|
||||||
|
if (!RolloutHelper.isRolloutRetried(rollout.getTargetFilterQuery())) {
|
||||||
|
targetsInGroupFilter = DeploymentHelper.runInNewTransaction(txManager,
|
||||||
"countAllTargetsByTargetFilterQueryAndNotInRolloutGroups",
|
"countAllTargetsByTargetFilterQueryAndNotInRolloutGroups",
|
||||||
count -> targetManagement.countByRsqlAndNotInRolloutGroupsAndCompatible(readyGroups, groupTargetFilter,
|
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
|
final long expectedInGroup = Math
|
||||||
.round((double) (group.getTargetPercentage() / 100) * (double) targetsInGroupFilter);
|
.round((double) (group.getTargetPercentage() / 100) * (double) targetsInGroupFilter);
|
||||||
final long currentlyInGroup = DeploymentHelper.runInNewTransaction(txManager,
|
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 PageRequest pageRequest = PageRequest.of(0, Math.toIntExact(limit));
|
||||||
final List<Long> readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(),
|
final List<Long> readyGroups = RolloutHelper.getGroupsByStatusIncludingGroup(rollout.getRolloutGroups(),
|
||||||
RolloutGroupStatus.READY, group);
|
RolloutGroupStatus.READY, group);
|
||||||
final Slice<Target> targets = targetManagement.findByTargetFilterQueryAndNotInRolloutGroupsAndCompatible(
|
Slice<Target> targets;
|
||||||
|
if (!RolloutHelper.isRolloutRetried(rollout.getTargetFilterQuery())) {
|
||||||
|
targets = targetManagement.findByTargetFilterQueryAndNotInRolloutGroupsAndCompatible(
|
||||||
pageRequest, readyGroups, targetFilter, rollout.getDistributionSet().getType());
|
pageRequest, readyGroups, targetFilter, rollout.getDistributionSet().getType());
|
||||||
|
} else {
|
||||||
|
targets = targetManagement.findByFailedRolloutAndNotInRolloutGroups(
|
||||||
|
pageRequest, readyGroups, RolloutHelper.getIdFromRetriedTargetFilter(rollout.getTargetFilterQuery()));
|
||||||
|
}
|
||||||
|
|
||||||
createAssignmentOfTargetsToGroup(targets, group);
|
createAssignmentOfTargetsToGroup(targets, group);
|
||||||
|
|
||||||
|
|||||||
@@ -196,10 +196,20 @@ public class JpaRolloutManagement implements RolloutManagement {
|
|||||||
|
|
||||||
private JpaRollout createRollout(final JpaRollout rollout) {
|
private JpaRollout createRollout(final JpaRollout rollout) {
|
||||||
WeightValidationHelper.usingContext(systemSecurityContext, tenantConfigurationManagement).validate(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());
|
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) {
|
if (totalTargets == 0) {
|
||||||
throw new ValidationException("Rollout does not match any existing targets");
|
throw new ValidationException(errMsg);
|
||||||
}
|
}
|
||||||
rollout.setTotalTargets(totalTargets);
|
rollout.setTotalTargets(totalTargets);
|
||||||
return rolloutRepository.save(rollout);
|
return rolloutRepository.save(rollout);
|
||||||
@@ -618,10 +628,19 @@ public class JpaRolloutManagement implements RolloutManagement {
|
|||||||
private RolloutGroupsValidation validateTargetsInGroups(final List<RolloutGroup> groups, final String baseFilter,
|
private RolloutGroupsValidation validateTargetsInGroups(final List<RolloutGroup> groups, final String baseFilter,
|
||||||
final long totalTargets, final Long dsTypeId) {
|
final long totalTargets, final Long dsTypeId) {
|
||||||
final List<Long> groupTargetCounts = new ArrayList<>(groups.size());
|
final List<Long> groupTargetCounts = new ArrayList<>(groups.size());
|
||||||
final Map<String, Long> targetFilterCounts = groups.stream()
|
Map<String, Long> targetFilterCounts;
|
||||||
|
if (!RolloutHelper.isRolloutRetried(baseFilter)) {
|
||||||
|
targetFilterCounts = groups.stream()
|
||||||
.map(group -> RolloutHelper.getGroupTargetFilter(baseFilter, group)).distinct()
|
.map(group -> RolloutHelper.getGroupTargetFilter(baseFilter, group)).distinct()
|
||||||
.collect(Collectors.toMap(Function.identity(),
|
.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;
|
long unusedTargetsCount = 0;
|
||||||
|
|
||||||
@@ -675,8 +694,11 @@ public class JpaRolloutManagement implements RolloutManagement {
|
|||||||
|
|
||||||
private long calculateRemainingTargets(final List<RolloutGroup> groups, final String targetFilter,
|
private long calculateRemainingTargets(final List<RolloutGroup> groups, final String targetFilter,
|
||||||
final Long createdAt, final Long dsTypeId) {
|
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) {
|
if (totalTargets == 0) {
|
||||||
throw new ConstraintDeclarationException("Rollout target filter does not match any targets");
|
throw new ConstraintDeclarationException("Rollout target filter does not match any targets");
|
||||||
}
|
}
|
||||||
@@ -691,9 +713,9 @@ public class JpaRolloutManagement implements RolloutManagement {
|
|||||||
public ListenableFuture<RolloutGroupsValidation> validateTargetsInGroups(final List<RolloutGroupCreate> groups,
|
public ListenableFuture<RolloutGroupsValidation> validateTargetsInGroups(final List<RolloutGroupCreate> groups,
|
||||||
final String targetFilter, final Long createdAt, final Long dsTypeId) {
|
final String targetFilter, final Long createdAt, final Long dsTypeId) {
|
||||||
|
|
||||||
final String baseFilter = RolloutHelper.getTargetFilterQuery(targetFilter, createdAt);
|
final TargetCount targets = calculateTargets(targetFilter, createdAt, dsTypeId);
|
||||||
|
long totalTargets = targets.total();
|
||||||
final long totalTargets = targetManagement.countByRsqlAndCompatible(baseFilter, dsTypeId);
|
final String baseFilter = targets.filter();
|
||||||
|
|
||||||
if (totalTargets == 0) {
|
if (totalTargets == 0) {
|
||||||
throw new ConstraintDeclarationException("Rollout target filter does not match any targets");
|
throw new ConstraintDeclarationException("Rollout target filter does not match any targets");
|
||||||
@@ -730,4 +752,21 @@ public class JpaRolloutManagement implements RolloutManagement {
|
|||||||
startNextRolloutGroupAction.exec(rollout, latestRunning);
|
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) {}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -693,6 +693,17 @@ public class JpaTargetManagement implements TargetManagement {
|
|||||||
return JpaManagementHelper.findAllWithoutCountBySpec(targetRepository, pageRequest, specList);
|
return JpaManagementHelper.findAllWithoutCountBySpec(targetRepository, pageRequest, specList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Slice<Target> findByFailedRolloutAndNotInRolloutGroups(Pageable pageRequest, Collection<Long> groups,
|
||||||
|
String rolloutId) {
|
||||||
|
final List<Specification<JpaTarget>> specList = Arrays.asList(
|
||||||
|
TargetSpecifications.failedActionsForRollout(rolloutId),
|
||||||
|
TargetSpecifications.isNotInRolloutGroups(groups)
|
||||||
|
);
|
||||||
|
|
||||||
|
return JpaManagementHelper.findAllWithCountBySpec(targetRepository, pageRequest, specList);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Slice<Target> findByInRolloutGroupWithoutAction(final Pageable pageRequest, final long group) {
|
public Slice<Target> findByInRolloutGroupWithoutAction(final Pageable pageRequest, final long group) {
|
||||||
if (!rolloutGroupRepository.existsById(group)) {
|
if (!rolloutGroupRepository.existsById(group)) {
|
||||||
@@ -715,6 +726,15 @@ public class JpaTargetManagement implements TargetManagement {
|
|||||||
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countByFailedRolloutAndNotInRolloutGroups(Collection<Long> groups, String rolloutId) {
|
||||||
|
final List<Specification<JpaTarget>> specList = Arrays.asList(
|
||||||
|
TargetSpecifications.failedActionsForRollout(rolloutId),
|
||||||
|
TargetSpecifications.isNotInRolloutGroups(groups));
|
||||||
|
|
||||||
|
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countByRsqlAndNonDSAndCompatible(final long distributionSetId, final String targetFilterQuery) {
|
public long countByRsqlAndNonDSAndCompatible(final long distributionSetId, final String targetFilterQuery) {
|
||||||
final DistributionSet jpaDistributionSet = distributionSetManagement.getOrElseThrowException(distributionSetId);
|
final DistributionSet jpaDistributionSet = distributionSetManagement.getOrElseThrowException(distributionSetId);
|
||||||
@@ -796,6 +816,14 @@ public class JpaTargetManagement implements TargetManagement {
|
|||||||
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countByFailedInRollout(final String rolloutId, final Long dsTypeId) {
|
||||||
|
final List<Specification<JpaTarget>> specList = List.of(
|
||||||
|
TargetSpecifications.failedActionsForRollout(rolloutId));
|
||||||
|
|
||||||
|
return JpaManagementHelper.countBySpec(targetRepository, specList);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Target> get(final long id) {
|
public Optional<Target> get(final long id) {
|
||||||
return targetRepository.findById(id).map(t -> t);
|
return targetRepository.findById(id).map(t -> t);
|
||||||
|
|||||||
@@ -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.JpaTarget_;
|
||||||
import org.eclipse.hawkbit.repository.jpa.model.RolloutTargetGroup;
|
import org.eclipse.hawkbit.repository.jpa.model.RolloutTargetGroup;
|
||||||
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.DistributionSet;
|
||||||
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.RolloutGroup;
|
import org.eclipse.hawkbit.repository.model.RolloutGroup;
|
||||||
import org.eclipse.hawkbit.repository.model.Target;
|
import org.eclipse.hawkbit.repository.model.Target;
|
||||||
import org.eclipse.hawkbit.repository.model.TargetTag;
|
import org.eclipse.hawkbit.repository.model.TargetTag;
|
||||||
@@ -611,4 +613,15 @@ public final class TargetSpecifications {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Specification<JpaTarget> failedActionsForRollout(final String rolloutId) {
|
||||||
|
return (targetRoot, query, cb) -> {
|
||||||
|
Join<JpaTarget, Action> targetActions =
|
||||||
|
targetRoot.join("actions");
|
||||||
|
|
||||||
|
return cb.and(
|
||||||
|
cb.equal(targetActions.get("rollout").get("id"), rolloutId),
|
||||||
|
cb.equal(targetActions.get("status"), Action.Status.ERROR));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,4 +404,28 @@ public interface MgmtRolloutRestApi {
|
|||||||
@PostMapping(value = MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING + "/{rolloutId}/triggerNextGroup", produces = {
|
@PostMapping(value = MgmtRestConstants.ROLLOUT_V1_REQUEST_MAPPING + "/{rolloutId}/triggerNextGroup", produces = {
|
||||||
MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
|
MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE })
|
||||||
ResponseEntity<Void> triggerNextGroup(@PathVariable("rolloutId") Long rolloutId);
|
ResponseEntity<Void> 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<MgmtRolloutResponseBody> retryRollout(@PathVariable("rolloutId") final String rolloutId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ final class MgmtRolloutMapper {
|
|||||||
.weight(restRequest.getWeight());
|
.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) {
|
static RolloutGroupCreate fromRequest(final EntityFactory entityFactory, final MgmtRolloutGroup restRequest) {
|
||||||
|
|
||||||
return entityFactory.rolloutGroup().create().name(restRequest.getName())
|
return entityFactory.rolloutGroup().create().name(restRequest.getName())
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import org.eclipse.hawkbit.repository.OffsetBasedPageRequest;
|
|||||||
import org.eclipse.hawkbit.repository.RolloutGroupManagement;
|
import org.eclipse.hawkbit.repository.RolloutGroupManagement;
|
||||||
import org.eclipse.hawkbit.repository.RolloutManagement;
|
import org.eclipse.hawkbit.repository.RolloutManagement;
|
||||||
import org.eclipse.hawkbit.repository.TargetFilterQueryManagement;
|
import org.eclipse.hawkbit.repository.TargetFilterQueryManagement;
|
||||||
|
import org.eclipse.hawkbit.repository.TargetManagement;
|
||||||
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
|
import org.eclipse.hawkbit.repository.TenantConfigurationManagement;
|
||||||
import org.eclipse.hawkbit.repository.builder.RolloutCreate;
|
import org.eclipse.hawkbit.repository.builder.RolloutCreate;
|
||||||
import org.eclipse.hawkbit.repository.builder.RolloutGroupCreate;
|
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.DistributionSet;
|
||||||
import org.eclipse.hawkbit.repository.model.Rollout;
|
import org.eclipse.hawkbit.repository.model.Rollout;
|
||||||
import org.eclipse.hawkbit.repository.model.RolloutGroup;
|
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.RolloutGroupConditions;
|
||||||
import org.eclipse.hawkbit.repository.model.Target;
|
import org.eclipse.hawkbit.repository.model.Target;
|
||||||
import org.eclipse.hawkbit.security.SystemSecurityContext;
|
import org.eclipse.hawkbit.security.SystemSecurityContext;
|
||||||
@@ -47,7 +49,6 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Slice;
|
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -311,6 +312,28 @@ public class MgmtRolloutResource implements MgmtRolloutRestApi {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<MgmtRolloutResponseBody> 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) {
|
private static MgmtRepresentationMode parseRepresentationMode(final String representationModeParam) {
|
||||||
return MgmtRepresentationMode.fromValue(representationModeParam).orElseGet(() -> {
|
return MgmtRepresentationMode.fromValue(representationModeParam).orElseGet(() -> {
|
||||||
// no need for a 400, just apply a safe fallback
|
// no need for a 400, just apply a safe fallback
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.NoSuchElementException;
|
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.repository.test.util.WithUser;
|
||||||
import org.eclipse.hawkbit.rest.util.JsonBuilder;
|
import org.eclipse.hawkbit.rest.util.JsonBuilder;
|
||||||
import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter;
|
import org.eclipse.hawkbit.rest.util.MockMvcResultPrinter;
|
||||||
|
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;
|
||||||
@@ -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<Target> successTargets = testdataFactory.createTargets("retryRolloutTargetSuccess-", 6);
|
||||||
|
final List<Target> failedTargets = testdataFactory.createTargets("retryRolloutTargetFailed-", 4);
|
||||||
|
|
||||||
|
final List<Target> 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<Action> 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<Action> 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<Action> 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 {
|
private void triggerNextGroupAndExpect(final Rollout rollout, final ResultMatcher expect) throws Exception {
|
||||||
mvc.perform(post("/rest/v1/rollouts/{rolloutId}/triggerNextGroup", rollout.getId()))
|
mvc.perform(post("/rest/v1/rollouts/{rolloutId}/triggerNextGroup", rollout.getId()))
|
||||||
.andDo(MockMvcResultPrinter.print()).andExpect(expect);
|
.andDo(MockMvcResultPrinter.print()).andExpect(expect);
|
||||||
@@ -1596,6 +1691,10 @@ class MgmtRolloutResourceTest extends AbstractManagementApiIntegrationTest {
|
|||||||
retrieveAndCompareRolloutsContent(dsA, urlTemplate, isFullRepresentation, false, null, null);
|
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,
|
private void retrieveAndCompareRolloutsContent(final DistributionSet dsA, final String urlTemplate,
|
||||||
final boolean isFullRepresentation, final boolean isStartTypeScheduled, final Long startAt,
|
final boolean isFullRepresentation, final boolean isStartTypeScheduled, final Long startAt,
|
||||||
final Long forcetime) throws Exception {
|
final Long forcetime) throws Exception {
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ public class RolloutGrid extends AbstractGrid<ProxyRollout, String> {
|
|||||||
return isDeletionAllowed(status) && status != RolloutStatus.CREATING;
|
return isDeletionAllowed(status) && status != RolloutStatus.CREATING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isRolloutRetried(final String targetFilter) {
|
||||||
|
return targetFilter.contains("failedrollout");
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isEditingAllowed(final RolloutStatus status) {
|
private static boolean isEditingAllowed(final RolloutStatus status) {
|
||||||
final List<RolloutStatus> statesThatAllowEditing = Arrays.asList(RolloutStatus.PAUSED, RolloutStatus.READY,
|
final List<RolloutStatus> statesThatAllowEditing = Arrays.asList(RolloutStatus.PAUSED, RolloutStatus.READY,
|
||||||
RolloutStatus.RUNNING, RolloutStatus.STARTING, RolloutStatus.STOPPED);
|
RolloutStatus.RUNNING, RolloutStatus.STARTING, RolloutStatus.STOPPED);
|
||||||
@@ -361,14 +365,16 @@ public class RolloutGrid extends AbstractGrid<ProxyRollout, String> {
|
|||||||
clickEvent -> updateRollout(rollout), VaadinIcons.EDIT, UIMessageIdProvider.TOOLTIP_ROLLOUT_UPDATE,
|
clickEvent -> updateRollout(rollout), VaadinIcons.EDIT, UIMessageIdProvider.TOOLTIP_ROLLOUT_UPDATE,
|
||||||
SPUIStyleDefinitions.STATUS_ICON_NEUTRAL,
|
SPUIStyleDefinitions.STATUS_ICON_NEUTRAL,
|
||||||
UIComponentIdProvider.ROLLOUT_UPDATE_BUTTON_ID + "." + rollout.getId(),
|
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));
|
actionColumns.add(GridComponentBuilder.addIconColumn(this, updateButton, UPDATE_BUTTON_ID, null));
|
||||||
|
|
||||||
final ValueProvider<ProxyRollout, Button> copyButton = rollout -> GridComponentBuilder.buildActionButton(i18n,
|
final ValueProvider<ProxyRollout, Button> copyButton = rollout -> GridComponentBuilder.buildActionButton(i18n,
|
||||||
clickEvent -> copyRollout(rollout), VaadinIcons.COPY, UIMessageIdProvider.TOOLTIP_ROLLOUT_COPY,
|
clickEvent -> copyRollout(rollout), VaadinIcons.COPY, UIMessageIdProvider.TOOLTIP_ROLLOUT_COPY,
|
||||||
SPUIStyleDefinitions.STATUS_ICON_NEUTRAL,
|
SPUIStyleDefinitions.STATUS_ICON_NEUTRAL,
|
||||||
UIComponentIdProvider.ROLLOUT_COPY_BUTTON_ID + "." + rollout.getId(),
|
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.addIconColumn(this, copyButton, COPY_BUTTON_ID, null));
|
||||||
|
|
||||||
actionColumns.add(GridComponentBuilder.addDeleteColumn(this, i18n, DELETE_BUTTON_ID, rolloutDeleteSupport,
|
actionColumns.add(GridComponentBuilder.addDeleteColumn(this, i18n, DELETE_BUTTON_ID, rolloutDeleteSupport,
|
||||||
|
|||||||
Reference in New Issue
Block a user