-
Notifications
You must be signed in to change notification settings - Fork 3
feat(devices): add and remove tags on a device at runtime [CU-86ak6uwjn] #2134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| package com.openframe.api.service.device; | ||
|
|
||
| import com.openframe.api.exception.DeviceNotFoundException; | ||
| import com.openframe.data.document.tag.Tag; | ||
| import com.openframe.data.document.tag.TagAssignment; | ||
| import com.openframe.data.document.tag.TagValidation; | ||
| import com.openframe.data.repository.device.MachineRepository; | ||
| import com.openframe.data.repository.tag.TagAssignmentRepository; | ||
| import com.openframe.data.repository.tag.TagRepository; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.validation.annotation.Validated; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.ArrayList; | ||
| import java.util.LinkedHashSet; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import static com.openframe.data.document.tag.TagEntityType.DEVICE; | ||
|
|
||
| /** | ||
| * Attaches and detaches DEVICE tags at runtime — the counterpart to | ||
| * {@code RegistrationTagAssignmentService}, which does the same thing from the agent-registration | ||
| * payload. Both write only Mongo ({@code tags} + {@code tag_assignments}); the Pinot facet columns | ||
| * are refreshed by {@code MachineTagEventAspect}, which intercepts the repository calls made here | ||
| * and republishes the machine's full tag list to Kafka. | ||
| * | ||
| * <p>Every write therefore has to go through an intercepted repository method — {@code save} or | ||
| * {@code deleteByEntityIdAndTagIdAndEntityType}. Bypassing them (e.g. {@code deleteAll}) would | ||
| * leave the device's Pinot row carrying tags it no longer has. | ||
| * | ||
| * <p>Not reused from the client-core service because {@code openframe-client-core} does not depend | ||
| * on {@code openframe-api-lib}, and pulling the logic down into a shared module would drag the | ||
| * machine/tag repositories along with it. | ||
| */ | ||
| @Service | ||
| @Slf4j | ||
| @Validated | ||
| @RequiredArgsConstructor | ||
| @Transactional(readOnly = true) | ||
| public class DeviceTagService { | ||
|
|
||
| private final TagRepository tagRepository; | ||
| private final TagAssignmentRepository tagAssignmentRepository; | ||
| private final MachineRepository machineRepository; | ||
|
|
||
| /** | ||
| * Tags a device with {@code key}, creating the tag key on first use. | ||
| * | ||
| * <p>Additive in both directions: new {@code values} are merged into the device's existing | ||
| * assignment rather than replacing it, and into the tag's list of predefined options. Calling | ||
| * it twice with the same key and values is a no-op beyond the Pinot republish. | ||
| * | ||
| * @return the tag, carrying this device's values (not the tag's full option list) | ||
| */ | ||
| @Transactional | ||
| public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List<String> values) { | ||
| TagValidation.validateKey(key); | ||
| TagValidation.validateValues(values, key); | ||
| requireMachine(machineId); | ||
|
|
||
| Tag tag = findOrCreateTag(key, values); | ||
| List<String> assignedValues = upsertAssignment(machineId, tag.getId(), values); | ||
|
|
||
| log.info("Assigned tag '{}' to machine {} with values {}", key, machineId, assignedValues); | ||
| return Tag.builder() | ||
| .id(tag.getId()) | ||
| .key(tag.getKey()) | ||
| .description(tag.getDescription()) | ||
| .color(tag.getColor()) | ||
| .values(assignedValues) | ||
| .entityType(tag.getEntityType()) | ||
| .createdAt(tag.getCreatedAt()) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * Detaches a tag from a device. The tag key itself survives — it stays available for other | ||
| * devices and in the filter dropdowns; use {@code TagService.deleteTag} to drop the key | ||
| * everywhere. | ||
| * | ||
| * @return {@code true} if the device had the tag, {@code false} if there was nothing to remove | ||
| */ | ||
| @Transactional | ||
| public boolean removeTag(@NotBlank String machineId, @NotBlank String tagId) { | ||
| requireMachine(machineId); | ||
|
|
||
| boolean assigned = tagAssignmentRepository | ||
| .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE) | ||
| .isPresent(); | ||
| if (!assigned) { | ||
| log.info("Tag {} is not assigned to machine {}, nothing to remove", tagId, machineId); | ||
| return false; | ||
| } | ||
|
|
||
| // Aspect-intercepted: publishes the machine's remaining tags before the delete proceeds. | ||
| tagAssignmentRepository.deleteByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); | ||
| log.info("Removed tag {} from machine {}", tagId, machineId); | ||
| return true; | ||
| } | ||
|
|
||
| private void requireMachine(String machineId) { | ||
| if (machineRepository.findByMachineId(machineId).isEmpty()) { | ||
| throw new DeviceNotFoundException("Device not found: " + machineId); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Finds the DEVICE tag for {@code key}, or creates it. On an existing tag any previously unseen | ||
| * values are appended to its predefined options, so a value typed on one device becomes a | ||
| * suggestion for the next. | ||
| */ | ||
| private Tag findOrCreateTag(String key, List<String> values) { | ||
| Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE); | ||
| if (existing == null) { | ||
| Tag created = tagRepository.save(Tag.builder() | ||
| .key(key) | ||
| .values(normalize(values)) | ||
| .entityType(DEVICE) | ||
| .createdAt(Instant.now()) | ||
| .build()); | ||
| log.info("Created DEVICE tag '{}' (id={})", key, created.getId()); | ||
| return created; | ||
| } | ||
|
Check warning on line 128 in openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java
|
||
|
|
||
| List<String> merged = merge(existing.getValues(), values); | ||
| if (merged.size() != size(existing.getValues())) { | ||
| existing.setValues(merged); | ||
| // Aspect-intercepted: refreshes every device already carrying this tag. | ||
| existing = tagRepository.save(existing); | ||
| log.info("Appended values {} to existing tag '{}'", values, key); | ||
| } | ||
| return existing; | ||
| } | ||
|
Check warning on line 138 in openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java
|
||
|
Comment on lines
+117
to
+138
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🦩 🟠 [warn/recommended] assignDeviceTag has no upper bound on tag values / no duplicate protection against unbounded tag growth per device assignTag merges new values into both the tag's global option list and the device's own assignment with no cap on collection size. Since values are user/agent supplied via a public GraphQL mutation, repeated calls with novel values will grow both Evidence🤖 Prompt for AI agentsconfidence: 30 — react 👍/👎 to teach the reviewer |
||
|
|
||
| /** | ||
| * Merges {@code values} into the device's assignment, creating it if the device does not carry | ||
| * the tag yet. Saving through the repository is what triggers the Pinot republish. | ||
| */ | ||
| private List<String> upsertAssignment(String machineId, String tagId, List<String> values) { | ||
| Optional<TagAssignment> existing = tagAssignmentRepository | ||
| .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE); | ||
|
|
||
| if (existing.isEmpty()) { | ||
| TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder() | ||
| .entityId(machineId) | ||
| .tagId(tagId) | ||
| .entityType(DEVICE) | ||
| .values(normalize(values)) | ||
| .taggedAt(Instant.now()) | ||
| .build()); | ||
| return saved.getValues(); | ||
| } | ||
|
|
||
| TagAssignment assignment = existing.get(); | ||
| List<String> merged = merge(assignment.getValues(), values); | ||
| if (merged.size() != size(assignment.getValues())) { | ||
| assignment.setValues(merged); | ||
| return tagAssignmentRepository.save(assignment).getValues(); | ||
| } | ||
| return assignment.getValues(); | ||
| } | ||
|
|
||
| /** Insertion-ordered union — existing values keep their order, new ones are appended. */ | ||
| private static List<String> merge(List<String> current, List<String> added) { | ||
| LinkedHashSet<String> merged = new LinkedHashSet<>(normalize(current)); | ||
| merged.addAll(normalize(added)); | ||
| return new ArrayList<>(merged); | ||
| } | ||
|
|
||
| private static List<String> normalize(List<String> values) { | ||
| return values != null ? values : List.of(); | ||
| } | ||
|
|
||
| private static int size(List<String> values) { | ||
| return values != null ? values.size() : 0; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import com.openframe.api.mapper.GraphQLDeviceMapper; | ||
| import com.openframe.api.service.device.DeviceFilterService; | ||
| import com.openframe.api.service.device.DeviceService; | ||
| import com.openframe.api.service.device.DeviceTagService; | ||
| import com.openframe.api.service.FleetVulnerabilityStatusService; | ||
| import com.openframe.api.service.TagService; | ||
| import com.openframe.data.document.device.Machine; | ||
|
|
@@ -53,6 +54,7 @@ | |
|
|
||
| private final DeviceService deviceService; | ||
| private final DeviceFilterService deviceFilterService; | ||
| private final DeviceTagService deviceTagService; | ||
| private final TagService tagService; | ||
| private final FleetVulnerabilityStatusService fleetVulnerabilityStatusService; | ||
| private final GraphQLDeviceMapper mapper; | ||
|
|
@@ -131,6 +133,22 @@ | |
| return deviceService.updateNickname(machineId, nickname); | ||
| } | ||
|
|
||
| @DgsMutation | ||
| public Tag assignDeviceTag(@InputArgument @NotBlank String machineId, | ||
| @InputArgument @NotBlank String key, | ||
| @InputArgument List<String> values) { | ||
| log.debug("Assigning tag '{}' to machineId: {}", key, machineId); | ||
| return deviceTagService.assignTag(machineId, key, values); | ||
| } | ||
|
|
||
| @DgsMutation | ||
| public boolean removeDeviceTag(@InputArgument @NotBlank String machineId, | ||
| @InputArgument @NotBlank String tagId) { | ||
| String rawTagId = RELAY.fromGlobalId(tagId).getId(); | ||
| log.debug("Removing tag {} (rawId: {}) from machineId: {}", tagId, rawTagId, machineId); | ||
| return deviceTagService.removeTag(machineId, rawTagId); | ||
| } | ||
|
Check warning on line 150 in openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java
|
||
|
Comment on lines
+144
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🦩 🟠 [warn/recommended] The resolver decodes the tagId Relay global ID directly with Evidence🤖 Prompt for AI agentsconfidence: 40 — react 👍/👎 to teach the reviewer |
||
|
|
||
| @DgsData(parentType = "Machine", field = "id") | ||
| public String machineNodeId(DgsDataFetchingEnvironment dfe) { | ||
| Machine machine = dfe.getSource(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🦩 🟠 [warn/recommended] findOrCreateTag / upsertAssignment read-then-write pattern is not concurrency-safe against parallel assignTag calls for the same key
findOrCreateTag() does a findByKeyAndEntityType then conditionally save() without any uniqueness constraint enforcement or optimistic locking visible in this diff. Two concurrent assignDeviceTag calls for a brand-new key (e.g., two devices tagging the same new key simultaneously) can both see
existing == nulland each create a separate Tag document for the same key, resulting in duplicate tag keys in Mongo. Given this is a new device-facing mutation exposed to potentially concurrent client callers, a unique index on (key, entityType) with duplicate-key handling (as required elsewhere in the org's OPENFRAM-006-11 idiom for idempotent inserts) should be applied here too.Evidence
🤖 Prompt for AI agents
confidence: 35 — react 👍/👎 to teach the reviewer