diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFields.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFields.java index 2ffbe6368..d3ae97c59 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFields.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFields.java @@ -29,6 +29,7 @@ public enum TargetFields implements RsqlQueryField { UPDATESTATUS("updateStatus"), IPADDRESS("address"), ATTRIBUTE("controllerAttributes"), + GROUP("group"), ASSIGNEDDS( "assignedDistributionSet", DistributionSetFields.NAME.getJpaEntityFieldName(), DistributionSetFields.VERSION.getJpaEntityFieldName()), diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java index f0e76a9a9..8af9671eb 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTarget.java @@ -90,6 +90,9 @@ public class MgmtTarget extends MgmtNamedEntity { @Schema(description = "Controller ID", example = "123") private String controllerId; + @Schema(description = "Target group", example = "Europe/East") + private String group; + @Schema(description = "If the target is in sync", example = "in_sync") private String updateStatus; diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetRequestBody.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetRequestBody.java index 3fe1e2e6e..a8f45b8a8 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetRequestBody.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/target/MgmtTargetRequestBody.java @@ -42,4 +42,7 @@ public class MgmtTargetRequestBody { @Schema(description = "ID of the target type", example = "10") private Long targetType; + + @Schema(description = "Target group", example = "Asia") + private String group; } \ No newline at end of file diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java index b877a15e4..a9a1b2036 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtRestConstants.java @@ -46,6 +46,10 @@ public final class MgmtRestConstants { * The target type URL mapping rest resource. */ public static final String TARGETTYPE_V1_REQUEST_MAPPING = BASE_V1_REQUEST_MAPPING + "/targettypes"; + /** + * The target group URL mapping rest resource. + */ + public static final String TARGET_GROUP_V1_REQUEST_MAPPING = BASE_V1_REQUEST_MAPPING + "/targetgroups"; /** * The tag URL mapping rest resource. */ @@ -162,6 +166,10 @@ public final class MgmtRestConstants { * The tag URL mapping rest resource. */ public static final String DISTRIBUTIONSET_TAG_DISTRIBUTIONSETS_REQUEST_MAPPING = "/{distributionsetTagId}/assigned"; + /** + * Target group URL mapping rest resource + */ + public static final String TARGET_GROUP_TARGETS_REQUEST_MAPPING = "/{group}/assigned"; /** * The default offset parameter in case the offset parameter is not present in the request. * @@ -233,6 +241,7 @@ public final class MgmtRestConstants { public static final String TARGET_TAG_ORDER = "2000"; public static final String TARGET_TYPE_ORDER = "3000"; public static final String TARGET_FILTER_ORDER = "4000"; + public static final String TARGET_GROUP_ORDER = "4500"; public static final String ACTION_ORDER = "5000"; public static final String ROLLOUT_ORDER = "6000"; public static final String DISTRIBUTION_SET_ORDER = "7000"; diff --git a/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetGroupRestApi.java b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetGroupRestApi.java new file mode 100644 index 000000000..d069a27ae --- /dev/null +++ b/hawkbit-mgmt/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetGroupRestApi.java @@ -0,0 +1,370 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mgmt.rest.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.eclipse.hawkbit.mgmt.json.model.PagedList; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; +import org.eclipse.hawkbit.rest.OpenApi; +import org.eclipse.hawkbit.rest.json.model.ExceptionInfo; +import org.springframework.hateoas.MediaTypes; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +import static org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants.TARGET_GROUP_ORDER; + +@Tag( + name = "Target Groups", description = "REST API for Target Groups operations.", + extensions = @Extension(name = OpenApi.X_HAWKBIT, properties = @ExtensionProperty(name = "order", value = TARGET_GROUP_ORDER))) +public interface MgmtTargetGroupRestApi { + + + /** + * Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) not supported here. + * For complex grouping use the analogical resource with query parameter for target group. + * + * @param group - target group + * @param pagingOffsetParam the offset of list of targets for pagination, might not be present in the rest request then default value will + * be applied + * @param pagingLimitParam the limit of the paged request, might not be present in the rest request then default value will be applied + * @param sortParam the sorting parameter in the request URL, syntax {@code field:direction, field:direction} + * @return a list of targets matching the provided group for a defined or default page request with status OK. The response is always paged. In any failure the + * JsonResponseExceptionHandler is handling the response. + */ + @Operation(summary = "Return assigned targets for group", + description = "Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) not supported here." + + "For complex grouping use the analogical resource with query parameter for target group.") + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_GROUP_TARGETS_REQUEST_MAPPING, + produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + ResponseEntity> getAssignedTargets( + @PathVariable + @Schema(description = "The target group of the targets") + String group, + @RequestParam( + value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_OFFSET, + defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_OFFSET) + @Schema(description = "The paging offset (default is 0)") + int pagingOffsetParam, + @RequestParam( + value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_LIMIT, + defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_LIMIT) + @Schema(description = "The maximum number of entries in a page (default is 50)") + int pagingLimitParam, + @RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SORTING, required = false) + @Schema(description = """ + The query parameter sort allows to define the sort order for the result of a query. A sort criteria + consists of the name of a field and the sort direction (ASC for ascending and DESC descending). + The sequence of the sort criteria (multiple can be used) defines the sort order of the entities + in the result.""") + String sortParam + ); + + /** + * Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) is supported here. + * Search could be for specific group, complex group e.g Parent/Child or also for groups including its subgroups + * + * @param groupFilter - An Actual group - Parent/Child or Parent + * @param subgroups - If set to {@code true} enables the search in subgroups + * @param pagingOffsetParam the offset of list of targets for pagination, might not be present in the rest request then default value will + * be applied + * @param pagingLimitParam the limit of the paged request, might not be present in the rest request then default value will be applied + * @param sortParam the sorting parameter in the request URL, syntax {@code field:direction, field:direction} + * @return a list of targets matching the provided group for a defined or default page request with status OK. The response is always paged. In any failure the + * JsonResponseExceptionHandler is handling the response. + */ + @Operation(summary = "Return assigned targets for group", + description = "Handles the GET request of retrieving a list of assigned targets for a specific group. Complex grouping (subgroups) is supported here." + + "Search could be for specific group, complex group e.g Parent/Child or also for groups including its subgroups") + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + ResponseEntity> getAssignedTargetsWithSubgroups( + @RequestParam(value = "group") + @Schema(description = "Target Group or Filter based on target groups. ") + String groupFilter, + @RequestParam(value = "subgroups", defaultValue = "false") + @Schema(description = " Possibility to search for subgroups with wildcard or not - e.g. ParentGroup/*") + boolean subgroups, + @RequestParam( + value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_OFFSET, + defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_OFFSET) + @Schema(description = "The paging offset (default is 0)") + int pagingOffsetParam, + @RequestParam( + value = MgmtRestConstants.REQUEST_PARAMETER_PAGING_LIMIT, + defaultValue = MgmtRestConstants.REQUEST_PARAMETER_PAGING_DEFAULT_LIMIT) + @Schema(description = "The maximum number of entries in a page (default is 50)") + int pagingLimitParam, + @RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SORTING, required = false) + @Schema(description = """ + The query parameter sort allows to define the sort order for the result of a query. A sort criteria + consists of the name of a field and the sort direction (ASC for ascending and DESC descending). + The sequence of the sort criteria (multiple can be used) defines the sort order of the entities + in the result.""") + String sortParam + ); + + /** + * Assigns targets to a given group. + * For complex groups use analogical method with query parameters. + * + * @param group - target group to be assigned + * @param controllerIds - list of controllerIds for targets to be assigned + * + */ + @Operation(summary = "Assign target(s) to given group", + description = "Handles the POST request of target assignment. Already assigned target will be ignored. " + + "For complex groups use analogical method with query parameters.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully assigned"), + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " + + "user in another request at the same time. You may retry your modification request."), + @ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " + + "supported by the server for this resource."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + MgmtRestConstants.TARGET_GROUP_TARGETS_REQUEST_MAPPING) + ResponseEntity assignTargetsToGroup( + @PathVariable(value = "group") + @Schema(description = "The target group to be set. Sub-grouping not allowed here, for sub-grouping use the analogical method with query parameter.") + String group, + @Schema(description = "List of controller ids to be assigned", example = "[\"controllerId1\", \"controllerId2\"]") + @RequestBody List controllerIds + ); + + /** + * Assigns targets to a given group. + * Complex (subgroups) are allowed - e.g. Parent/Child + * + * @param group - target group to be assigned + * @param controllerIds - list of controllerIds for targets to be assigned + * + */ + @Operation(summary = "Assign target(s) to given group", + description = "Handles the PUT request of assign target group." + + "Subgroups are allowed - e.g. Parent/Child") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully assigned"), + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " + + "user in another request at the same time. You may retry your modification request."), + @ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " + + "supported by the server for this resource."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + ResponseEntity assignTargetsToGroupWithSubgroups( + @RequestParam("group") + @Schema(description = "The target group to be set. Sub-grouping is allowed here - '/' could be used for subgroups") + final String group, + @Schema(description = "List of controller ids to be assigned", example = "[\"controllerId1\", \"controllerId2\"]") + @RequestBody List controllerIds + ); + + /** + * Assigns targets to a given group. + * Complex (subgroups) are allowed - e.g. Parent/Child + * + * @param group - target group to be assigned + * @param rsql - filter to match desired targets + * + */ + @Operation(summary = "Assign target(s) to given group by rsql", + description = "Handles the PUT request of target group assignment." + + "Subgroups are NOT allowed here - e.g. Parent/Child") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully assigned"), + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " + + "user in another request at the same time. You may retry your modification request."), + @ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " + + "supported by the server for this resource."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/{group}") + ResponseEntity assignTargetsToGroupWithRsql( + @PathVariable final String group, + @RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH) + @Schema(description = """ + Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for + available fields.""") + final String rsql + ); + + /** + * Unassigns targets from their groups + * + * @param controllerIds - list of targets to be unassigned. + */ + @Operation(summary = "Unassign targets from their target groups", + description = "Handles the DELETE request to unassign the given target(s).") + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @DeleteMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + ResponseEntity unassignTargetsFromGroup( + @RequestBody(required = false) + @Schema(description = "List of controller ids to be unassigned from their groups", example = "[\"controllerId1\", \"controllerId2\"]") + List controllerIds + + ); + + /** + * Unassigns targets from their groups + * + * @param rsql - filter for the matching targets to be unassigned + */ + @Operation(summary = "Unassign targets from their target groups", + description = "Handles the DELETE request to unassign the given target(s).") + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @DeleteMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING) + ResponseEntity unassignTargetsFromGroupByRsql( + @RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH, required = false) + @Schema(description = """ + Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for + available fields.""") + final String rsql + ); + + /** + * + * @return list of all assigned target groups + */ + @Operation(summary = "Return all assigned target groups", + description = "Handles the GET request of retrieving a list of all target groups.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved"), + @ApiResponse(responseCode = "401", description = "The request requires user authentication."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @GetMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING, + produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) + ResponseEntity> getTargetGroups(); + + + /** + * Assign targets matching a rsql filter to a provided target group + * @param group - target group to be assigned + * @param rsqlParam - rsql filter based on Target fields + */ + @Operation(summary = "Assign targets matching a rsql filter to provided target group", + description = "Handles the GET request of assigning targets matching a rsql filter to a provided target group" + + "Subgroups are allowed - e.g. Parent/Child") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully assigned"), + @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."), + @ApiResponse(responseCode = "403", description = "Insufficient permissions, entity is not allowed to be " + + "changed (i.e. read-only) or data volume restriction applies."), + @ApiResponse(responseCode = "405", description = "The http request method is not allowed on the resource."), + @ApiResponse(responseCode = "406", description = "In case accept header is specified and not application/json."), + @ApiResponse(responseCode = "409", description = "E.g. in case an entity is created or modified by another " + + "user in another request at the same time. You may retry your modification request."), + @ApiResponse(responseCode = "415", description = "The request was attempt with a media-type which is not " + + "supported by the server for this resource."), + @ApiResponse(responseCode = "429", description = "Too many requests. The server will refuse further attempts " + + "and the client has to wait another second.") + }) + @PutMapping(value = MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING) + ResponseEntity assignTargetsToGroup( + @RequestParam(name = "group") + @Schema(description = "The target group to be set. Sub-grouping is allowed here - '/' could be used for subgroups") + final String group, + @RequestParam(value = MgmtRestConstants.REQUEST_PARAMETER_SEARCH) + @Schema(description = """ + Query fields based on the Feed Item Query Language (FIQL). See Entity Definitions for + available fields.""") + String rsqlParam); +} diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResource.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResource.java new file mode 100644 index 000000000..bbf6de297 --- /dev/null +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResource.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mgmt.rest.resource; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.mgmt.json.model.PagedList; +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget; +import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetGroupRestApi; +import org.eclipse.hawkbit.mgmt.rest.resource.mapper.MgmtTargetMapper; +import org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility; +import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.TenantConfigurationManagement; +import org.eclipse.hawkbit.repository.model.Target; +import org.eclipse.hawkbit.security.SystemSecurityContext; +import org.eclipse.hawkbit.utils.TenantConfigHelper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static org.eclipse.hawkbit.mgmt.rest.resource.util.PagingUtility.sanitizeTargetSortParam; + +@Slf4j +@RestController +public class MgmtTargetGroupResource implements MgmtTargetGroupRestApi { + + private final TargetManagement targetManagement; + private final TenantConfigHelper tenantConfigHelper; + + public MgmtTargetGroupResource(final TargetManagement targetManagement, final SystemSecurityContext systemSecurityContext, + final TenantConfigurationManagement tenantConfigurationManagement) { + this.targetManagement = targetManagement; + this.tenantConfigHelper = TenantConfigHelper.usingContext(systemSecurityContext, tenantConfigurationManagement); + } + + @Override + public ResponseEntity> getAssignedTargets(String group, int pagingOffsetParam, int pagingLimitParam, String sortParam) { + final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeTargetSortParam(sortParam)); + + final Page targets = targetManagement.findTargetsByGroup(group, false, pageable); + + final List rest = MgmtTargetMapper.toResponse(targets.getContent(), tenantConfigHelper); + return ResponseEntity.ok(new PagedList<>(rest, targets.getTotalElements())); + } + + @Override + public ResponseEntity> getAssignedTargetsWithSubgroups(String groupFilter, boolean subgroups, int pagingOffsetParam, int pagingLimitParam, String sortParam) { + final Pageable pageable = PagingUtility.toPageable(pagingOffsetParam, pagingLimitParam, sanitizeTargetSortParam(sortParam)); + + final Page targets = targetManagement.findTargetsByGroup(groupFilter, subgroups, pageable); + + final List rest = MgmtTargetMapper.toResponse(targets.getContent(), tenantConfigHelper); + return ResponseEntity.ok(new PagedList<>(rest, targets.getTotalElements())); + } + + @Override + public ResponseEntity assignTargetsToGroup(String group, List controllerIds) { + return assignTargets(group, controllerIds); + } + + @Override + public ResponseEntity assignTargetsToGroupWithSubgroups(String group, List controllerIds) { + return assignTargets(group, controllerIds); + } + + @Override + public ResponseEntity assignTargetsToGroupWithRsql(String group, String rsql) { + targetManagement.assignTargetGroupWithRsql(group, rsql); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity unassignTargetsFromGroup(List controllerIds) { + targetManagement.assignTargetsWithGroup(null, controllerIds); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity unassignTargetsFromGroupByRsql(String rsql) { + targetManagement.assignTargetGroupWithRsql(null, rsql); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity> getTargetGroups() { + final List groups = targetManagement.findGroups(); + return ResponseEntity.ok(groups); + } + + @Override + public ResponseEntity assignTargetsToGroup(final String group, final String rsql) { + targetManagement.assignTargetGroupWithRsql(group, rsql); + return ResponseEntity.ok().build(); + } + + private ResponseEntity assignTargets(final String group, final List controllerIds) { + targetManagement.assignTargetsWithGroup(group, controllerIds); + return ResponseEntity.ok().build(); + } +} diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java index 652c2e6c5..717eaac7e 100644 --- a/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/mapper/MgmtTargetMapper.java @@ -140,6 +140,7 @@ public final class MgmtTargetMapper { targetRest.setDescription(target.getDescription()); targetRest.setName(target.getName()); targetRest.setUpdateStatus(target.getUpdateStatus().name().toLowerCase()); + targetRest.setGroup(target.getGroup()); final URI address = target.getAddress(); if (address != null) { @@ -325,7 +326,7 @@ public final class MgmtTargetMapper { private static TargetCreate fromRequest(final EntityFactory entityFactory, final MgmtTargetRequestBody targetRest) { return entityFactory.target().create().controllerId(targetRest.getControllerId()).name(targetRest.getName()) .description(targetRest.getDescription()).securityToken(targetRest.getSecurityToken()) - .address(targetRest.getAddress()).targetType(targetRest.getTargetType()); + .address(targetRest.getAddress()).targetType(targetRest.getTargetType()).group(targetRest.getGroup()); } private static String getType(final Action action) { diff --git a/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java new file mode 100644 index 000000000..834852c9b --- /dev/null +++ b/hawkbit-mgmt/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetGroupResourceTest.java @@ -0,0 +1,236 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mgmt.rest.resource; + +import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; +import org.eclipse.hawkbit.rest.util.JsonBuilder; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +public class MgmtTargetGroupResourceTest extends AbstractManagementApiIntegrationTest { + + + @Test + void shouldRetrieveDistinctTargetGroups() throws Exception { + + final List expectedGroups = List.of("Europe", "Asia"); + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("Asia")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe")); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.[0]", Matchers.in(expectedGroups))) + .andExpect(jsonPath("$.[1]", Matchers.in(expectedGroups))); + } + + @Test + void shouldRetrieveTargetsFilteredByGroupAndParentGroupCorrectly() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe/West")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe/East")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe")); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param("group", "Europe/East") + .param("subgroups", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(1))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target2"))); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param("group", "Europe") + .param("subgroups", "true") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(3))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2"))) + .andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3"))); + } + + @Test + void shouldGetAssignedTargetsToSpecificGroup() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("US")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe")); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/Europe/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(2))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target3"))); + } + + @Test + void shouldAssignListOfTargetsToASpecificGroup() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe")); + + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/newGroup/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Arrays.asList("target1", "target2", "target3")))) + .andExpect(status().isOk()); + + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/newGroup/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(3))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2"))) + .andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3"))); + } + + @Test + void shouldReturnBadRequestWhenProvidingAnEmptyListOfControllerIds() throws Exception { + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/someGroup/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Collections.emptyList()))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldAssignListOfTargetsToProvidedGroupWithSubgroup() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("US")); + + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Arrays.asList("target1", "target2", "target3"))) + .param("group", "Europe/East")) + .andExpect(status().isOk()); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .param("group", "Europe/East") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(3))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2"))) + .andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3"))); + + // expect bad request if empty controllerIds + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Collections.emptyList())) + .param("group", "doesNotMatter")) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldAssignTargetsToProvidedGroupByRsql() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("A")); + targetManagement.create(entityFactory.target().create().controllerId("target2")); + targetManagement.create(entityFactory.target().create().controllerId("shouldNotAssign").group("B")); + + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/C") + .contentType(MediaType.APPLICATION_JSON) + .param("q", "controllerId==target*")) + .andExpect(status().isOk()); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .param("group", "C") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(2))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2"))); + } + + @Test + void shouldUnassignTargetsFromGroup() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe")); + + mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Arrays.asList("target1", "target2")))) + .andExpect(status().isOk()); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .param("group", "Europe") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(0))); + + // expect bad request if empty + mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .contentType(MediaType.APPLICATION_JSON) + .content(JsonBuilder.toArray(Collections.emptyList()))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldUnassignTargetsFromGroupByRsqlFilter() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("nonMatchingTarget").group("Europe")); + + mvc.perform(delete(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING) + .contentType(MediaType.APPLICATION_JSON) + .param("q", "controllerId==target*")) + .andExpect(status().isOk()); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .param("group", "Europe") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(1))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("nonMatchingTarget"))); + } + + @Test + void shouldUpdateTargetGroupsOfTargetsMatchingTheRsqlFilter() throws Exception { + targetManagement.create(entityFactory.target().create().controllerId("target1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target2").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("target3").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("shouldNotBeUpdated1").group("Europe")); + targetManagement.create(entityFactory.target().create().controllerId("shouldNotBeUpdated2").group("Europe")); + + mvc.perform(put(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING) + .contentType(MediaType.APPLICATION_JSON) + .param("group", "Europe/East") + .param("q", "controllerId==target*")) + .andExpect(status().isOk()); + + mvc.perform(get(MgmtRestConstants.TARGET_GROUP_V1_REQUEST_MAPPING + "/assigned") + .param("group", "Europe/East") + .param(MgmtRestConstants.REQUEST_PARAMETER_SORTING, "ID:ASC") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("content", Matchers.hasSize(3))) + .andExpect(jsonPath("content.[0].controllerId", Matchers.equalTo("target1"))) + .andExpect(jsonPath("content.[1].controllerId", Matchers.equalTo("target2"))) + .andExpect(jsonPath("content.[2].controllerId", Matchers.equalTo("target3"))); + + + } +} 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 fcc631985..f7c3ced70 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 @@ -620,6 +620,41 @@ public interface TargetManagement { @PreAuthorize(HAS_AUTH_UPDATE_TARGET) Target assignType(@NotEmpty String controllerId, @NotNull Long targetTypeId); + /** + * Finds targets by group or subgroup. + * @param group - provided group/subgroup to filter for + * @param withSubgroups - whether is a subgroup or not e.g. x/y/z + * @param pageable - page parameter + * @return all matching targets to provided group/subgroup + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) + Page findTargetsByGroup(@NotEmpty String group, boolean withSubgroups, @NotNull Pageable pageable); + /** + * Finds all the distinct target groups in the scope of a tenant + * + * @return list of all distinct target groups + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_READ_TARGET) + List findGroups(); + + /** + * Assigns the target group of the targets matching the provided rsql filter. + * + * @param group target group parameter + * @param rsql rsql filter for {@link Target} + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET) + void assignTargetGroupWithRsql(String group, @NotNull String rsql); + + /** + * Assigns the provided group to the targets which are in the provided list of controllerIds. + * + * @param group target group parameter + * @param controllerIds list of targets + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET) + void assignTargetsWithGroup(String group, @NotEmpty List controllerIds); + /** * updates the {@link Target}. * diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetCreate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetCreate.java index 14bf80f58..cfe764f6b 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetCreate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetCreate.java @@ -73,6 +73,12 @@ public interface TargetCreate { */ TargetCreate status(@NotNull TargetUpdateStatus status); + /** + * @param group for setting the group of the target + * @return updated builder instance + */ + TargetCreate group(String group); + /** * @return peek on current state of {@link Target} in the builder */ diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetUpdate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetUpdate.java index df1ed627f..e3a935464 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetUpdate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetUpdate.java @@ -70,4 +70,11 @@ public interface TargetUpdate { * @return updated builder instance */ TargetUpdate requestAttributes(Boolean requestAttributes); + + /** + * + * @param group for {@link Target#getGroup()} + * @return updated builder instance + */ + TargetUpdate group(String group); } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java index d08917021..7bcdd6482 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/Target.java @@ -124,4 +124,10 @@ public interface Target extends NamedEntity { * {@link #getControllerAttributes()}. */ boolean isRequestControllerAttributes(); + + /** + * + * @return the group of the target + */ + String getGroup(); } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetUpdateCreate.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetUpdateCreate.java index 0aec44887..34e411f65 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetUpdateCreate.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetUpdateCreate.java @@ -34,6 +34,7 @@ public class AbstractTargetUpdateCreate extends AbstractNamedEntityBuilder protected TargetUpdateStatus status; protected Boolean requestAttributes; protected Long targetTypeId; + protected String group; protected AbstractTargetUpdateCreate(final String controllerId) { this.controllerId = AbstractBaseEntityBuilder.strip(controllerId); @@ -83,6 +84,11 @@ public class AbstractTargetUpdateCreate extends AbstractNamedEntityBuilder return (T) this; } + public T group(final String group) { + this.group = group; + return (T) this; + } + public String getControllerId() { return controllerId; } @@ -107,4 +113,6 @@ public class AbstractTargetUpdateCreate extends AbstractNamedEntityBuilder return targetTypeId; } + public Optional getGroup() { return Optional.ofNullable(group); } + } diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_12_33__add_group_to_target__H2.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_12_33__add_group_to_target__H2.sql new file mode 100644 index 000000000..6c776ac98 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/H2/V1_12_33__add_group_to_target__H2.sql @@ -0,0 +1,2 @@ +ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256); +CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group); \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_12_33__add_group_to_target__MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_12_33__add_group_to_target__MYSQL.sql new file mode 100644 index 000000000..6c776ac98 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/MYSQL/V1_12_33__add_group_to_target__MYSQL.sql @@ -0,0 +1,2 @@ +ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256); +CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group); \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_12_34__add_group_to_target__POSTGRESQL.sql b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_12_34__add_group_to_target__POSTGRESQL.sql new file mode 100644 index 000000000..6c776ac98 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa-flyway/src/main/resources/db/migration/POSTGRESQL/V1_12_34__add_group_to_target__POSTGRESQL.sql @@ -0,0 +1,2 @@ +ALTER TABLE sp_target ADD COLUMN target_group VARCHAR(256); +CREATE INDEX sp_idx_target_group ON sp_target (tenant, target_group); \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetCreate.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetCreate.java index 8d6cccbd6..7a89dbf2c 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetCreate.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetCreate.java @@ -53,6 +53,7 @@ public class JpaTargetCreate extends AbstractTargetUpdateCreate im target.setAddress(address); target.setUpdateStatus(getStatus().orElse(TargetUpdateStatus.UNKNOWN)); getLastTargetQuery().ifPresent(target::setLastTargetQuery); + target.setGroup(getGroup().orElse(null)); return target; } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java index f58f0b2fb..ac1ded544 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java @@ -28,7 +28,9 @@ import java.util.stream.Collectors; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.CriteriaUpdate; import jakarta.persistence.criteria.MapJoin; +import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import jakarta.persistence.metamodel.MapAttribute; import jakarta.validation.constraints.NotEmpty; @@ -562,6 +564,57 @@ public class JpaTargetManagement implements TargetManagement { return targetRepository.save(target); } + @Override + public Page findTargetsByGroup(String group, final boolean withSubgroups, final Pageable pageable) { + if (withSubgroups) { + // search for eq(group) and like(group%) + return JpaManagementHelper + .findAllWithCountBySpec(targetRepository, List.of(TargetSpecifications.eqOrSubTargetGroup(group)), pageable); + } else { + return JpaManagementHelper + .findAllWithCountBySpec(targetRepository, List.of(TargetSpecifications.eqTargetGroup(group)), pageable); + } + } + + @Override + public List findGroups() { + return targetRepository.findDistinctGroups(); + } + + + @Override + @Transactional + @Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, + backoff = @Backoff(delay = Constants.TX_RT_DELAY)) + public void assignTargetGroupWithRsql(String group, String rsql) { + final Specification rsqlSpecification = RsqlUtility.getInstance().buildRsqlSpecification(rsql, TargetFields.class); + + final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + final CriteriaUpdate criteriaUpdateQuery = cb.createCriteriaUpdate(JpaTarget.class); + final Root root = criteriaUpdateQuery.getRoot(); + criteriaUpdateQuery.set("group", group); + // get predicate from rsql specification using a dummy query in order to execute batch update + final Predicate predicate = rsqlSpecification.toPredicate(root, entityManager.getCriteriaBuilder().createQuery(JpaTarget.class), cb); + criteriaUpdateQuery.where(predicate); + + entityManager.createQuery(criteriaUpdateQuery).executeUpdate(); + } + + @Override + @Transactional + @Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, + backoff = @Backoff(delay = Constants.TX_RT_DELAY)) + public void assignTargetsWithGroup(String group, List controllerIds) { + final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaUpdate criteriaQuery = cb.createCriteriaUpdate(JpaTarget.class); + Root root = criteriaQuery.from(JpaTarget.class); + CriteriaBuilder.In in = cb.in(root.get("controllerId")); + controllerIds.forEach(in::value); + + entityManager.createQuery(criteriaQuery.set("group", group).where(in)).executeUpdate(); + + } + @Override @Transactional @Retryable(retryFor = { ConcurrencyFailureException.class }, maxAttempts = Constants.TX_RT_MAX, diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTarget.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTarget.java index 76259e724..c33702f21 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTarget.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTarget.java @@ -154,6 +154,11 @@ public class JpaTarget extends AbstractJpaNamedEntity implements Target, EventAw // set default request controller attributes to true, because we want to request them the first time private boolean requestControllerAttributes = true; + @Setter + @Getter + @Column(name = "target_group") + private String group; + // actually it is OneToOne - but lazy loading is not supported for OneToOne (at least for hibernate 6.6.2) @OneToMany(fetch = FetchType.LAZY, mappedBy = "target", cascade = { CascadeType.ALL }, orphanRemoval = true) @PrimaryKeyJoinColumn diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java index 11ff45645..e832ffec8 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java @@ -10,6 +10,7 @@ package org.eclipse.hawkbit.repository.jpa.repository; import java.util.Collection; +import java.util.List; import java.util.Optional; import jakarta.persistence.EntityManager; @@ -96,4 +97,11 @@ public interface TargetRepository extends BaseEntityRepository { @Transactional @Query("DELETE FROM JpaTarget t WHERE t.tenant = :tenant") void deleteByTenant(@Param("tenant") String tenant); + + /** + * Finds all available distinct target groups + * @return all target groups + */ + @Query(value = "SELECT DISTINCT target_group FROM sp_target WHERE target_group IS NOT NULL", nativeQuery = true) + List findDistinctGroups(); } \ No newline at end of file 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 29ba5e8f5..43fc6cbfb 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 @@ -192,6 +192,22 @@ public final class TargetSpecifications { }; } + public static Specification eqTargetGroup(final String targetGroup) { + return (targetRoot, query, criteriaBuilder) -> { + final String groupTextToLower = targetGroup.toLowerCase(); + return criteriaBuilder.equal(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), groupTextToLower); + }; + } + + public static Specification eqOrSubTargetGroup(final String targetGroupSearch) { + return (targetRoot, query, criteriaBuilder) -> { + final String searchTextToLower = targetGroupSearch.toLowerCase(); + return criteriaBuilder.or( + criteriaBuilder.equal(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), searchTextToLower), + criteriaBuilder.like(criteriaBuilder.lower(targetRoot.get(JpaTarget_.group)), searchTextToLower.concat("/%"))); + }; + } + /** * {@link Specification} for retrieving {@link Target}s by "like controllerId". * diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/Constants.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/Constants.java index f58d0343d..a18f02270 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/Constants.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/Constants.java @@ -21,6 +21,7 @@ public interface Constants { String VERSION = "Version"; String VENDOR = "Vendor"; String TYPE = "Type"; + String GROUP = "Group"; String CREATED_BY = "Created by"; String CREATED_AT = "Created at"; String LAST_MODIFIED_BY = "Last modified by"; diff --git a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/TargetView.java b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/TargetView.java index 1b576dfc4..ed19af223 100644 --- a/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/TargetView.java +++ b/hawkbit-simple-ui/src/main/java/org/eclipse/hawkbit/ui/simple/view/TargetView.java @@ -345,6 +345,7 @@ public class TargetView extends TableView { private final TextField lastModifiedAt = Utils.textField(Constants.LAST_MODIFIED_AT); private final TextField securityToken = Utils.textField(Constants.SECURITY_TOKEN); private final TextField lastPoll = Utils.textField(Constants.LAST_POLL); + private final TextField group = Utils.textField(Constants.GROUP); private final TextArea targetAttributes = new TextArea(Constants.ATTRIBUTES); private transient MgmtTarget target; @@ -355,7 +356,7 @@ public class TargetView extends TableView { description, createdBy, createdAt, lastModifiedBy, lastModifiedAt, - securityToken, lastPoll, targetAttributes + securityToken, lastPoll, targetAttributes, group ) .forEach(field -> { field.setReadOnly(true); @@ -378,6 +379,7 @@ public class TargetView extends TableView { lastModifiedBy.setValue(target.getLastModifiedBy()); lastModifiedAt.setValue(new Date(target.getLastModifiedAt()).toString()); securityToken.setValue(target.getSecurityToken()); + group.setValue(target.getGroup() != null ? target.getGroup() : ""); final MgmtPollStatus pollStatus = target.getPollStatus(); lastPoll.setValue(pollStatus == null ? NOT_AVAILABLE_NULL : new Date(pollStatus.getLastRequestAt()).toString());