From 73a5ef44dc39c3252be5c71a08cecff7cfd83278 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Mon, 7 Sep 2026 15:30:09 +0300 Subject: [PATCH 01/14] Added skeleton for Software Management. Interaction with Fleet --- .../api/dto/rmm/DispatchResponse.java | 10 - .../rmm/software/InstallSoftwareInput.java | 16 ++ .../software/ScheduleUpdateSoftwareInput.java | 21 ++ .../dto/rmm/software/SoftwareCveSeverity.java | 10 + .../software/SoftwareOnDeviceResponse.java | 21 ++ .../rmm/software/SoftwareOnDeviceStatus.java | 14 + .../dto/rmm/software/SoftwareResponse.java | 26 ++ .../api/dto/rmm/software/SoftwareSource.java | 13 + .../api/dto/rmm/software/SoftwareType.java | 6 + .../rmm/software/SoftwareVersionStatus.java | 8 + .../SoftwareVulnerabilityResponse.java | 21 ++ .../SoftwareVulnerabilitySummaryResponse.java | 16 ++ .../rmm/software/UninstallSoftwareInput.java | 16 ++ .../rmm/software/SoftwareDispatchService.java | 46 ++++ .../software/SoftwareInventoryService.java | 63 +++++ .../datafetcher/rmm/SoftwareDataFetcher.java | 120 +++++++++ .../main/resources/schema/software.graphqls | 253 ++++++++++++++++++ 17 files changed, 670 insertions(+), 10 deletions(-) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java create mode 100644 openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java create mode 100644 openframe-api-service-core/src/main/resources/schema/software.graphqls diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java index b362f9bb64..ffd7519bac 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/DispatchResponse.java @@ -3,16 +3,6 @@ import lombok.Builder; import lombok.Data; -/** - * Result of a fire-and-forget RMM dispatch over core NATS — running a command, - * cancelling an in-flight execution, or running a saved script. Carries the - * server-minted {@code executionId} the dashboard uses to correlate the agent's - * asynchronous result. - * - *

Shared by all dispatch mutations ({@code runCommand}, {@code cancelExecution}, - * {@code runScript}): they are structurally identical, so a single type avoids - * three duplicate one-field DTOs. - */ @Data @Builder public class DispatchResponse { diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java new file mode 100644 index 0000000000..003ad438fb --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java @@ -0,0 +1,16 @@ +package com.openframe.api.dto.rmm.software; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class InstallSoftwareInput { + @NotNull + private String softwareId; + + @NotEmpty + private List machineIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java new file mode 100644 index 0000000000..0414633e48 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java @@ -0,0 +1,21 @@ +package com.openframe.api.dto.rmm.software; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +public class ScheduleUpdateSoftwareInput { + @NotNull + private String softwareId; + + @NotEmpty + private List machineIds; + + /** UTC instant at which the update should fire on each device. */ + @NotNull + private Instant scheduledAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java new file mode 100644 index 0000000000..7521279151 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java @@ -0,0 +1,10 @@ +package com.openframe.api.dto.rmm.software; + +/** CVSS-derived severity bucket. Ordered from most to least severe. */ +public enum SoftwareCveSeverity { + CRITICAL, + HIGH, + MEDIUM, + LOW, + NONE +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java new file mode 100644 index 0000000000..0f4ea27db3 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java @@ -0,0 +1,21 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Per-device row for a software title. The {@code device} GraphQL field is + * resolved in the DataFetcher by looking up {@code machineId} against the + * device service. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareOnDeviceResponse { + private String machineId; + private String softwareVersion; + private SoftwareOnDeviceStatus status; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java new file mode 100644 index 0000000000..dac889812c --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java @@ -0,0 +1,14 @@ +package com.openframe.api.dto.rmm.software; + +/** + * Per-device software state — static (UP_TO_DATE / OUTDATED) or a lifecycle + * transition triggered by a pending dispatch (SCHEDULED_UPDATE / UNINSTALLING / + * SCHEDULED_UNINSTALL). + */ +public enum SoftwareOnDeviceStatus { + UP_TO_DATE, + OUTDATED, + SCHEDULED_UPDATE, + UNINSTALLING, + SCHEDULED_UNINSTALL +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java new file mode 100644 index 0000000000..fb399d4203 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java @@ -0,0 +1,26 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Aggregate software row — one per software title, rolled up across the tenant's fleet. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareResponse { + private String id; + private String name; + private String publisher; + private SoftwareType type; + private SoftwareSource source; + private String currentVersion; + private String latestVersion; + private SoftwareVersionStatus versionStatus; + private Integer olderVersionsCount; + private Integer devicesCount; + private SoftwareVulnerabilitySummaryResponse vulnerabilitySummary; + private Boolean cpeMatched; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java new file mode 100644 index 0000000000..09da03d803 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java @@ -0,0 +1,13 @@ +package com.openframe.api.dto.rmm.software; + +/** + * Package-manager source of the software as reported by the OS inventory scan. + * UNMANAGED covers everything installed outside a supported package manager + * (custom MSI, driver bundles, side-loaded apps). + */ +public enum SoftwareSource { + WINGET, + CHOCOLATEY, + BREW, + UNMANAGED +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java new file mode 100644 index 0000000000..ff2b11a66a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java @@ -0,0 +1,6 @@ +package com.openframe.api.dto.rmm.software; + +public enum SoftwareType { + APPLICATION, + DRIVER +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java new file mode 100644 index 0000000000..004f29c323 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java @@ -0,0 +1,8 @@ +package com.openframe.api.dto.rmm.software; + +/** Fleet-wide freshness of a software title's most-installed version vs. the latest known. */ +public enum SoftwareVersionStatus { + UP_TO_DATE, + OUTDATED, + UNKNOWN +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java new file mode 100644 index 0000000000..d9aa59c91e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java @@ -0,0 +1,21 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** Single CVE row for a software title. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareVulnerabilityResponse { + private String cveId; + private SoftwareCveSeverity severity; + private Double cvssScore; + private String affectedVersion; + private Instant publishedAt; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java new file mode 100644 index 0000000000..2754471a20 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java @@ -0,0 +1,16 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Vulnerability roll-up for a software row — highest severity + total CVE count. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareVulnerabilitySummaryResponse { + private SoftwareCveSeverity highestSeverity; + private int cveCount; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java new file mode 100644 index 0000000000..fe080bf14b --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java @@ -0,0 +1,16 @@ +package com.openframe.api.dto.rmm.software; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +public class UninstallSoftwareInput { + @NotNull + private String softwareId; + + @NotEmpty + private List machineIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java new file mode 100644 index 0000000000..cf5ee15d0e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java @@ -0,0 +1,46 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.DispatchResponse; +import com.openframe.api.dto.rmm.software.InstallSoftwareInput; +import com.openframe.api.dto.rmm.software.ScheduleUpdateSoftwareInput; +import com.openframe.api.dto.rmm.software.UninstallSoftwareInput; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * Write-side service for the Software Management feature — dispatches install / + * uninstall / scheduled-update / cancel operations to agents via the RMM + * pipeline (system-preset scripts per package-manager source). + * + *

Stub: every method returns {@code null}. Real implementation will + * reuse the RMM {@code Script}/{@code SystemScriptDispatchService} pipeline + * with a package-manager-specific script per software source (WINGET / CHOCO / + * BREW). Gated by {@code openframe.software-management.enabled}. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +public class SoftwareDispatchService { + + public DispatchResponse install(InstallSoftwareInput input, String initiatedBy) { + log.debug("[software-mgmt stub] install softwareId={} initiatedBy={}", input.getSoftwareId(), initiatedBy); + return null; + } + + public DispatchResponse uninstall(UninstallSoftwareInput input, String initiatedBy) { + log.debug("[software-mgmt stub] uninstall softwareId={} initiatedBy={}", input.getSoftwareId(), initiatedBy); + return null; + } + + public DispatchResponse scheduleUpdate(ScheduleUpdateSoftwareInput input, String initiatedBy) { + log.debug("[software-mgmt stub] scheduleUpdate softwareId={} initiatedBy={} scheduledAt={}", + input.getSoftwareId(), initiatedBy, input.getScheduledAt()); + return null; + } + + public DispatchResponse cancelScheduled(String executionId, String actorUserId) { + log.debug("[software-mgmt stub] cancelScheduled executionId={} actorUserId={}", executionId, actorUserId); + return null; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java new file mode 100644 index 0000000000..80819baaab --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -0,0 +1,63 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +/** + * Read-side service for the Software Management feature — aggregate list of + * software titles, per-title device list, and per-title CVE list. + * + *

Stub: every method returns {@code null} / {@code Optional.empty()} / + * {@code List.of()}. Real implementation will read from a Mongo materialized + * view populated by a Fleet REST poller (see software management design). The + * bean is gated by {@code openframe.software-management.enabled} so it is not + * loaded in production until the feature is turned on. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +public class SoftwareInventoryService { + + public Optional findById(String softwareId) { + log.debug("[software-mgmt stub] findById softwareId={}", softwareId); + return Optional.empty(); + } + + public List listSoftware(Object filter, String search, Object pagination, Object sort) { + log.debug("[software-mgmt stub] listSoftware"); + return List.of(); + } + + public long countSoftware(Object filter, String search) { + return 0L; + } + + public List listDevicesForSoftware(String softwareId, + Object filter, String search, + Object pagination, Object sort) { + log.debug("[software-mgmt stub] listDevicesForSoftware softwareId={}", softwareId); + return List.of(); + } + + public long countDevicesForSoftware(String softwareId, Object filter, String search) { + return 0L; + } + + public List listVulnerabilitiesForSoftware(String softwareId, + Object filter, String search, + Object pagination, Object sort) { + log.debug("[software-mgmt stub] listVulnerabilitiesForSoftware softwareId={}", softwareId); + return List.of(); + } + + public long countVulnerabilitiesForSoftware(String softwareId, Object filter, String search) { + return 0L; + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java new file mode 100644 index 0000000000..286893200a --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -0,0 +1,120 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.rmm.DispatchResponse; +import com.openframe.api.dto.rmm.software.InstallSoftwareInput; +import com.openframe.api.dto.rmm.software.ScheduleUpdateSoftwareInput; +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.UninstallSoftwareInput; +import com.openframe.api.service.rmm.software.SoftwareDispatchService; +import com.openframe.api.service.rmm.software.SoftwareInventoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.List; + +/** + * GraphQL resolver for the Software Management surface — All Software list, + * per-title Devices tab, per-title Vulnerabilities tab, and install / uninstall + * / scheduled-update mutations. + * + *

Skeleton: wire contract is stable so the frontend can bind against + * it, but resolvers delegate to stub services that return {@code null} / + * empty. The whole component is gated by + * {@code openframe.software-management.enabled} — omit the flag in prod until + * the feature is ready. + * + *

Faceted filters, nested {@code SoftwareOnDevice.device} resolver, and + * {@code Software.vulnerabilitySummary} field resolver will be added when the + * backing services are implemented; today the DTOs already carry the summary + * inline, so the default field resolvers cover the read-path. + */ +@DgsComponent +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareDataFetcher { + + private final SoftwareInventoryService softwareInventoryService; + private final SoftwareDispatchService softwareDispatchService; + + // ────────── Queries ────────── + + @DgsQuery + public SoftwareResponse software(@InputArgument String id) { + return softwareInventoryService.findById(id).orElse(null); + } + + @DgsQuery + public Object softwares(@InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument Object sort) { + log.debug("[software-mgmt stub] softwares query"); + return null; + } + + @DgsQuery + public Object softwareDevices(@InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument Object sort) { + log.debug("[software-mgmt stub] softwareDevices query softwareId={}", softwareId); + return null; + } + + @DgsQuery + public Object softwareVulnerabilities(@InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument Object sort) { + log.debug("[software-mgmt stub] softwareVulnerabilities query softwareId={}", softwareId); + return null; + } + + @DgsQuery + public Object softwareFilters(@InputArgument Object filter, @InputArgument String search) { + log.debug("[software-mgmt stub] softwareFilters query"); + return null; + } + + // ────────── Mutations ────────── + + @DgsMutation + public DispatchResponse installSoftware(@InputArgument InstallSoftwareInput input) { + return softwareDispatchService.install(input, currentUserId()); + } + + @DgsMutation + public DispatchResponse uninstallSoftware(@InputArgument UninstallSoftwareInput input) { + return softwareDispatchService.uninstall(input, currentUserId()); + } + + @DgsMutation + public DispatchResponse scheduleUpdateSoftware(@InputArgument ScheduleUpdateSoftwareInput input) { + return softwareDispatchService.scheduleUpdate(input, currentUserId()); + } + + @DgsMutation + public DispatchResponse cancelScheduledSoftware(@InputArgument String executionId) { + return softwareDispatchService.cancelScheduled(executionId, currentUserId()); + } + + /** + * Placeholder — will be replaced with the standard security-context helper + * used by the rest of the datafetchers (see {@code CommandDataFetcher}). + * Returns {@code null} in the stub. + */ + private String currentUserId() { + return null; + } + + // Suppress unused-import warning in the stub — List shape + // is referenced from the service layer once the real implementation lands. + @SuppressWarnings("unused") + private static final List UNUSED_SHAPE_REFERENCE = List.of(); +} diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls new file mode 100644 index 0000000000..5158dc9eb7 --- /dev/null +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -0,0 +1,253 @@ +# Software inventory (read side) + install / uninstall / update dispatch (write side). +# +# The wire contract is stable so the frontend can start binding against it; +# resolvers currently return null / empty because the feature is stubbed under +# a Conditional-property. Enable via openframe.software-management.enabled=true. + +extend type Query { + """Single software row by id (Relay global id). Null when unknown.""" + software(id: ID!): Software + + """Paginated list of software titles installed across the tenant's fleet. + One row = one software title (aggregated across devices).""" + softwares( + filter: SoftwareFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareConnection! + + """Devices that have a given software title installed, with each device's own + installed version and per-device software status (OUTDATED / SCHEDULED_UPDATE / …). + Powers the Software → Devices tab.""" + softwareDevices( + softwareId: ID!, + filter: SoftwareOnDeviceFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareOnDeviceConnection! + + """CVEs affecting a given software title. Powers the Software → Vulnerabilities tab.""" + softwareVulnerabilities( + softwareId: ID!, + filter: SoftwareVulnerabilityFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): SoftwareVulnerabilityConnection! + + """Faceted filter-option counts for the software list — Type / Source / Version-status / + Vulnerability-severity dropdowns. Mirrors scriptFilters / deviceFilters.""" + softwareFilters(filter: SoftwareFilterInput, search: String): SoftwareFilters! +} + +extend type Mutation { + """Dispatch an install of the given software title to the target machines. + Idempotent — a machine that already has the latest version is a no-op on the agent.""" + installSoftware(input: InstallSoftwareInput!): DispatchResponse! + + """Dispatch an uninstall of the given software title from the target machines.""" + uninstallSoftware(input: UninstallSoftwareInput!): DispatchResponse! + + """Schedule a deferred update of the given software title on the target machines + at the specified server-time instant.""" + scheduleUpdateSoftware(input: ScheduleUpdateSoftwareInput!): DispatchResponse! + + """Cancel a scheduled software install / uninstall / update by its executionId, + if still in a cancellable state.""" + cancelScheduledSoftware(executionId: ID!): DispatchResponse! +} + +# ────────── Software (aggregate row) ────────── + +type Software { + id: ID! + name: String! + publisher: String + + """Application vs Driver.""" + type: SoftwareType + + """Package-manager source — WINGET / CHOCOLATEY / BREW / UNMANAGED.""" + source: SoftwareSource + + """Most-recent version installed anywhere in the fleet — shown as CURRENT VERSION.""" + currentVersion: String + + """Most up-to-date known version — shown as Latest Version on the detail page.""" + latestVersion: String + + """UP_TO_DATE / OUTDATED / UNKNOWN — drives the "OUTDATED" chip in the list.""" + versionStatus: SoftwareVersionStatus + + """Additional older versions in use across the fleet, beyond currentVersion. + Shows as "+N older versions" line in the list.""" + olderVersionsCount: Int + + """Number of devices with this software installed.""" + devicesCount: Int + + """Vulnerability roll-up — highest severity + CVE count for the badge.""" + vulnerabilitySummary: SoftwareVulnerabilitySummary + + """False when the vulnerability scanner could not find a matching CPE entry + — shown as "no CPE match" instead of a severity badge.""" + cpeMatched: Boolean +} + +type SoftwareVulnerabilitySummary { + highestSeverity: SoftwareCveSeverity + cveCount: Int! +} + +# ────────── Software on device (per-device row) ────────── + +type SoftwareOnDevice { + device: Machine! + softwareVersion: String + status: SoftwareOnDeviceStatus +} + +# ────────── Vulnerability (CVE row) ────────── + +type SoftwareVulnerability { + cveId: String! + severity: SoftwareCveSeverity + cvssScore: Float + affectedVersion: String + publishedAt: String +} + +# ────────── Enums ────────── + +enum SoftwareType { + APPLICATION + DRIVER +} + +enum SoftwareSource { + WINGET + CHOCOLATEY + BREW + UNMANAGED +} + +enum SoftwareVersionStatus { + UP_TO_DATE + OUTDATED + UNKNOWN +} + +enum SoftwareCveSeverity { + CRITICAL + HIGH + MEDIUM + LOW + NONE +} + +"""Per-device software state: static (UP_TO_DATE / OUTDATED) or in a lifecycle +transition (SCHEDULED_UPDATE / UNINSTALLING / SCHEDULED_UNINSTALL).""" +enum SoftwareOnDeviceStatus { + UP_TO_DATE + OUTDATED + SCHEDULED_UPDATE + UNINSTALLING + SCHEDULED_UNINSTALL +} + +# ────────── Connections (Relay-style) ────────── + +type SoftwareConnection { + edges: [SoftwareEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareEdge { + node: Software! + cursor: String! +} + +type SoftwareOnDeviceConnection { + edges: [SoftwareOnDeviceEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareOnDeviceEdge { + node: SoftwareOnDevice! + cursor: String! +} + +type SoftwareVulnerabilityConnection { + edges: [SoftwareVulnerabilityEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type SoftwareVulnerabilityEdge { + node: SoftwareVulnerability! + cursor: String! +} + +# ────────── Facet dropdowns ────────── + +type SoftwareFilters { + types: [SoftwareFilterOption!]! + sources: [SoftwareFilterOption!]! + versionStatuses: [SoftwareFilterOption!]! + severities: [SoftwareFilterOption!]! +} + +type SoftwareFilterOption { + value: String! + label: String! + count: Int! +} + +# ────────── Inputs ────────── + +input SoftwareFilterInput { + types: [SoftwareType!] + sources: [SoftwareSource!] + versionStatuses: [SoftwareVersionStatus!] + minSeverity: SoftwareCveSeverity + deviceTagIds: [ID!] +} + +input SoftwareOnDeviceFilterInput { + statuses: [SoftwareOnDeviceStatus!] + deviceTagIds: [ID!] +} + +input SoftwareVulnerabilityFilterInput { + severities: [SoftwareCveSeverity!] +} + +input InstallSoftwareInput { + softwareId: ID! + machineIds: [ID!]! +} + +input UninstallSoftwareInput { + softwareId: ID! + machineIds: [ID!]! +} + +input ScheduleUpdateSoftwareInput { + softwareId: ID! + machineIds: [ID!]! + """ISO-8601 UTC instant of when to fire the update.""" + scheduledAt: String! +} From 70780b6490462ca51c1c91ac0d78a73ad7f5208b Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Mon, 7 Sep 2026 15:52:05 +0300 Subject: [PATCH 02/14] Removed redundant javadocs --- .../dto/rmm/software/ScheduleUpdateSoftwareInput.java | 1 - .../api/dto/rmm/software/SoftwareCveSeverity.java | 1 - .../api/dto/rmm/software/SoftwareOnDeviceResponse.java | 5 ----- .../api/dto/rmm/software/SoftwareOnDeviceStatus.java | 5 ----- .../api/dto/rmm/software/SoftwareResponse.java | 1 - .../openframe/api/dto/rmm/software/SoftwareSource.java | 5 ----- .../api/dto/rmm/software/SoftwareVersionStatus.java | 1 - .../rmm/software/SoftwareVulnerabilityResponse.java | 1 - .../software/SoftwareVulnerabilitySummaryResponse.java | 1 - .../service/rmm/software/SoftwareDispatchService.java | 10 ---------- .../service/rmm/software/SoftwareInventoryService.java | 10 ---------- 11 files changed, 41 deletions(-) diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java index 0414633e48..4a3fa9ed93 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java @@ -15,7 +15,6 @@ public class ScheduleUpdateSoftwareInput { @NotEmpty private List machineIds; - /** UTC instant at which the update should fire on each device. */ @NotNull private Instant scheduledAt; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java index 7521279151..b577a8fa07 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareCveSeverity.java @@ -1,6 +1,5 @@ package com.openframe.api.dto.rmm.software; -/** CVSS-derived severity bucket. Ordered from most to least severe. */ public enum SoftwareCveSeverity { CRITICAL, HIGH, diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java index 0f4ea27db3..41b21aaf33 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java @@ -5,11 +5,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * Per-device row for a software title. The {@code device} GraphQL field is - * resolved in the DataFetcher by looking up {@code machineId} against the - * device service. - */ @Data @Builder @NoArgsConstructor diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java index dac889812c..60cfdebe0a 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceStatus.java @@ -1,10 +1,5 @@ package com.openframe.api.dto.rmm.software; -/** - * Per-device software state — static (UP_TO_DATE / OUTDATED) or a lifecycle - * transition triggered by a pending dispatch (SCHEDULED_UPDATE / UNINSTALLING / - * SCHEDULED_UNINSTALL). - */ public enum SoftwareOnDeviceStatus { UP_TO_DATE, OUTDATED, diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java index fb399d4203..3cc25b3a0e 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java @@ -5,7 +5,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** Aggregate software row — one per software title, rolled up across the tenant's fleet. */ @Data @Builder @NoArgsConstructor diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java index 09da03d803..6cfa47fc29 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareSource.java @@ -1,10 +1,5 @@ package com.openframe.api.dto.rmm.software; -/** - * Package-manager source of the software as reported by the OS inventory scan. - * UNMANAGED covers everything installed outside a supported package manager - * (custom MSI, driver bundles, side-loaded apps). - */ public enum SoftwareSource { WINGET, CHOCOLATEY, diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java index 004f29c323..b7ed5a3f98 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVersionStatus.java @@ -1,6 +1,5 @@ package com.openframe.api.dto.rmm.software; -/** Fleet-wide freshness of a software title's most-installed version vs. the latest known. */ public enum SoftwareVersionStatus { UP_TO_DATE, OUTDATED, diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java index d9aa59c91e..d295a6ec96 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilityResponse.java @@ -7,7 +7,6 @@ import java.time.Instant; -/** Single CVE row for a software title. */ @Data @Builder @NoArgsConstructor diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java index 2754471a20..dc6352b210 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareVulnerabilitySummaryResponse.java @@ -5,7 +5,6 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** Vulnerability roll-up for a software row — highest severity + total CVE count. */ @Data @Builder @NoArgsConstructor diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java index cf5ee15d0e..b247296885 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java @@ -8,16 +8,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; -/** - * Write-side service for the Software Management feature — dispatches install / - * uninstall / scheduled-update / cancel operations to agents via the RMM - * pipeline (system-preset scripts per package-manager source). - * - *

Stub: every method returns {@code null}. Real implementation will - * reuse the RMM {@code Script}/{@code SystemScriptDispatchService} pipeline - * with a package-manager-specific script per software source (WINGET / CHOCO / - * BREW). Gated by {@code openframe.software-management.enabled}. - */ @Slf4j @Service @ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java index 80819baaab..c0f8eb95bd 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -10,16 +10,6 @@ import java.util.List; import java.util.Optional; -/** - * Read-side service for the Software Management feature — aggregate list of - * software titles, per-title device list, and per-title CVE list. - * - *

Stub: every method returns {@code null} / {@code Optional.empty()} / - * {@code List.of()}. Real implementation will read from a Mongo materialized - * view populated by a Fleet REST poller (see software management design). The - * bean is gated by {@code openframe.software-management.enabled} so it is not - * loaded in production until the feature is turned on. - */ @Slf4j @Service @ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") From 6435f5677015d55fe7d7aaed020063c00c9303c2 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Mon, 7 Sep 2026 15:56:04 +0300 Subject: [PATCH 03/14] Removed redundant javadocs --- .../datafetcher/rmm/SoftwareDataFetcher.java | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java index 286893200a..3106363d39 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -17,22 +17,6 @@ import java.util.List; -/** - * GraphQL resolver for the Software Management surface — All Software list, - * per-title Devices tab, per-title Vulnerabilities tab, and install / uninstall - * / scheduled-update mutations. - * - *

Skeleton: wire contract is stable so the frontend can bind against - * it, but resolvers delegate to stub services that return {@code null} / - * empty. The whole component is gated by - * {@code openframe.software-management.enabled} — omit the flag in prod until - * the feature is ready. - * - *

Faceted filters, nested {@code SoftwareOnDevice.device} resolver, and - * {@code Software.vulnerabilitySummary} field resolver will be added when the - * backing services are implemented; today the DTOs already carry the summary - * inline, so the default field resolvers cover the read-path. - */ @DgsComponent @ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") @RequiredArgsConstructor @@ -42,8 +26,6 @@ public class SoftwareDataFetcher { private final SoftwareInventoryService softwareInventoryService; private final SoftwareDispatchService softwareDispatchService; - // ────────── Queries ────────── - @DgsQuery public SoftwareResponse software(@InputArgument String id) { return softwareInventoryService.findById(id).orElse(null); @@ -82,8 +64,6 @@ public Object softwareFilters(@InputArgument Object filter, @InputArgument Strin return null; } - // ────────── Mutations ────────── - @DgsMutation public DispatchResponse installSoftware(@InputArgument InstallSoftwareInput input) { return softwareDispatchService.install(input, currentUserId()); @@ -104,17 +84,9 @@ public DispatchResponse cancelScheduledSoftware(@InputArgument String executionI return softwareDispatchService.cancelScheduled(executionId, currentUserId()); } - /** - * Placeholder — will be replaced with the standard security-context helper - * used by the rest of the datafetchers (see {@code CommandDataFetcher}). - * Returns {@code null} in the stub. - */ private String currentUserId() { return null; } - // Suppress unused-import warning in the stub — List shape - // is referenced from the service layer once the real implementation lands. - @SuppressWarnings("unused") private static final List UNUSED_SHAPE_REFERENCE = List.of(); } From 81812ea855e6febacce7f23a887be0cd4856ce78 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Tue, 8 Sep 2026 20:04:32 +0300 Subject: [PATCH 04/14] Updated API for Software. Added API for Vulnerabilities. --- .../rmm/software/InstallSoftwareInput.java | 16 -- .../software/ScheduleUpdateSoftwareInput.java | 20 -- .../dto/rmm/software/SoftwareFilterInput.java | 17 ++ .../dto/rmm/software/SoftwareResponse.java | 1 - .../api/dto/rmm/software/SoftwareType.java | 6 - .../rmm/software/UninstallSoftwareInput.java | 16 -- .../rmm/software/FleetSoftwareCategory.java | 36 +++ .../rmm/software/FleetSoftwareMapper.java | 64 ++++++ .../software/FleetVulnerabilityMapper.java | 51 +++++ .../api/service/rmm/software/PageResult.java | 10 + .../rmm/software/SoftwareDispatchService.java | 36 --- .../software/SoftwareInventoryService.java | 210 ++++++++++++++++-- .../datafetcher/rmm/SoftwareDataFetcher.java | 113 ++++++---- .../main/resources/schema/software.graphqls | 46 +--- .../sdk/fleetmdm/FleetMdmClient.java | 66 +++++- .../sdk/fleetmdm/model/SoftwareTitle.java | 33 +++ .../fleetmdm/model/SoftwareTitleRequest.java | 25 +++ .../fleetmdm/model/SoftwareTitleVersion.java | 20 ++ .../model/SoftwareTitlesResponse.java | 33 +++ .../sdk/fleetmdm/model/Vulnerability.java | 31 +-- 20 files changed, 631 insertions(+), 219 deletions(-) delete mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java delete mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java delete mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java delete mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java delete mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitle.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleRequest.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleVersion.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitlesResponse.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java deleted file mode 100644 index 003ad438fb..0000000000 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/InstallSoftwareInput.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.openframe.api.dto.rmm.software; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.util.List; - -@Data -public class InstallSoftwareInput { - @NotNull - private String softwareId; - - @NotEmpty - private List machineIds; -} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java deleted file mode 100644 index 4a3fa9ed93..0000000000 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/ScheduleUpdateSoftwareInput.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.openframe.api.dto.rmm.software; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.time.Instant; -import java.util.List; - -@Data -public class ScheduleUpdateSoftwareInput { - @NotNull - private String softwareId; - - @NotEmpty - private List machineIds; - - @NotNull - private Instant scheduledAt; -} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java new file mode 100644 index 0000000000..12973114a1 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterInput.java @@ -0,0 +1,17 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Data; + +import java.util.List; + +@Data +public class SoftwareFilterInput { + + private List sources; + + private List versionStatuses; + + private SoftwareCveSeverity minSeverity; + + private List deviceTagIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java index 3cc25b3a0e..f6fdce1ba6 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareResponse.java @@ -13,7 +13,6 @@ public class SoftwareResponse { private String id; private String name; private String publisher; - private SoftwareType type; private SoftwareSource source; private String currentVersion; private String latestVersion; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java deleted file mode 100644 index ff2b11a66a..0000000000 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.openframe.api.dto.rmm.software; - -public enum SoftwareType { - APPLICATION, - DRIVER -} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java deleted file mode 100644 index fe080bf14b..0000000000 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UninstallSoftwareInput.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.openframe.api.dto.rmm.software; - -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -import java.util.List; - -@Data -public class UninstallSoftwareInput { - @NotNull - private String softwareId; - - @NotEmpty - private List machineIds; -} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java new file mode 100644 index 0000000000..bde2e3a436 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java @@ -0,0 +1,36 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareSource; + +import java.util.Set; + +enum FleetSoftwareCategory { + + CHOCOLATEY(SoftwareSource.CHOCOLATEY, "chocolatey_packages"), + HOMEBREW(SoftwareSource.BREW, "homebrew_packages"), + OTHER(SoftwareSource.UNMANAGED); + + private final SoftwareSource source; + private final Set fleetSources; + + FleetSoftwareCategory(SoftwareSource source, String... fleetSources) { + this.source = source; + this.fleetSources = Set.of(fleetSources); + } + + SoftwareSource source() { + return source; + } + + /** Never returns null — unknown / missing input falls back to {@link #OTHER}. */ + static FleetSoftwareCategory of(String fleetSource) { + if (fleetSource != null) { + for (FleetSoftwareCategory c : values()) { + if (c.fleetSources.contains(fleetSource)) { + return c; + } + } + } + return OTHER; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java new file mode 100644 index 0000000000..e02195a244 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java @@ -0,0 +1,64 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareResponse; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilitySummaryResponse; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +final class FleetSoftwareMapper { + + private FleetSoftwareMapper() { + } + + static SoftwareResponse toResponse(SoftwareTitle title) { + if (title == null) { + return null; + } + List versions = title.getVersions(); + SoftwareTitleVersion current = pickCurrentVersion(versions); + return SoftwareResponse.builder() + .id(Objects.toString(title.getId(), null)) + .name(title.getName()) + .source(FleetSoftwareCategory.of(title.getSource()).source()) + .currentVersion(current == null ? null : current.getVersion()) + .olderVersionsCount(olderVersionsCount(versions)) + .devicesCount(title.getHostsCount()) + .vulnerabilitySummary(rollUpVulnerabilities(versions)) + .build(); + } + + private static SoftwareTitleVersion pickCurrentVersion(List versions) { + if (versions == null || versions.isEmpty()) { + return null; + } + return versions.stream() + .max(Comparator.comparingInt(v -> v.getHostsCount() == null ? 0 : v.getHostsCount())) + .orElse(versions.get(0)); + } + + private static Integer olderVersionsCount(List versions) { + if (versions == null || versions.isEmpty()) { + return 0; + } + return versions.size() - 1; + } + + private static SoftwareVulnerabilitySummaryResponse rollUpVulnerabilities(List versions) { + if (versions == null || versions.isEmpty()) { + return null; + } + int total = versions.stream() + .mapToInt(v -> v.getVulnerabilities() == null ? 0 : v.getVulnerabilities().size()) + .sum(); + if (total == 0) { + return null; + } + return SoftwareVulnerabilitySummaryResponse.builder() + .cveCount(total) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java new file mode 100644 index 0000000000..8690774d8b --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetVulnerabilityMapper.java @@ -0,0 +1,51 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; + +import java.time.Instant; +import java.time.format.DateTimeParseException; + +final class FleetVulnerabilityMapper { + + private FleetVulnerabilityMapper() { + } + + static SoftwareVulnerabilityResponse toResponse(String cveId, Vulnerability enrichment, String affectedVersion) { + if (cveId == null) { + return null; + } + Double cvss = enrichment == null ? null : enrichment.getCvssScore(); + String published = enrichment == null ? null : enrichment.getCvePublished(); + return SoftwareVulnerabilityResponse.builder() + .cveId(cveId) + .severity(bucketSeverity(cvss)) + .cvssScore(cvss) + .affectedVersion(affectedVersion) + .publishedAt(parseInstant(published)) + .build(); + } + + private static SoftwareCveSeverity bucketSeverity(Double cvss) { + if (cvss == null) { + return null; + } + if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL; + if (cvss >= 7.0) return SoftwareCveSeverity.HIGH; + if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM; + if (cvss > 0.0) return SoftwareCveSeverity.LOW; + return SoftwareCveSeverity.NONE; + } + + private static Instant parseInstant(String iso) { + if (iso == null || iso.isBlank()) { + return null; + } + try { + return Instant.parse(iso); + } catch (DateTimeParseException e) { + return null; + } + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java new file mode 100644 index 0000000000..8ef61ffe96 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java @@ -0,0 +1,10 @@ +package com.openframe.api.service.rmm.software; + +import java.util.List; + +public record PageResult(List items, boolean hasNext, boolean hasPrevious, int filteredCount, int page) { + + public static PageResult empty(int page) { + return new PageResult<>(List.of(), false, false, 0, page); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java deleted file mode 100644 index b247296885..0000000000 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareDispatchService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.openframe.api.service.rmm.software; - -import com.openframe.api.dto.rmm.DispatchResponse; -import com.openframe.api.dto.rmm.software.InstallSoftwareInput; -import com.openframe.api.dto.rmm.software.ScheduleUpdateSoftwareInput; -import com.openframe.api.dto.rmm.software.UninstallSoftwareInput; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") -public class SoftwareDispatchService { - - public DispatchResponse install(InstallSoftwareInput input, String initiatedBy) { - log.debug("[software-mgmt stub] install softwareId={} initiatedBy={}", input.getSoftwareId(), initiatedBy); - return null; - } - - public DispatchResponse uninstall(UninstallSoftwareInput input, String initiatedBy) { - log.debug("[software-mgmt stub] uninstall softwareId={} initiatedBy={}", input.getSoftwareId(), initiatedBy); - return null; - } - - public DispatchResponse scheduleUpdate(ScheduleUpdateSoftwareInput input, String initiatedBy) { - log.debug("[software-mgmt stub] scheduleUpdate softwareId={} initiatedBy={} scheduledAt={}", - input.getSoftwareId(), initiatedBy, input.getScheduledAt()); - return null; - } - - public DispatchResponse cancelScheduled(String executionId, String actorUserId) { - log.debug("[software-mgmt stub] cancelScheduled executionId={} actorUserId={}", executionId, actorUserId); - return null; - } -} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java index c0f8eb95bd..163814e82d 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -1,53 +1,219 @@ package com.openframe.api.service.rmm.software; -import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.tool.IntegratedToolId; +import com.openframe.data.document.tool.ToolApiKey; +import com.openframe.data.document.tool.ToolCredentials; +import com.openframe.data.document.tool.ToolUrl; +import com.openframe.data.document.tool.ToolUrlType; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.exception.FleetMdmException; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.util.Comparator; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.springframework.util.CollectionUtils.isEmpty; +import static org.springframework.util.StringUtils.hasText; @Slf4j @Service @ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@RequiredArgsConstructor public class SoftwareInventoryService { + private static final String PORT_SEPARATOR = ":"; + + private final IntegratedToolRepository integratedToolRepository; + private final TenantIdProvider tenantIdProvider; + public Optional findById(String softwareId) { - log.debug("[software-mgmt stub] findById softwareId={}", softwareId); - return Optional.empty(); + return parseNumericId(softwareId) + .map(id -> callFleet(client -> client.getSoftwareTitle(id), "get Fleet software title id=" + id)) + .map(FleetSoftwareMapper::toResponse); + } + + public PageResult listSoftware(String search, int page, Integer perPage, + String orderKey, String orderDirection, Boolean vulnerable) { + SoftwareTitleRequest request = SoftwareTitleRequest.builder() + .page(page).perPage(perPage).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .vulnerable(vulnerable) + .build(); + SoftwareTitlesResponse response = callFleet( + client -> client.listSoftwareTitles(request), + "list Fleet software titles"); + List items = response.getSoftwareTitles() == null + ? List.of() + : response.getSoftwareTitles().stream() + .map(FleetSoftwareMapper::toResponse) + .filter(Objects::nonNull) + .toList(); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + boolean hasPrev = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults()); + int total = response.getCount() == null ? items.size() : response.getCount(); + return new PageResult<>(items, hasNext, hasPrev, total, page); + } + + public PageResult listVulnerabilitiesForSoftware( + String softwareId, String search, int page, Integer perPage, + String sortField, boolean sortAsc) { + Optional parsed = parseNumericId(softwareId); + if (parsed.isEmpty()) { + return PageResult.empty(page); + } + SoftwareTitle title = callFleet( + client -> client.getSoftwareTitle(parsed.get()), + "get Fleet software title id=" + parsed.get()); + if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) { + return PageResult.empty(page); + } + List pairs = collectVersionCves(title.getVersions()); + if (pairs.isEmpty()) { + return PageResult.empty(page); + } + Map enrichment = enrichCves(uniqueCves(pairs)); + List all = pairs.stream() + .map(p -> FleetVulnerabilityMapper.toResponse(p.cve(), enrichment.get(p.cve()), p.version())) + .filter(Objects::nonNull) + .filter(v -> matchesSearch(v, search)) + .sorted(comparator(sortField, sortAsc)) + .toList(); + return paginate(all, page, perPage); + } + + private static Optional parseNumericId(String softwareId) { + if (!hasText(softwareId)) { + return Optional.empty(); + } + try { + return Optional.of(Long.parseLong(softwareId)); + } catch (NumberFormatException e) { + log.debug("softwareId={} is not numeric — Fleet ids are numeric, returning empty", softwareId); + return Optional.empty(); + } + } + + private static List collectVersionCves(List versions) { + return versions.stream() + .filter(v -> v.getVulnerabilities() != null) + .flatMap(v -> v.getVulnerabilities().stream() + .filter(cve -> hasText(cve)) + .map(cve -> new VersionCve(v.getVersion(), cve))) + .toList(); } - public List listSoftware(Object filter, String search, Object pagination, Object sort) { - log.debug("[software-mgmt stub] listSoftware"); - return List.of(); + private static Set uniqueCves(List pairs) { + return pairs.stream().map(VersionCve::cve).collect(Collectors.toSet()); } - public long countSoftware(Object filter, String search) { - return 0L; + private Map enrichCves(Set cves) { + return cves.parallelStream().collect(Collectors.toConcurrentMap( + cve -> cve, + cve -> Optional.ofNullable(callFleet( + client -> client.getVulnerability(cve), + "get Fleet vulnerability " + cve)).orElse(null))); + } + + private static boolean matchesSearch(SoftwareVulnerabilityResponse row, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + return row.getCveId() != null && row.getCveId().toLowerCase(Locale.ROOT).contains(needle); + } + + private static Comparator comparator(String field, boolean ascending) { + Comparator base = switch (field == null ? "" : field) { + case "severity", "cvssScore" -> Comparator.comparing( + SoftwareVulnerabilityResponse::getCvssScore, + Comparator.nullsLast(Comparator.naturalOrder())); + case "publishedAt", "published" -> Comparator.comparing( + SoftwareVulnerabilityResponse::getPublishedAt, + Comparator.nullsLast(Comparator.naturalOrder())); + default -> Comparator.comparing( + SoftwareVulnerabilityResponse::getCveId, + Comparator.nullsLast(Comparator.naturalOrder())); + }; + return ascending ? base : base.reversed(); + } + + private static PageResult paginate(List all, + int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } + + private record VersionCve(String version, String cve) { + } + + // ────────── Fleet SDK plumbing ────────── + + private T callFleet(FleetSdkCall call, String action) { + try { + return call.execute(fleetClient()); + } catch (IOException e) { + throw new FleetMdmException("Failed to " + action, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FleetMdmException("Interrupted while " + action, e); + } } - public List listDevicesForSoftware(String softwareId, - Object filter, String search, - Object pagination, Object sort) { - log.debug("[software-mgmt stub] listDevicesForSoftware softwareId={}", softwareId); - return List.of(); + @FunctionalInterface + private interface FleetSdkCall { + T execute(FleetMdmClient client) throws IOException, InterruptedException; } - public long countDevicesForSoftware(String softwareId, Object filter, String search) { - return 0L; + private FleetMdmClient fleetClient() { + String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); + IntegratedTool tool = integratedToolRepository.findByKey(key) + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); + return new FleetMdmClient(resolveApiUrl(tool), resolveApiToken(tool), tenantIdProvider.getTenantId()); } - public List listVulnerabilitiesForSoftware(String softwareId, - Object filter, String search, - Object pagination, Object sort) { - log.debug("[software-mgmt stub] listVulnerabilitiesForSoftware softwareId={}", softwareId); - return List.of(); + private static String resolveApiUrl(IntegratedTool tool) { + List urls = tool.getToolUrls(); + if (isEmpty(urls)) { + throw new IllegalStateException("Fleet MDM tool has no configured URLs"); + } + ToolUrl api = urls.stream() + .filter(u -> u.getType() == ToolUrlType.API) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool has no API URL")); + return hasText(api.getPort()) ? api.getUrl() + PORT_SEPARATOR + api.getPort() : api.getUrl(); } - public long countVulnerabilitiesForSoftware(String softwareId, Object filter, String search) { - return 0L; + private static String resolveApiToken(IntegratedTool tool) { + ToolCredentials credentials = tool.getCredentials(); + ToolApiKey apiKey = credentials == null ? null : credentials.getApiKey(); + if (apiKey == null || !hasText(apiKey.getKey())) { + throw new IllegalStateException("Fleet MDM tool has no API token configured"); + } + return apiKey.getKey(); } } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java index 3106363d39..fbdd7595aa 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -1,21 +1,28 @@ package com.openframe.api.datafetcher.rmm; import com.netflix.graphql.dgs.DgsComponent; -import com.netflix.graphql.dgs.DgsMutation; import com.netflix.graphql.dgs.DgsQuery; import com.netflix.graphql.dgs.InputArgument; -import com.openframe.api.dto.rmm.DispatchResponse; -import com.openframe.api.dto.rmm.software.InstallSoftwareInput; -import com.openframe.api.dto.rmm.software.ScheduleUpdateSoftwareInput; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareFilterInput; import com.openframe.api.dto.rmm.software.SoftwareResponse; -import com.openframe.api.dto.rmm.software.UninstallSoftwareInput; -import com.openframe.api.service.rmm.software.SoftwareDispatchService; +import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; +import com.openframe.api.dto.shared.PageInfo; +import com.openframe.api.dto.shared.SortDirection; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.service.rmm.software.PageResult; import com.openframe.api.service.rmm.software.SoftwareInventoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; +import java.util.Locale; +import java.util.Map; @DgsComponent @ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") @@ -23,8 +30,12 @@ @Slf4j public class SoftwareDataFetcher { + private static final Map FLEET_ORDER_KEY = Map.of( + "name", "name", + "devicesCount", "hosts_count", + "hosts_count", "hosts_count"); + private final SoftwareInventoryService softwareInventoryService; - private final SoftwareDispatchService softwareDispatchService; @DgsQuery public SoftwareResponse software(@InputArgument String id) { @@ -32,12 +43,21 @@ public SoftwareResponse software(@InputArgument String id) { } @DgsQuery - public Object softwares(@InputArgument Object filter, - @InputArgument Integer first, @InputArgument String after, - @InputArgument Integer last, @InputArgument String before, - @InputArgument String search, @InputArgument Object sort) { - log.debug("[software-mgmt stub] softwares query"); - return null; + public CountedGenericConnection> softwares( + @InputArgument SoftwareFilterInput filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField()); + String orderDirection = sort == null || sort.getDirection() == null + ? null : sort.getDirection().name().toLowerCase(Locale.ROOT); + Boolean vulnerable = filter != null && filter.getMinSeverity() != null + && filter.getMinSeverity() != SoftwareCveSeverity.NONE + ? Boolean.TRUE : null; + return toConnection(softwareInventoryService.listSoftware( + search, page, perPage, orderKey, orderDirection, vulnerable)); } @DgsQuery @@ -50,12 +70,17 @@ public Object softwareDevices(@InputArgument String softwareId, @InputArgument O } @DgsQuery - public Object softwareVulnerabilities(@InputArgument String softwareId, @InputArgument Object filter, - @InputArgument Integer first, @InputArgument String after, - @InputArgument Integer last, @InputArgument String before, - @InputArgument String search, @InputArgument Object sort) { - log.debug("[software-mgmt stub] softwareVulnerabilities query softwareId={}", softwareId); - return null; + public CountedGenericConnection> softwareVulnerabilities( + @InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + String sortField = sort == null ? null : sort.getField(); + boolean asc = sort != null && sort.getDirection() == SortDirection.ASC; + return toConnection(softwareInventoryService.listVulnerabilitiesForSoftware( + softwareId, search, page, perPage, sortField, asc)); } @DgsQuery @@ -64,29 +89,39 @@ public Object softwareFilters(@InputArgument Object filter, @InputArgument Strin return null; } - @DgsMutation - public DispatchResponse installSoftware(@InputArgument InstallSoftwareInput input) { - return softwareDispatchService.install(input, currentUserId()); - } - - @DgsMutation - public DispatchResponse uninstallSoftware(@InputArgument UninstallSoftwareInput input) { - return softwareDispatchService.uninstall(input, currentUserId()); + private static int decodePage(String cursor) { + if (cursor == null || cursor.isBlank()) { + return 0; + } + try { + return Math.max(0, Integer.parseInt(new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8))); + } catch (IllegalArgumentException e) { + log.debug("invalid cursor '{}' — starting from page 0", cursor); + return 0; + } } - @DgsMutation - public DispatchResponse scheduleUpdateSoftware(@InputArgument ScheduleUpdateSoftwareInput input) { - return softwareDispatchService.scheduleUpdate(input, currentUserId()); + private static String encodePage(int page) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(Integer.toString(page).getBytes(StandardCharsets.UTF_8)); } - @DgsMutation - public DispatchResponse cancelScheduledSoftware(@InputArgument String executionId) { - return softwareDispatchService.cancelScheduled(executionId, currentUserId()); + private static CountedGenericConnection> toConnection(PageResult page) { + String currentCursor = encodePage(page.page()); + String nextCursor = page.hasNext() ? encodePage(page.page() + 1) : null; + List> edges = page.items().stream() + .map(node -> GenericEdge.builder().node(node).cursor(currentCursor).build()) + .toList(); + PageInfo pageInfo = PageInfo.builder() + .hasNextPage(page.hasNext()) + .hasPreviousPage(page.hasPrevious()) + .startCursor(edges.isEmpty() ? null : currentCursor) + .endCursor(nextCursor != null ? nextCursor : (edges.isEmpty() ? null : currentCursor)) + .build(); + return CountedGenericConnection.>builder() + .edges(edges) + .pageInfo(pageInfo) + .filteredCount(page.filteredCount()) + .build(); } - - private String currentUserId() { - return null; - } - - private static final List UNUSED_SHAPE_REFERENCE = List.of(); } diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls index 5158dc9eb7..71a2b17949 100644 --- a/openframe-api-service-core/src/main/resources/schema/software.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -1,4 +1,4 @@ -# Software inventory (read side) + install / uninstall / update dispatch (write side). +# Software inventory (read side, thin proxy over Fleet MDM REST). # # The wire contract is stable so the frontend can start binding against it; # resolvers currently return null / empty because the feature is stubbed under @@ -51,23 +51,6 @@ extend type Query { softwareFilters(filter: SoftwareFilterInput, search: String): SoftwareFilters! } -extend type Mutation { - """Dispatch an install of the given software title to the target machines. - Idempotent — a machine that already has the latest version is a no-op on the agent.""" - installSoftware(input: InstallSoftwareInput!): DispatchResponse! - - """Dispatch an uninstall of the given software title from the target machines.""" - uninstallSoftware(input: UninstallSoftwareInput!): DispatchResponse! - - """Schedule a deferred update of the given software title on the target machines - at the specified server-time instant.""" - scheduleUpdateSoftware(input: ScheduleUpdateSoftwareInput!): DispatchResponse! - - """Cancel a scheduled software install / uninstall / update by its executionId, - if still in a cancellable state.""" - cancelScheduledSoftware(executionId: ID!): DispatchResponse! -} - # ────────── Software (aggregate row) ────────── type Software { @@ -75,9 +58,6 @@ type Software { name: String! publisher: String - """Application vs Driver.""" - type: SoftwareType - """Package-manager source — WINGET / CHOCOLATEY / BREW / UNMANAGED.""" source: SoftwareSource @@ -130,11 +110,6 @@ type SoftwareVulnerability { # ────────── Enums ────────── -enum SoftwareType { - APPLICATION - DRIVER -} - enum SoftwareSource { WINGET CHOCOLATEY @@ -204,7 +179,6 @@ type SoftwareVulnerabilityEdge { # ────────── Facet dropdowns ────────── type SoftwareFilters { - types: [SoftwareFilterOption!]! sources: [SoftwareFilterOption!]! versionStatuses: [SoftwareFilterOption!]! severities: [SoftwareFilterOption!]! @@ -219,7 +193,6 @@ type SoftwareFilterOption { # ────────── Inputs ────────── input SoftwareFilterInput { - types: [SoftwareType!] sources: [SoftwareSource!] versionStatuses: [SoftwareVersionStatus!] minSeverity: SoftwareCveSeverity @@ -234,20 +207,3 @@ input SoftwareOnDeviceFilterInput { input SoftwareVulnerabilityFilterInput { severities: [SoftwareCveSeverity!] } - -input InstallSoftwareInput { - softwareId: ID! - machineIds: [ID!]! -} - -input UninstallSoftwareInput { - softwareId: ID! - machineIds: [ID!]! -} - -input ScheduleUpdateSoftwareInput { - softwareId: ID! - machineIds: [ID!]! - """ISO-8601 UTC instant of when to fire the update.""" - scheduledAt: String! -} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java index 079aa9ef0c..726e0ac590 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java @@ -16,7 +16,11 @@ import com.openframe.sdk.fleetmdm.model.UpdatePolicyRequest; import com.openframe.sdk.fleetmdm.model.CreateScheduledQueryRequest; import com.openframe.sdk.fleetmdm.model.UpdateScheduledQueryRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; +import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; import java.io.IOException; import java.net.URI; @@ -42,6 +46,8 @@ public class FleetMdmClient { private static final String LIVE_QUERY_RUN_URL = "/api/v1/fleet/queries/run"; private static final String POLICIES_DELETE_URL = "/api/latest/fleet/policies/delete"; private static final String VULNERABILITIES_URL = "/api/latest/fleet/vulnerabilities"; + private static final String SOFTWARE_TITLES_URL = "/api/latest/fleet/software/titles"; + private static final String VULNERABILITY_DETAIL_URL = "/api/latest/fleet/vulnerabilities/"; static final String TENANT_ID_HEADER = "X-Tenant-Id"; @@ -405,6 +411,60 @@ public Policy createPolicy(CreatePolicyRequest request) { } } + public SoftwareTitlesResponse listSoftwareTitles(SoftwareTitleRequest request) throws IOException, InterruptedException { + HttpResponse response = sendRequest(buildSoftwareTitlesQuery(request), "GET", null); + checkResponse(response, "list Fleet software titles"); + return MAPPER.readValue(response.body(), SoftwareTitlesResponse.class); + } + + public SoftwareTitle getSoftwareTitle(long id) throws IOException, InterruptedException { + HttpResponse response = sendRequest(SOFTWARE_TITLES_URL + "/" + id, "GET", null); + if (response.statusCode() == 404) { + return null; + } + checkResponse(response, "get Fleet software title"); + return MAPPER.treeToValue(requireNode(response.body(), "software_title"), SoftwareTitle.class); + } + + public Vulnerability getVulnerability(String cve) throws IOException, InterruptedException { + HttpResponse response = sendRequest(VULNERABILITY_DETAIL_URL + URLEncoder.encode(cve, StandardCharsets.UTF_8), + "GET", null); + if (response.statusCode() == 404) { + return null; + } + checkResponse(response, "get Fleet vulnerability"); + return MAPPER.treeToValue(requireNode(response.body(), "vulnerability"), Vulnerability.class); + } + + private static String buildSoftwareTitlesQuery(SoftwareTitleRequest request) { + StringBuilder url = new StringBuilder(SOFTWARE_TITLES_URL); + List params = new ArrayList<>(); + if (request != null) { + if (request.getPage() != null) { + params.add("page=" + request.getPage()); + } + if (request.getPerPage() != null) { + params.add("per_page=" + request.getPerPage()); + } + if (request.getQuery() != null && !request.getQuery().isBlank()) { + params.add("query=" + URLEncoder.encode(request.getQuery(), StandardCharsets.UTF_8)); + } + if (request.getOrderKey() != null && !request.getOrderKey().isBlank()) { + params.add("order_key=" + URLEncoder.encode(request.getOrderKey(), StandardCharsets.UTF_8)); + } + if (request.getOrderDirection() != null && !request.getOrderDirection().isBlank()) { + params.add("order_direction=" + URLEncoder.encode(request.getOrderDirection(), StandardCharsets.UTF_8)); + } + if (Boolean.TRUE.equals(request.getVulnerable())) { + params.add("vulnerable=true"); + } + } + if (!params.isEmpty()) { + url.append("?").append(String.join("&", params)); + } + return url.toString(); + } + /** * List vulnerabilities with pagination. */ @@ -817,7 +877,7 @@ private HttpRequest buildRequest(String path, String method, String body) { return builder.build(); } - private HttpResponse sendRequest(String path, String method, String body) throws Exception { + private HttpResponse sendRequest(String path, String method, String body) throws IOException, InterruptedException { return httpClient.send(buildRequest(path, method, body), HttpResponse.BodyHandlers.ofString()); } @@ -830,7 +890,7 @@ private static void checkResponse(HttpResponse response, String action) + (body.isEmpty() ? "" : ": " + body), response.statusCode(), body); } - private static JsonNode listNodeOrEmpty(String responseBody, String fieldName) throws Exception { + private static JsonNode listNodeOrEmpty(String responseBody, String fieldName) throws IOException { JsonNode root = MAPPER.readTree(responseBody); JsonNode node = root.get(fieldName); if (node == null || node.isNull()) { @@ -839,7 +899,7 @@ private static JsonNode listNodeOrEmpty(String responseBody, String fieldName) t return node; } - private static JsonNode requireNode(String responseBody, String fieldName) throws Exception { + private static JsonNode requireNode(String responseBody, String fieldName) throws IOException { JsonNode root = MAPPER.readTree(responseBody); JsonNode node = root.get(fieldName); if (node == null || node.isNull()) { diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitle.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitle.java new file mode 100644 index 0000000000..79f01953de --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitle.java @@ -0,0 +1,33 @@ +package com.openframe.sdk.fleetmdm.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SoftwareTitle { + + private Long id; + private String name; + + @JsonProperty("bundle_identifier") + private String bundleIdentifier; + + private String source; + + private String browser; + + @JsonProperty("hosts_count") + private Integer hostsCount; + + @JsonProperty("versions_count") + private Integer versionsCount; + + private List versions; + + @JsonProperty("counts_updated_at") + private String countsUpdatedAt; +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleRequest.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleRequest.java new file mode 100644 index 0000000000..ff569eb889 --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleRequest.java @@ -0,0 +1,25 @@ +package com.openframe.sdk.fleetmdm.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareTitleRequest { + + private Integer page; + + private Integer perPage; + + private String query; + + private String orderKey; + + private String orderDirection; + + private Boolean vulnerable; +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleVersion.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleVersion.java new file mode 100644 index 0000000000..dc78d68d86 --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitleVersion.java @@ -0,0 +1,20 @@ +package com.openframe.sdk.fleetmdm.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SoftwareTitleVersion { + + private Long id; + private String version; + + private List vulnerabilities; + + @JsonProperty("hosts_count") + private Integer hostsCount; +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitlesResponse.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitlesResponse.java new file mode 100644 index 0000000000..12b5bd0b21 --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/SoftwareTitlesResponse.java @@ -0,0 +1,33 @@ +package com.openframe.sdk.fleetmdm.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SoftwareTitlesResponse { + + @JsonProperty("software_titles") + private List softwareTitles; + + private Integer count; + + @JsonProperty("counts_updated_at") + private String countsUpdatedAt; + + private Meta meta; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Meta { + + @JsonProperty("has_next_results") + private Boolean hasNextResults; + + @JsonProperty("has_previous_results") + private Boolean hasPreviousResults; + } +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java index f117221c27..555ff8840a 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java @@ -2,10 +2,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; -/** - * Vulnerability entry from Fleet MDM - */ +@Data @JsonIgnoreProperties(ignoreUnknown = true) public class Vulnerability { @@ -14,19 +13,21 @@ public class Vulnerability { @JsonProperty("hosts_count_updated_at") private String hostsCountUpdatedAt; - public String getCve() { - return cve; - } + @JsonProperty("cvss_score") + private Double cvssScore; - public void setCve(String cve) { - this.cve = cve; - } + @JsonProperty("epss_probability") + private Double epssProbability; - public String getHostsCountUpdatedAt() { - return hostsCountUpdatedAt; - } + @JsonProperty("cve_published") + private String cvePublished; - public void setHostsCountUpdatedAt(String hostsCountUpdatedAt) { - this.hostsCountUpdatedAt = hostsCountUpdatedAt; - } + @JsonProperty("cisa_known_exploit") + private Boolean cisaKnownExploit; + + @JsonProperty("details_link") + private String detailsLink; + + @JsonProperty("resolved_in_version") + private String resolvedInVersion; } From d667e211c03f89fd2d9dbd9fc20ed31e8d496ecf Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Wed, 9 Sep 2026 20:59:49 +0300 Subject: [PATCH 05/14] Updated API for Vulnerabilities --- .../AffectedSoftwareResponse.java | 26 +++ .../VulnerabilityFilterInput.java | 12 ++ .../vulnerability/VulnerabilityResponse.java | 39 ++++ .../software => dto/shared}/PageResult.java | 2 +- .../rmm/fleet/FleetClientProvider.java | 76 +++++++ .../FleetSoftwareCategory.java | 9 +- .../rmm/software/FleetSoftwareMapper.java | 1 + .../software/SoftwareInventoryService.java | 73 +------ .../FleetGlobalVulnerabilityMapper.java | 95 +++++++++ .../VulnerabilityInventoryService.java | 83 ++++++++ .../FleetGlobalVulnerabilityMapperTest.java | 185 ++++++++++++++++ .../VulnerabilityInventoryServiceTest.java | 198 ++++++++++++++++++ .../api/datafetcher/rmm/PageCursors.java | 52 +++++ .../datafetcher/rmm/SoftwareDataFetcher.java | 49 +---- .../rmm/VulnerabilityDataFetcher.java | 58 +++++ .../main/resources/schema/software.graphqls | 78 +++++++ .../sdk/fleetmdm/FleetMdmClient.java | 71 ++++--- .../sdk/fleetmdm/model/AffectedSoftware.java | 25 +++ .../model/VulnerabilitiesResponse.java | 32 +-- .../sdk/fleetmdm/model/Vulnerability.java | 13 ++ .../fleetmdm/model/VulnerabilityRequest.java | 25 +++ 21 files changed, 1033 insertions(+), 169 deletions(-) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java rename openframe-api-lib/src/main/java/com/openframe/api/{service/rmm/software => dto/shared}/PageResult.java (84%) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java rename openframe-api-lib/src/main/java/com/openframe/api/service/rmm/{software => fleet}/FleetSoftwareCategory.java (75%) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java create mode 100644 openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java create mode 100644 openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java create mode 100644 openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java create mode 100644 openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/AffectedSoftware.java create mode 100644 sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilityRequest.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java new file mode 100644 index 0000000000..b0cd70fdd5 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/AffectedSoftwareResponse.java @@ -0,0 +1,26 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareSource; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AffectedSoftwareResponse { + + private String id; + + private String name; + + private SoftwareSource source; + + private String version; + + private Integer devicesCount; + + private String resolvedInVersion; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java new file mode 100644 index 0000000000..af35d3570f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityFilterInput.java @@ -0,0 +1,12 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import lombok.Data; + +@Data +public class VulnerabilityFilterInput { + + private SoftwareCveSeverity minSeverity; + + private Boolean exploited; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java new file mode 100644 index 0000000000..1f63e694e3 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/vulnerability/VulnerabilityResponse.java @@ -0,0 +1,39 @@ +package com.openframe.api.dto.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VulnerabilityResponse { + + private String cveId; + + private SoftwareCveSeverity severity; + + private Double cvssScore; + + private Double epssProbability; + + private Boolean cisaKnownExploit; + + private Instant publishedAt; + + private String detailsLink; + + private Integer devicesCount; + + private String description; + + private String resolvedInVersion; + + private List affectedSoftware; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java similarity index 84% rename from openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java rename to openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java index 8ef61ffe96..c96a0c2e0d 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/PageResult.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/shared/PageResult.java @@ -1,4 +1,4 @@ -package com.openframe.api.service.rmm.software; +package com.openframe.api.dto.shared; import java.util.List; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java new file mode 100644 index 0000000000..93d3d472f6 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java @@ -0,0 +1,76 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.tool.IntegratedToolId; +import com.openframe.data.document.tool.ToolApiKey; +import com.openframe.data.document.tool.ToolCredentials; +import com.openframe.data.document.tool.ToolUrl; +import com.openframe.data.document.tool.ToolUrlType; +import com.openframe.data.repository.tool.IntegratedToolRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.exception.FleetMdmException; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; + +import static org.springframework.util.CollectionUtils.isEmpty; +import static org.springframework.util.StringUtils.hasText; + +@Component +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@RequiredArgsConstructor +public class FleetClientProvider { + + private static final String PORT_SEPARATOR = ":"; + + private final IntegratedToolRepository integratedToolRepository; + private final TenantIdProvider tenantIdProvider; + + public T call(FleetSdkCall call, String action) { + try { + return call.execute(client()); + } catch (IOException e) { + throw new FleetMdmException("Failed to " + action, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FleetMdmException("Interrupted while " + action, e); + } + } + + @FunctionalInterface + public interface FleetSdkCall { + T execute(FleetMdmClient client) throws IOException, InterruptedException; + } + + private FleetMdmClient client() { + String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); + IntegratedTool tool = integratedToolRepository.findByKey(key) + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); + return new FleetMdmClient(resolveApiUrl(tool), resolveApiToken(tool), tenantIdProvider.getTenantId()); + } + + private static String resolveApiUrl(IntegratedTool tool) { + List urls = tool.getToolUrls(); + if (isEmpty(urls)) { + throw new IllegalStateException("Fleet MDM tool has no configured URLs"); + } + ToolUrl api = urls.stream() + .filter(u -> u.getType() == ToolUrlType.API) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Fleet MDM tool has no API URL")); + return hasText(api.getPort()) ? api.getUrl() + PORT_SEPARATOR + api.getPort() : api.getUrl(); + } + + private static String resolveApiToken(IntegratedTool tool) { + ToolCredentials credentials = tool.getCredentials(); + ToolApiKey apiKey = credentials == null ? null : credentials.getApiKey(); + if (apiKey == null || !hasText(apiKey.getKey())) { + throw new IllegalStateException("Fleet MDM tool has no API token configured"); + } + return apiKey.getKey(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java similarity index 75% rename from openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java rename to openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java index bde2e3a436..1adfa60c96 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareCategory.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetSoftwareCategory.java @@ -1,10 +1,10 @@ -package com.openframe.api.service.rmm.software; +package com.openframe.api.service.rmm.fleet; import com.openframe.api.dto.rmm.software.SoftwareSource; import java.util.Set; -enum FleetSoftwareCategory { +public enum FleetSoftwareCategory { CHOCOLATEY(SoftwareSource.CHOCOLATEY, "chocolatey_packages"), HOMEBREW(SoftwareSource.BREW, "homebrew_packages"), @@ -18,12 +18,11 @@ enum FleetSoftwareCategory { this.fleetSources = Set.of(fleetSources); } - SoftwareSource source() { + public SoftwareSource source() { return source; } - /** Never returns null — unknown / missing input falls back to {@link #OTHER}. */ - static FleetSoftwareCategory of(String fleetSource) { + public static FleetSoftwareCategory of(String fleetSource) { if (fleetSource != null) { for (FleetSoftwareCategory c : values()) { if (c.fleetSources.contains(fleetSource)) { diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java index e02195a244..a22e070ec1 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/FleetSoftwareMapper.java @@ -2,6 +2,7 @@ import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilitySummaryResponse; +import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory; import com.openframe.sdk.fleetmdm.model.SoftwareTitle; import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java index 163814e82d..7ba9c0f245 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -2,16 +2,8 @@ import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; -import com.openframe.data.document.tool.IntegratedTool; -import com.openframe.data.document.tool.IntegratedToolId; -import com.openframe.data.document.tool.ToolApiKey; -import com.openframe.data.document.tool.ToolCredentials; -import com.openframe.data.document.tool.ToolUrl; -import com.openframe.data.document.tool.ToolUrlType; -import com.openframe.data.repository.tool.IntegratedToolRepository; -import com.openframe.data.service.TenantIdProvider; -import com.openframe.sdk.fleetmdm.FleetMdmClient; -import com.openframe.sdk.fleetmdm.exception.FleetMdmException; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetClientProvider; import com.openframe.sdk.fleetmdm.model.SoftwareTitle; import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; @@ -22,7 +14,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; -import java.io.IOException; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -32,7 +23,6 @@ import java.util.Set; import java.util.stream.Collectors; -import static org.springframework.util.CollectionUtils.isEmpty; import static org.springframework.util.StringUtils.hasText; @Slf4j @@ -41,14 +31,11 @@ @RequiredArgsConstructor public class SoftwareInventoryService { - private static final String PORT_SEPARATOR = ":"; - - private final IntegratedToolRepository integratedToolRepository; - private final TenantIdProvider tenantIdProvider; + private final FleetClientProvider fleet; public Optional findById(String softwareId) { return parseNumericId(softwareId) - .map(id -> callFleet(client -> client.getSoftwareTitle(id), "get Fleet software title id=" + id)) + .map(id -> fleet.call(client -> client.getSoftwareTitle(id), "get Fleet software title id=" + id)) .map(FleetSoftwareMapper::toResponse); } @@ -59,7 +46,7 @@ public PageResult listSoftware(String search, int page, Intege .orderKey(orderKey).orderDirection(orderDirection) .vulnerable(vulnerable) .build(); - SoftwareTitlesResponse response = callFleet( + SoftwareTitlesResponse response = fleet.call( client -> client.listSoftwareTitles(request), "list Fleet software titles"); List items = response.getSoftwareTitles() == null @@ -83,7 +70,7 @@ public PageResult listVulnerabilitiesForSoftware( if (parsed.isEmpty()) { return PageResult.empty(page); } - SoftwareTitle title = callFleet( + SoftwareTitle title = fleet.call( client -> client.getSoftwareTitle(parsed.get()), "get Fleet software title id=" + parsed.get()); if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) { @@ -131,7 +118,7 @@ private static Set uniqueCves(List pairs) { private Map enrichCves(Set cves) { return cves.parallelStream().collect(Collectors.toConcurrentMap( cve -> cve, - cve -> Optional.ofNullable(callFleet( + cve -> Optional.ofNullable(fleet.call( client -> client.getVulnerability(cve), "get Fleet vulnerability " + cve)).orElse(null))); } @@ -170,50 +157,4 @@ private static PageResult paginate(List T callFleet(FleetSdkCall call, String action) { - try { - return call.execute(fleetClient()); - } catch (IOException e) { - throw new FleetMdmException("Failed to " + action, e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new FleetMdmException("Interrupted while " + action, e); - } - } - - @FunctionalInterface - private interface FleetSdkCall { - T execute(FleetMdmClient client) throws IOException, InterruptedException; - } - - private FleetMdmClient fleetClient() { - String key = IntegratedToolId.FLEET_SERVER_ID.getValue(); - IntegratedTool tool = integratedToolRepository.findByKey(key) - .orElseThrow(() -> new IllegalStateException("Fleet MDM tool not configured: " + key)); - return new FleetMdmClient(resolveApiUrl(tool), resolveApiToken(tool), tenantIdProvider.getTenantId()); - } - - private static String resolveApiUrl(IntegratedTool tool) { - List urls = tool.getToolUrls(); - if (isEmpty(urls)) { - throw new IllegalStateException("Fleet MDM tool has no configured URLs"); - } - ToolUrl api = urls.stream() - .filter(u -> u.getType() == ToolUrlType.API) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Fleet MDM tool has no API URL")); - return hasText(api.getPort()) ? api.getUrl() + PORT_SEPARATOR + api.getPort() : api.getUrl(); - } - - private static String resolveApiToken(IntegratedTool tool) { - ToolCredentials credentials = tool.getCredentials(); - ToolApiKey apiKey = credentials == null ? null : credentials.getApiKey(); - if (apiKey == null || !hasText(apiKey.getKey())) { - throw new IllegalStateException("Fleet MDM tool has no API token configured"); - } - return apiKey.getKey(); - } } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java new file mode 100644 index 0000000000..6145458cff --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapper.java @@ -0,0 +1,95 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.service.rmm.fleet.FleetSoftwareCategory; +import com.openframe.sdk.fleetmdm.model.AffectedSoftware; +import com.openframe.sdk.fleetmdm.model.Vulnerability; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; + +public final class FleetGlobalVulnerabilityMapper { + + private FleetGlobalVulnerabilityMapper() { + } + + public static VulnerabilityResponse toListRow(Vulnerability v) { + if (v == null || v.getCve() == null) { + return null; + } + return baseBuilder(v).build(); + } + + public static VulnerabilityResponse toDetail(Vulnerability v) { + if (v == null || v.getCve() == null) { + return null; + } + return baseBuilder(v) + .description(v.getCveDescription()) + .resolvedInVersion(v.getResolvedInVersion()) + .affectedSoftware(mapAffected(v.getSoftware())) + .build(); + } + + private static VulnerabilityResponse.VulnerabilityResponseBuilder baseBuilder(Vulnerability v) { + return VulnerabilityResponse.builder() + .cveId(v.getCve()) + .severity(bucketSeverity(v.getCvssScore())) + .cvssScore(v.getCvssScore()) + .epssProbability(v.getEpssProbability()) + .cisaKnownExploit(v.getCisaKnownExploit()) + .publishedAt(parseInstant(v.getCvePublished())) + .detailsLink(v.getDetailsLink()) + .devicesCount(v.getHostsCount()); + } + + private static List mapAffected(List software) { + if (software == null || software.isEmpty()) { + return List.of(); + } + return software.stream() + .map(FleetGlobalVulnerabilityMapper::toAffected) + .filter(Objects::nonNull) + .toList(); + } + + private static AffectedSoftwareResponse toAffected(AffectedSoftware s) { + if (s == null) { + return null; + } + return AffectedSoftwareResponse.builder() + .id(Objects.toString(s.getId(), null)) + .name(s.getName()) + .source(FleetSoftwareCategory.of(s.getSource()).source()) + .version(s.getVersion()) + .devicesCount(s.getHostsCount()) + .resolvedInVersion(s.getResolvedInVersion()) + .build(); + } + + static SoftwareCveSeverity bucketSeverity(Double cvss) { + if (cvss == null) { + return null; + } + if (cvss >= 9.0) return SoftwareCveSeverity.CRITICAL; + if (cvss >= 7.0) return SoftwareCveSeverity.HIGH; + if (cvss >= 4.0) return SoftwareCveSeverity.MEDIUM; + if (cvss > 0.0) return SoftwareCveSeverity.LOW; + return SoftwareCveSeverity.NONE; + } + + private static Instant parseInstant(String iso) { + if (iso == null || iso.isBlank()) { + return null; + } + try { + return Instant.parse(iso); + } catch (DateTimeParseException e) { + return null; + } + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java new file mode 100644 index 0000000000..ecccb71beb --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java @@ -0,0 +1,83 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetClientProvider; +import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static org.springframework.util.StringUtils.hasText; + +@Slf4j +@Service +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@RequiredArgsConstructor +public class VulnerabilityInventoryService { + + private final FleetClientProvider fleet; + + public Optional findByCveId(String cveId) { + if (!hasText(cveId)) { + return Optional.empty(); + } + Vulnerability detail = fleet.call( + client -> client.getVulnerability(cveId), + "get Fleet vulnerability " + cveId); + return Optional.ofNullable(FleetGlobalVulnerabilityMapper.toDetail(detail)); + } + + public PageResult listVulnerabilities( + String search, int page, Integer perPage, + String orderKey, String orderDirection, + Boolean exploit, SoftwareCveSeverity minSeverity) { + VulnerabilityRequest request = VulnerabilityRequest.builder() + .page(page).perPage(perPage).query(search) + .orderKey(orderKey).orderDirection(orderDirection) + .exploit(exploit) + .build(); + VulnerabilitiesResponse response = fleet.call( + client -> client.listVulnerabilities(request), + "list Fleet vulnerabilities"); + List items = response.getVulnerabilities() == null + ? List.of() + : response.getVulnerabilities().stream() + .map(FleetGlobalVulnerabilityMapper::toListRow) + .filter(Objects::nonNull) + .filter(row -> passesMinSeverity(row, minSeverity)) + .toList(); + boolean hasNext = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasNextResults()); + boolean hasPrev = response.getMeta() != null + && Boolean.TRUE.equals(response.getMeta().getHasPreviousResults()); + int total = response.getCount() == null ? items.size() : response.getCount().intValue(); + return new PageResult<>(items, hasNext, hasPrev, total, page); + } + + private static boolean passesMinSeverity(VulnerabilityResponse row, SoftwareCveSeverity min) { + if (min == null || min == SoftwareCveSeverity.NONE) { + return true; + } + SoftwareCveSeverity actual = row.getSeverity(); + return actual != null && severityRank(actual) >= severityRank(min); + } + + private static int severityRank(SoftwareCveSeverity s) { + return switch (s) { + case CRITICAL -> 4; + case HIGH -> 3; + case MEDIUM -> 2; + case LOW -> 1; + case NONE -> 0; + }; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java new file mode 100644 index 0000000000..7a52288961 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/FleetGlobalVulnerabilityMapperTest.java @@ -0,0 +1,185 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.software.SoftwareSource; +import com.openframe.api.dto.rmm.vulnerability.AffectedSoftwareResponse; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.sdk.fleetmdm.model.AffectedSoftware; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FleetGlobalVulnerabilityMapperTest { + + @Test + void toListRow_nullInput_returnsNull() { + assertNull(FleetGlobalVulnerabilityMapper.toListRow(null)); + } + + @Test + void toListRow_missingCve_returnsNullSoTheListSkipsRowsWithoutId() { + Vulnerability v = new Vulnerability(); + v.setCvssScore(7.0); + + assertNull(FleetGlobalVulnerabilityMapper.toListRow(v)); + } + + @Test + void toDetail_nullInput_returnsNull() { + assertNull(FleetGlobalVulnerabilityMapper.toDetail(null)); + } + + + @Test + void toListRow_populatesBaseFields_leavesDetailOnlyFieldsNull() { + // setup + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + v.setCvssScore(9.8); + v.setEpssProbability(0.42); + v.setCisaKnownExploit(Boolean.TRUE); + v.setCvePublished("2024-08-13T00:00:00Z"); + v.setDetailsLink("https://nvd.nist.gov/vuln/detail/CVE-2024-38063"); + v.setHostsCount(17); + v.setCveDescription("Windows TCP/IP RCE"); + v.setResolvedInVersion("2023.001.20143"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(42L); + sw.setName("WinRAR"); + v.setSoftware(List.of(sw)); + + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + assertNotNull(row); + assertEquals("CVE-2024-38063", row.getCveId()); + assertEquals(SoftwareCveSeverity.CRITICAL, row.getSeverity()); + assertEquals(9.8, row.getCvssScore()); + assertEquals(0.42, row.getEpssProbability()); + assertEquals(Boolean.TRUE, row.getCisaKnownExploit()); + assertEquals(Instant.parse("2024-08-13T00:00:00Z"), row.getPublishedAt()); + assertEquals("https://nvd.nist.gov/vuln/detail/CVE-2024-38063", row.getDetailsLink()); + assertEquals(17, row.getDevicesCount()); + assertNull(row.getDescription()); + assertNull(row.getResolvedInVersion()); + assertNull(row.getAffectedSoftware()); + } + + @Test + void toListRow_nullCvssScore_severityIsNull() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-00000"); + + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + assertNotNull(row); + assertNull(row.getSeverity()); + assertNull(row.getCvssScore()); + } + + + @Test + void toListRow_cvssBucketing_matchesNvdRanges() { + // 9.0+ → CRITICAL, 7.0..8.9 → HIGH, 4.0..6.9 → MEDIUM, >0..3.9 → LOW, 0 → NONE + assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.9)); + assertEquals(SoftwareCveSeverity.CRITICAL, severityFor(9.0)); + assertEquals(SoftwareCveSeverity.HIGH, severityFor(8.9)); + assertEquals(SoftwareCveSeverity.HIGH, severityFor(7.0)); + assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(6.9)); + assertEquals(SoftwareCveSeverity.MEDIUM, severityFor(4.0)); + assertEquals(SoftwareCveSeverity.LOW, severityFor(3.9)); + assertEquals(SoftwareCveSeverity.LOW, severityFor(0.1)); + assertEquals(SoftwareCveSeverity.NONE, severityFor(0.0)); + } + + private static SoftwareCveSeverity severityFor(double cvss) { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-x"); + v.setCvssScore(cvss); + return FleetGlobalVulnerabilityMapper.toListRow(v).getSeverity(); + } + + @Test + void toListRow_unparseablePublishedAt_leavesInstantNullInsteadOfCrashing() { + // setup — Fleet occasionally returns partial or malformed dates + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-00000"); + v.setCvePublished("not-a-date"); + + // execution + VulnerabilityResponse row = FleetGlobalVulnerabilityMapper.toListRow(v); + + // verifications — parseInstant swallows the DateTimeParseException, leaves publishedAt null + assertNull(row.getPublishedAt()); + } + + + @Test + void toDetail_populatesDetailFieldsAndAffectedSoftware() { + // setup + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + v.setCvssScore(9.2); + v.setCveDescription("Windows TCP/IP RCE"); + v.setResolvedInVersion("N/A"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(42L); + sw.setName("WinRAR"); + sw.setSource("homebrew_packages"); + sw.setVersion("6.22"); + sw.setHostsCount(2); + sw.setResolvedInVersion("6.23"); + v.setSoftware(List.of(sw)); + + // execution + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + // verifications + assertNotNull(detail); + assertEquals("Windows TCP/IP RCE", detail.getDescription()); + assertEquals("N/A", detail.getResolvedInVersion()); + assertNotNull(detail.getAffectedSoftware()); + assertEquals(1, detail.getAffectedSoftware().size()); + AffectedSoftwareResponse aff = detail.getAffectedSoftware().get(0); + assertEquals("42", aff.getId()); + assertEquals("WinRAR", aff.getName()); + assertEquals(SoftwareSource.BREW, aff.getSource()); + assertEquals("6.22", aff.getVersion()); + assertEquals(2, aff.getDevicesCount()); + assertEquals("6.23", aff.getResolvedInVersion()); + } + + @Test + void toDetail_nullSoftwareList_producesEmptyListNotNull_soFeCanTellAskedFromNotAsked() { + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + assertNotNull(detail.getAffectedSoftware()); + assertTrue(detail.getAffectedSoftware().isEmpty()); + } + + @Test + void toDetail_unknownSourceString_fallsBackToUnmanaged() { + // setup — Fleet emits sources like "programs", "apps" etc that our category doesn't map + Vulnerability v = new Vulnerability(); + v.setCve("CVE-2024-38063"); + AffectedSoftware sw = new AffectedSoftware(); + sw.setId(1L); + sw.setSource("programs"); + v.setSoftware(List.of(sw)); + + // execution + VulnerabilityResponse detail = FleetGlobalVulnerabilityMapper.toDetail(v); + + // verifications + assertEquals(SoftwareSource.UNMANAGED, detail.getAffectedSoftware().get(0).getSource()); + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java new file mode 100644 index 0000000000..6b1c3a89c2 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java @@ -0,0 +1,198 @@ +package com.openframe.api.service.rmm.vulnerability; + +import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetClientProvider; +import com.openframe.sdk.fleetmdm.FleetMdmClient; +import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; +import com.openframe.sdk.fleetmdm.model.Vulnerability; +import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VulnerabilityInventoryServiceTest { + + @Mock private FleetClientProvider fleet; + @Mock private FleetMdmClient fleetClient; + + @Captor private ArgumentCaptor> callCaptor; + + private VulnerabilityInventoryService service; + + @BeforeEach + void setUp() { + service = new VulnerabilityInventoryService(fleet); + } + + @Test + void findByCveId_blankId_returnsEmptyWithoutHittingFleet() { + // execution + verifications + assertTrue(service.findByCveId("").isEmpty()); + assertTrue(service.findByCveId(null).isEmpty()); + // no interaction with fleet — the guard fires before we dispatch + } + + @Test + void findByCveId_fleetReturnsNull_returnsEmpty() { + // setup — 404 from Fleet detail: the SDK returns null and we propagate as empty + when(fleet.call(any(), any())).thenReturn(null); + + // execution + Optional found = service.findByCveId("CVE-2024-00000"); + + // verifications + assertTrue(found.isEmpty()); + } + + @Test + void findByCveId_fleetReturnsDetail_mapsToDetailFlow() throws Exception { + // setup — verify we hit the detail (getVulnerability) not the list endpoint + Vulnerability detail = new Vulnerability(); + detail.setCve("CVE-2024-38063"); + detail.setCveDescription("Windows TCP/IP RCE"); + detail.setCvssScore(9.2); + when(fleet.call(any(), any())).thenAnswer(inv -> { + FleetClientProvider.FleetSdkCall call = inv.getArgument(0); + when(fleetClient.getVulnerability("CVE-2024-38063")).thenReturn(detail); + return call.execute(fleetClient); + }); + + // execution + Optional found = service.findByCveId("CVE-2024-38063"); + + // verifications + assertTrue(found.isPresent()); + assertEquals("CVE-2024-38063", found.get().getCveId()); + assertEquals("Windows TCP/IP RCE", found.get().getDescription()); + assertEquals(SoftwareCveSeverity.CRITICAL, found.get().getSeverity()); + // detail flow always populates affectedSoftware (possibly empty), never null + assertNotNull(found.get().getAffectedSoftware()); + } + + @Test + void listVulnerabilities_buildsRequestFromFilters_wiresPagingFromMeta() throws Exception { + // setup + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-1", 9.2), cve("CVE-2", 5.0))); + response.setCount(37L); + VulnerabilitiesResponse.Meta meta = new VulnerabilitiesResponse.Meta(); + meta.setHasNextResults(true); + meta.setHasPreviousResults(false); + response.setMeta(meta); + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(VulnerabilityRequest.class); + when(fleetClient.listVulnerabilities(reqCaptor.capture())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + "chrome", 0, 20, "cvss_score", "desc", Boolean.TRUE, null); + + // verifications + VulnerabilityRequest req = reqCaptor.getValue(); + assertEquals("chrome", req.getQuery()); + assertEquals(0, req.getPage()); + assertEquals(20, req.getPerPage()); + assertEquals("cvss_score", req.getOrderKey()); + assertEquals("desc", req.getOrderDirection()); + assertEquals(Boolean.TRUE, req.getExploit()); + + assertEquals(2, result.items().size()); + assertTrue(result.hasNext()); + assertFalse(result.hasPrevious()); + assertEquals(37, result.filteredCount()); + assertEquals(0, result.page()); + } + + @Test + void listVulnerabilities_minSeverityHigh_filtersRowsBelowFloor() throws Exception { + // setup — 4 CVEs, 3 below HIGH + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of( + cve("CVE-CRIT", 9.5), + cve("CVE-HIGH", 7.2), + cve("CVE-MED", 4.5), + cve("CVE-LOW", 1.2))); + response.setCount(4L); + when(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.HIGH); + + // verifications + assertEquals(2, result.items().size()); + assertEquals("CVE-CRIT", result.items().get(0).getCveId()); + assertEquals("CVE-HIGH", result.items().get(1).getCveId()); + } + + @Test + void listVulnerabilities_minSeverityNone_isTreatedAsNoFilter() throws Exception { + // setup + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(List.of(cve("CVE-LOW", 1.2))); + response.setCount(1L); + when(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 0, 20, null, null, null, SoftwareCveSeverity.NONE); + + // verifications — NONE floor keeps the row (defensive against strict-mode UI) + assertEquals(1, result.items().size()); + } + + @Test + void listVulnerabilities_nullVulnerabilitiesArray_emitsEmptyPageNotNpe() throws Exception { + // setup — Fleet returns {"vulnerabilities": null, "count": 0} on some pages + VulnerabilitiesResponse response = new VulnerabilitiesResponse(); + response.setVulnerabilities(null); + response.setCount(0L); + when(fleetClient.listVulnerabilities(any())).thenReturn(response); + stubCallThatUnwrapsSdkCallOn(fleetClient); + + // execution + PageResult result = service.listVulnerabilities( + null, 5, 20, null, null, null, null); + + // verifications + assertTrue(result.items().isEmpty()); + assertFalse(result.hasNext()); + assertEquals(5, result.page()); // page-index preserved on empty responses + assertEquals(0, result.filteredCount()); + } + + private static Vulnerability cve(String id, Double cvss) { + Vulnerability v = new Vulnerability(); + v.setCve(id); + v.setCvssScore(cvss); + return v; + } + + private void stubCallThatUnwrapsSdkCallOn(FleetMdmClient client) { + when(fleet.call(any(), any())).thenAnswer(inv -> { + FleetClientProvider.FleetSdkCall call = inv.getArgument(0); + return call.execute(client); + }); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java new file mode 100644 index 0000000000..bb43b6fd47 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/PageCursors.java @@ -0,0 +1,52 @@ +package com.openframe.api.datafetcher.rmm; + +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.shared.PageInfo; +import com.openframe.api.dto.shared.PageResult; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +final class PageCursors { + + private PageCursors() { + } + + static int decodePage(String cursor) { + if (cursor == null || cursor.isBlank()) { + return 0; + } + try { + return Math.max(0, Integer.parseInt( + new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8))); + } catch (IllegalArgumentException e) { + return 0; + } + } + + static String encodePage(int page) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(Integer.toString(page).getBytes(StandardCharsets.UTF_8)); + } + + static CountedGenericConnection> toConnection(PageResult page) { + String currentCursor = encodePage(page.page()); + String nextCursor = page.hasNext() ? encodePage(page.page() + 1) : null; + List> edges = page.items().stream() + .map(node -> GenericEdge.builder().node(node).cursor(currentCursor).build()) + .toList(); + PageInfo pageInfo = PageInfo.builder() + .hasNextPage(page.hasNext()) + .hasPreviousPage(page.hasPrevious()) + .startCursor(edges.isEmpty() ? null : currentCursor) + .endCursor(nextCursor != null ? nextCursor : (edges.isEmpty() ? null : currentCursor)) + .build(); + return CountedGenericConnection.>builder() + .edges(edges) + .pageInfo(pageInfo) + .filteredCount(page.filteredCount()) + .build(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java index fbdd7595aa..28e23fc71d 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -9,18 +9,13 @@ import com.openframe.api.dto.rmm.software.SoftwareFilterInput; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; -import com.openframe.api.dto.shared.PageInfo; import com.openframe.api.dto.shared.SortDirection; import com.openframe.api.dto.shared.SortInput; -import com.openframe.api.service.rmm.software.PageResult; import com.openframe.api.service.rmm.software.SoftwareInventoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.List; import java.util.Locale; import java.util.Map; @@ -48,7 +43,7 @@ public CountedGenericConnection> softwares( @InputArgument Integer first, @InputArgument String after, @InputArgument Integer last, @InputArgument String before, @InputArgument String search, @InputArgument SortInput sort) { - int page = decodePage(after != null ? after : before); + int page = PageCursors.decodePage(after != null ? after : before); Integer perPage = first != null ? first : last; String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField()); String orderDirection = sort == null || sort.getDirection() == null @@ -56,7 +51,7 @@ public CountedGenericConnection> softwares( Boolean vulnerable = filter != null && filter.getMinSeverity() != null && filter.getMinSeverity() != SoftwareCveSeverity.NONE ? Boolean.TRUE : null; - return toConnection(softwareInventoryService.listSoftware( + return PageCursors.toConnection(softwareInventoryService.listSoftware( search, page, perPage, orderKey, orderDirection, vulnerable)); } @@ -75,11 +70,11 @@ public CountedGenericConnection> soft @InputArgument Integer first, @InputArgument String after, @InputArgument Integer last, @InputArgument String before, @InputArgument String search, @InputArgument SortInput sort) { - int page = decodePage(after != null ? after : before); + int page = PageCursors.decodePage(after != null ? after : before); Integer perPage = first != null ? first : last; String sortField = sort == null ? null : sort.getField(); boolean asc = sort != null && sort.getDirection() == SortDirection.ASC; - return toConnection(softwareInventoryService.listVulnerabilitiesForSoftware( + return PageCursors.toConnection(softwareInventoryService.listVulnerabilitiesForSoftware( softwareId, search, page, perPage, sortField, asc)); } @@ -88,40 +83,4 @@ public Object softwareFilters(@InputArgument Object filter, @InputArgument Strin log.debug("[software-mgmt stub] softwareFilters query"); return null; } - - private static int decodePage(String cursor) { - if (cursor == null || cursor.isBlank()) { - return 0; - } - try { - return Math.max(0, Integer.parseInt(new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8))); - } catch (IllegalArgumentException e) { - log.debug("invalid cursor '{}' — starting from page 0", cursor); - return 0; - } - } - - private static String encodePage(int page) { - return Base64.getUrlEncoder().withoutPadding() - .encodeToString(Integer.toString(page).getBytes(StandardCharsets.UTF_8)); - } - - private static CountedGenericConnection> toConnection(PageResult page) { - String currentCursor = encodePage(page.page()); - String nextCursor = page.hasNext() ? encodePage(page.page() + 1) : null; - List> edges = page.items().stream() - .map(node -> GenericEdge.builder().node(node).cursor(currentCursor).build()) - .toList(); - PageInfo pageInfo = PageInfo.builder() - .hasNextPage(page.hasNext()) - .hasPreviousPage(page.hasPrevious()) - .startCursor(edges.isEmpty() ? null : currentCursor) - .endCursor(nextCursor != null ? nextCursor : (edges.isEmpty() ? null : currentCursor)) - .build(); - return CountedGenericConnection.>builder() - .edges(edges) - .pageInfo(pageInfo) - .filteredCount(page.filteredCount()) - .build(); - } } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java new file mode 100644 index 0000000000..be62017411 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java @@ -0,0 +1,58 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.CountedGenericConnection; +import com.openframe.api.dto.GenericEdge; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityFilterInput; +import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; +import com.openframe.api.dto.shared.SortInput; +import com.openframe.api.service.rmm.vulnerability.VulnerabilityInventoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.util.Locale; +import java.util.Map; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class VulnerabilityDataFetcher { + + private static final Map FLEET_ORDER_KEY = Map.of( + "cveId", "cve", + "cvssScore", "cvss_score", + "severity", "cvss_score", + "devicesCount", "hosts_count", + "epssProbability", "epss_probability", + "publishedAt", "cve_published", + "createdAt", "created_at"); + + private final VulnerabilityInventoryService vulnerabilityInventoryService; + + @DgsQuery + public VulnerabilityResponse vulnerability(@InputArgument String cveId) { + return vulnerabilityInventoryService.findByCveId(cveId).orElse(null); + } + + @DgsQuery + public CountedGenericConnection> vulnerabilities( + @InputArgument VulnerabilityFilterInput filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + String orderKey = sort == null ? null : FLEET_ORDER_KEY.get(sort.getField()); + String orderDirection = sort == null || sort.getDirection() == null + ? null : sort.getDirection().name().toLowerCase(Locale.ROOT); + Boolean exploit = filter != null && Boolean.TRUE.equals(filter.getExploited()) + ? Boolean.TRUE : null; + return PageCursors.toConnection(vulnerabilityInventoryService.listVulnerabilities( + search, page, perPage, orderKey, orderDirection, exploit, + filter == null ? null : filter.getMinSeverity())); + } +} diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls index 71a2b17949..5da59feb5e 100644 --- a/openframe-api-service-core/src/main/resources/schema/software.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -49,6 +49,22 @@ extend type Query { """Faceted filter-option counts for the software list — Type / Source / Version-status / Vulnerability-severity dropdowns. Mirrors scriptFilters / deviceFilters.""" softwareFilters(filter: SoftwareFilterInput, search: String): SoftwareFilters! + + """Single global CVE record by id (e.g. "CVE-2024-38063"). Null when Fleet has no record. + Populates the vulnerability detail page — includes affected-software list.""" + vulnerability(cveId: String!): Vulnerability + + """Global list of CVEs across every software title in the tenant's fleet. + Powers the Vulnerabilities page. One row = one CVE (not one CVE × software).""" + vulnerabilities( + filter: VulnerabilityFilterInput, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): VulnerabilityConnection! } # ────────── Software (aggregate row) ────────── @@ -207,3 +223,65 @@ input SoftwareOnDeviceFilterInput { input SoftwareVulnerabilityFilterInput { severities: [SoftwareCveSeverity!] } + +# ────────── Global vulnerability (CVE row) ────────── + +"""Global CVE record. Detail-only fields (description, resolvedInVersion, affectedSoftware) +are null on list rows and populated on the detail page.""" +type Vulnerability { + cveId: String! + + """Severity bucketed from CVSS via the NVD ranges (CRITICAL ≥ 9, HIGH ≥ 7, MEDIUM ≥ 4, LOW > 0). + Null when Fleet has no CVSS score for the CVE.""" + severity: SoftwareCveSeverity + + cvssScore: Float + + """Exploit Prediction Scoring System probability (0..1) — Fleet Premium only.""" + epssProbability: Float + + """True when the CVE is on CISA's Known Exploited Vulnerabilities list — Fleet Premium only.""" + cisaKnownExploit: Boolean + + """CVE publication timestamp (ISO-8601).""" + publishedAt: String + + """External details link Fleet returns (usually NVD).""" + detailsLink: String + + """Number of tenant devices affected by any (software, version) pair hit by this CVE.""" + devicesCount: Int + + # ── Detail-only ── + description: String + resolvedInVersion: String + affectedSoftware: [AffectedSoftware!] +} + +"""One row of the "Affected Software" table on the vulnerability detail page.""" +type AffectedSoftware { + id: ID! + name: String! + source: SoftwareSource + version: String + devicesCount: Int + resolvedInVersion: String +} + +type VulnerabilityConnection { + edges: [VulnerabilityEdge!]! + pageInfo: PageInfo! + filteredCount: Int! +} + +type VulnerabilityEdge { + node: Vulnerability! + cursor: String! +} + +input VulnerabilityFilterInput { + """Cut-off severity — rows with severity below this are excluded.""" + minSeverity: SoftwareCveSeverity + """True → only CVEs on CISA's Known Exploited Vulnerabilities list.""" + exploited: Boolean +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java index 726e0ac590..7546318386 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java @@ -21,6 +21,7 @@ import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; import com.openframe.sdk.fleetmdm.model.Vulnerability; +import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; import java.io.IOException; import java.net.URI; @@ -465,24 +466,52 @@ private static String buildSoftwareTitlesQuery(SoftwareTitleRequest request) { return url.toString(); } - /** - * List vulnerabilities with pagination. - */ public VulnerabilitiesResponse listVulnerabilities(int page, int perPage) { try { - HttpResponse response = sendRequest(VULNERABILITIES_URL + "?page=" + page + "&per_page=" + perPage, "GET", null); - checkResponse(response, "list Fleet vulnerabilities"); - return MAPPER.readValue(response.body(), VulnerabilitiesResponse.class); - } catch (FleetMdmApiException e) { - throw e; - } catch (Exception e) { + return listVulnerabilities(VulnerabilityRequest.builder().page(page).perPage(perPage).build()); + } catch (IOException e) { throw new FleetMdmException("Failed to list Fleet vulnerabilities", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FleetMdmException("Interrupted while listing Fleet vulnerabilities", e); } } - /** - * List all global policies. - */ + public VulnerabilitiesResponse listVulnerabilities(VulnerabilityRequest request) throws IOException, InterruptedException { + HttpResponse response = sendRequest(buildVulnerabilitiesQuery(request), "GET", null); + checkResponse(response, "list Fleet vulnerabilities"); + return MAPPER.readValue(response.body(), VulnerabilitiesResponse.class); + } + + private static String buildVulnerabilitiesQuery(VulnerabilityRequest request) { + StringBuilder url = new StringBuilder(VULNERABILITIES_URL); + List params = new ArrayList<>(); + if (request != null) { + if (request.getPage() != null) { + params.add("page=" + request.getPage()); + } + if (request.getPerPage() != null) { + params.add("per_page=" + request.getPerPage()); + } + if (request.getQuery() != null && !request.getQuery().isBlank()) { + params.add("query=" + URLEncoder.encode(request.getQuery(), StandardCharsets.UTF_8)); + } + if (request.getOrderKey() != null && !request.getOrderKey().isBlank()) { + params.add("order_key=" + URLEncoder.encode(request.getOrderKey(), StandardCharsets.UTF_8)); + } + if (request.getOrderDirection() != null && !request.getOrderDirection().isBlank()) { + params.add("order_direction=" + URLEncoder.encode(request.getOrderDirection(), StandardCharsets.UTF_8)); + } + if (Boolean.TRUE.equals(request.getExploit())) { + params.add("exploit=true"); + } + } + if (!params.isEmpty()) { + url.append("?").append(String.join("&", params)); + } + return url.toString(); + } + public List listPolicies() { try { HttpResponse response = sendRequest(POLICIES_URL, "GET", null); @@ -497,9 +526,6 @@ public List listPolicies() { } } - /** - * Get a policy by numeric ID. - */ public Policy getPolicy(long policyId) { try { HttpResponse response = sendRequest(POLICIES_URL + "/" + policyId, "GET", null); @@ -512,9 +538,6 @@ public Policy getPolicy(long policyId) { } } - /** - * Update an existing policy. - */ public Policy updatePolicy(long policyId, UpdatePolicyRequest request) { try { HttpResponse response = sendRequest(POLICIES_URL + "/" + policyId, "PATCH", MAPPER.writeValueAsString(request)); @@ -527,9 +550,6 @@ public Policy updatePolicy(long policyId, UpdatePolicyRequest request) { } } - /** - * Create a scheduled query. - */ public Query createScheduledQuery(CreateScheduledQueryRequest request) { try { HttpResponse response = sendRequest(QUERIES_URL, "POST", MAPPER.writeValueAsString(request)); @@ -542,9 +562,6 @@ public Query createScheduledQuery(CreateScheduledQueryRequest request) { } } - /** - * List all scheduled queries (interval > 0). - */ public List listScheduledQueries() { try { HttpResponse response = sendRequest(QUERIES_URL, "GET", null); @@ -559,9 +576,6 @@ public List listScheduledQueries() { } } - /** - * Get a scheduled query by numeric ID. - */ public Query getScheduledQuery(long queryId) { try { HttpResponse response = sendRequest(QUERIES_URL + "/" + queryId, "GET", null); @@ -574,9 +588,6 @@ public Query getScheduledQuery(long queryId) { } } - /** - * Update an existing scheduled query. - */ public Query updateScheduledQuery(long queryId, UpdateScheduledQueryRequest request) { try { HttpResponse response = sendRequest(QUERIES_URL + "/" + queryId, "PATCH", MAPPER.writeValueAsString(request)); diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/AffectedSoftware.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/AffectedSoftware.java new file mode 100644 index 0000000000..91d1f79a97 --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/AffectedSoftware.java @@ -0,0 +1,25 @@ +package com.openframe.sdk.fleetmdm.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class AffectedSoftware { + + private Long id; + private String name; + private String source; + private String version; + private String browser; + + @JsonProperty("hosts_count") + private Integer hostsCount; + + @JsonProperty("generated_cpe") + private String generatedCpe; + + @JsonProperty("resolved_in_version") + private String resolvedInVersion; +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilitiesResponse.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilitiesResponse.java index c8e66978ea..35cdc9b539 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilitiesResponse.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilitiesResponse.java @@ -2,12 +2,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; import java.util.List; -/** - * Response wrapper for vulnerability list results from Fleet MDM - */ +@Data @JsonIgnoreProperties(ignoreUnknown = true) public class VulnerabilitiesResponse { @@ -18,27 +17,16 @@ public class VulnerabilitiesResponse { @JsonProperty("counts_updated_at") private String countsUpdatedAt; - public List getVulnerabilities() { - return vulnerabilities; - } - - public void setVulnerabilities(List vulnerabilities) { - this.vulnerabilities = vulnerabilities; - } + private Meta meta; - public Long getCount() { - return count; - } + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Meta { - public void setCount(Long count) { - this.count = count; - } - - public String getCountsUpdatedAt() { - return countsUpdatedAt; - } + @JsonProperty("has_next_results") + private Boolean hasNextResults; - public void setCountsUpdatedAt(String countsUpdatedAt) { - this.countsUpdatedAt = countsUpdatedAt; + @JsonProperty("has_previous_results") + private Boolean hasPreviousResults; } } diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java index 555ff8840a..d34bd1c67e 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/Vulnerability.java @@ -4,12 +4,20 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; +import java.util.List; + @Data @JsonIgnoreProperties(ignoreUnknown = true) public class Vulnerability { private String cve; + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("hosts_count") + private Integer hostsCount; + @JsonProperty("hosts_count_updated_at") private String hostsCountUpdatedAt; @@ -22,6 +30,9 @@ public class Vulnerability { @JsonProperty("cve_published") private String cvePublished; + @JsonProperty("cve_description") + private String cveDescription; + @JsonProperty("cisa_known_exploit") private Boolean cisaKnownExploit; @@ -30,4 +41,6 @@ public class Vulnerability { @JsonProperty("resolved_in_version") private String resolvedInVersion; + + private List software; } diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilityRequest.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilityRequest.java new file mode 100644 index 0000000000..c3349165a9 --- /dev/null +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/VulnerabilityRequest.java @@ -0,0 +1,25 @@ +package com.openframe.sdk.fleetmdm.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VulnerabilityRequest { + + private Integer page; + + private Integer perPage; + + private String query; + + private String orderKey; + + private String orderDirection; + + private Boolean exploit; +} From 3660e3ea08b63ce1b82f542d04a90badfef73c6b Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Mon, 14 Sep 2026 15:59:15 +0300 Subject: [PATCH 06/14] Added Software Schedule Run using assign devices by Criteria --- .../software/SoftwareScheduleResponse.java | 2 + .../rmm/software/SoftwareScheduleService.java | 42 ++++-- .../rmm/SoftwareScheduleDataFetcher.java | 14 ++ .../schema/software-schedule.graphqls | 12 +- .../rmm/ScheduleDeviceTargetResolverTest.java | 10 +- .../software/SoftwareScheduleServiceTest.java | 29 +++++ .../ScheduleCriteriaDeviceResolverTest.java | 93 ++++++++++++++ .../SoftwareDeviceLocalScheduleService.java | 4 +- .../rmm/SoftwareScheduleExecutionService.java | 2 +- ...oftwareDeviceLocalScheduleServiceTest.java | 2 +- .../SoftwareScheduleExecutionServiceTest.java | 4 +- .../rmm/schedule/SoftwareSchedule.java | 2 + .../rmm/ScheduleCriteriaDeviceResolver.java | 107 ++++++++++++++++ .../rmm/ScheduleDeviceTargetResolver.java | 120 +----------------- .../rmm/SoftwareScheduleTargetResolver.java | 10 +- 15 files changed, 320 insertions(+), 133 deletions(-) create mode 100644 openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolver.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java index 4b069c86a5..7b53544682 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareScheduleResponse.java @@ -1,5 +1,6 @@ package com.openframe.api.dto.rmm.software; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger; @@ -23,6 +24,7 @@ public class SoftwareScheduleResponse { private SoftwareAction action; private List packages; private ScheduleDeviceSelectionMode selectionMode; + private ScheduleDeviceCriteria deviceCriteria; private ScheduleScriptTrigger trigger; private ScheduleTimeReference timeReference; private ScheduleOfflineBehavior offlineBehavior; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java index 951245d9b3..6bc7f28007 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareScheduleService.java @@ -8,6 +8,8 @@ import com.openframe.core.exception.BadRequestException; import com.openframe.core.exception.ConflictException; import com.openframe.core.exception.NotFoundException; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger; import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.schedule.SoftwareSchedule; @@ -152,22 +154,22 @@ private SoftwareScheduleResponse transitionTo(String id, ScriptStatus target) { return toResponse(entity); } - /** Replace the full assigned device set (PUT — backs "Edit Devices"). */ public SoftwareScheduleResponse setDevices(String scheduleId, List machineIds, String actor) { String tenantId = tenantIdProvider.getTenantId(); SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); + ensureSpecificMode(entity); replaceDevices(tenantId, scheduleId, machineIds, actor); return toResponse(entity); } - /** Incrementally assign devices (idempotent — already-assigned ids are skipped). */ public void addDevices(String scheduleId, List machineIds, String actor) { String tenantId = tenantIdProvider.getTenantId(); - loadVisibleOrThrow(tenantId, scheduleId); + SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); if (machineIds == null || machineIds.isEmpty()) { return; } - Set existing = new HashSet<>(getMachineIds(scheduleId)); + ensureSpecificMode(entity); + Set existing = new HashSet<>(assignedMachineIds(tenantId, scheduleId)); List rows = machineIds.stream().distinct() .filter(id -> !existing.contains(id)) .map(machineId -> SoftwareScheduleMachineAssigned.builder() @@ -187,13 +189,36 @@ public void removeDevices(String scheduleId, List machineIds, String act assignedRepository.deleteByTenantIdAndSoftwareScheduleIdAndMachineIdIn(tenantId, scheduleId, machineIds); } + public SoftwareScheduleResponse setDeviceCriteria(String scheduleId, ScheduleDeviceCriteria criteria, String actor) { + String tenantId = tenantIdProvider.getTenantId(); + SoftwareSchedule entity = loadVisibleOrThrow(tenantId, scheduleId); + entity.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA); + entity.setDeviceCriteria(criteria); + SoftwareSchedule saved = scheduleRepository.save(entity); + log.info("Applied device criteria to software schedule id={} tenantId={} by user={}: {}", + scheduleId, tenantId, actor, criteria); + return toResponse(saved); + } + public List getMachineIds(String scheduleId) { - return targetResolver.resolveMachineIds(tenantIdProvider.getTenantId(), scheduleId); + return targetResolver.resolveMachineIds(loadVisibleOrThrow(tenantIdProvider.getTenantId(), scheduleId)); } public int deviceCount(String scheduleId) { - return (int) assignedRepository - .countByTenantIdAndSoftwareScheduleId(tenantIdProvider.getTenantId(), scheduleId); + return getMachineIds(scheduleId).size(); + } + + private List assignedMachineIds(String tenantId, String scheduleId) { + return assignedRepository.findByTenantIdAndSoftwareScheduleId(tenantId, scheduleId).stream() + .map(SoftwareScheduleMachineAssigned::getMachineId).toList(); + } + + private void ensureSpecificMode(SoftwareSchedule schedule) { + if (schedule.getSelectionMode() != ScheduleDeviceSelectionMode.SPECIFIC) { + schedule.setSelectionMode(ScheduleDeviceSelectionMode.SPECIFIC); + schedule.setDeviceCriteria(null); + scheduleRepository.save(schedule); + } } private void replaceDevices(String tenantId, String scheduleId, List machineIds, String actor) { @@ -244,7 +269,8 @@ private SoftwareScheduleResponse toResponse(SoftwareSchedule s) { return SoftwareScheduleResponse.builder() .id(s.getId()).name(s.getName()).description(s.getDescription()) .action(s.getAction()).packages(s.getPackages()) - .selectionMode(s.getSelectionMode()).trigger(s.getTrigger()).timeReference(s.getTimeReference()) + .selectionMode(s.getSelectionMode()).deviceCriteria(s.getDeviceCriteria()) + .trigger(s.getTrigger()).timeReference(s.getTimeReference()) .offlineBehavior(s.getOfflineBehavior()).reconnectWindowSeconds(s.getReconnectWindowSeconds()) .startAt(s.getStartAt()).repeat(s.getRepeat()) .nextRunAt(s.getNextRunAt()).lastRunAt(s.getLastRunAt()) diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java index 120bc2aa44..acf2888541 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareScheduleDataFetcher.java @@ -18,6 +18,7 @@ import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; import com.openframe.api.dto.rmm.software.UpdateSoftwareScheduleInput; import com.openframe.api.dto.shared.ConnectionArgs; +import com.openframe.api.dto.rmm.schedule.ScheduleDeviceCriteriaInput; import com.openframe.api.dto.shared.CursorPaginationCriteria; import com.openframe.api.dto.shared.SortInput; import com.openframe.api.dto.user.UserResponse; @@ -25,6 +26,7 @@ import com.openframe.api.service.device.DeviceService; import com.openframe.api.service.rmm.software.SoftwareScheduleService; import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; import com.openframe.security.authentication.AuthPrincipal; import graphql.relay.Relay; import jakarta.validation.Valid; @@ -118,6 +120,18 @@ public SoftwareScheduleResponse removeDevicesFromSoftwareSchedule(@InputArgument return scheduleService.get(rawScheduleId); } + @DgsMutation + public SoftwareScheduleResponse setSoftwareScheduleDeviceCriteria(@InputArgument @NotBlank String scheduleId, + @InputArgument @Valid ScheduleDeviceCriteriaInput criteria, + @AuthenticationPrincipal AuthPrincipal principal) { + ScheduleDeviceCriteria domainCriteria = ScheduleDeviceCriteria.builder() + .organizationIds(criteria.getOrganizationIds()) + .deviceTypes(criteria.getDeviceTypes()) + .osTypes(criteria.getOsTypes()) + .build(); + return scheduleService.setDeviceCriteria(decodeId(scheduleId), domainCriteria, principal.getId()); + } + @DgsData(parentType = "SoftwareSchedule", field = "id") public String softwareScheduleNodeId(DgsDataFetchingEnvironment dfe) { SoftwareScheduleResponse schedule = dfe.getSource(); diff --git a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls index 7f63ade35f..0225e4a748 100644 --- a/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software-schedule.graphqls @@ -41,6 +41,12 @@ extend type Mutation { """Incrementally unassign the given devices (the trash / "Remove selected" actions). Missing ids are no-ops.""" removeDevicesFromSoftwareSchedule(scheduleId: ID!, machineIds: [ID!]!): SoftwareSchedule! + + """Switch a software schedule to CRITERIA device selection and store its rule (customer / type / OS) — + backs "Select Devices by Criteria" → Save Devices. The target device set is then resolved live at + dispatch and display time, so devices registered later that match the rule are included automatically. + Reuses the same ScheduleDeviceCriteriaInput as script schedules. Returns the updated schedule.""" + setSoftwareScheduleDeviceCriteria(scheduleId: ID!, criteria: ScheduleDeviceCriteriaInput!): SoftwareSchedule! } type SoftwareSchedule implements Node { @@ -51,8 +57,12 @@ type SoftwareSchedule implements Node { action: SoftwareAction! """The packages this schedule installs/updates.""" packages: [SoftwareSchedulePackage!]! - """How this schedule targets devices. Always SPECIFIC for now (criteria out of scope).""" + """How this schedule targets devices: SPECIFIC (an explicit machine set) or CRITERIA (a live rule; + devices registered later that match are included automatically). Never null — legacy schedules read as SPECIFIC.""" selectionMode: ScheduleDeviceSelectionMode! + """The device-selection rule when selectionMode is CRITERIA (customer / type / OS). Null for SPECIFIC schedules. + Reuses the ScheduleDeviceCriteria type from script schedules.""" + deviceCriteria: ScheduleDeviceCriteria """How a DATE_TIME schedule reads its startAt: SERVER (an absolute instant) or DEVICE_LOCAL (a wall-clock re-based into each device's own timezone). Never null; defaults to SERVER.""" timeReference: ScheduleTimeReference! """What to do when a target device is offline at the scheduled time. Never null; defaults to SKIP.""" diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java index 51e954a92e..c2064ded1a 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScheduleDeviceTargetResolverTest.java @@ -11,12 +11,13 @@ import com.openframe.data.document.device.filter.MachineQueryFilter; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.rmm.ScriptScheduleMachineAssignedRepository; +import com.openframe.data.service.rmm.ScheduleCriteriaDeviceResolver; import com.openframe.data.service.rmm.ScheduleDeviceTargetResolver; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -40,7 +41,12 @@ class ScheduleDeviceTargetResolverTest { @Mock private MachineRepository machineRepository; @Mock private ScriptScheduleMachineAssignedRepository assignedRepository; - @InjectMocks private ScheduleDeviceTargetResolver resolver; + private ScheduleDeviceTargetResolver resolver; + + @BeforeEach + void setUp() { + resolver = new ScheduleDeviceTargetResolver(machineRepository, assignedRepository, new ScheduleCriteriaDeviceResolver(machineRepository)); + } @Test @DisplayName("resolveTargetMachineIds: SPECIFIC reads the join rows (deduped) and keeps active machines in input order") diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java index 5f690ddfdf..0b808efcf9 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareScheduleServiceTest.java @@ -10,6 +10,8 @@ import com.openframe.core.exception.NotFoundException; import com.openframe.data.document.packagesearch.BrewPackageType; import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.schedule.SoftwareSchedule; import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned; @@ -218,6 +220,33 @@ void getDeletedThrows() { assertThatThrownBy(() -> service.get(SCHEDULE_ID)).isInstanceOf(NotFoundException.class); } + @Test + @DisplayName("setDeviceCriteria: switches the schedule to CRITERIA and stores the rule") + void setDeviceCriteria_switchesToCriteria() { + SoftwareSchedule existing = existingActive(); + when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing)); + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")).build(); + + service.setDeviceCriteria(SCHEDULE_ID, criteria, ACTOR); + + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareSchedule.class); + verify(scheduleRepository).save(saved.capture()); + assertThat(saved.getValue().getSelectionMode()).isEqualTo(ScheduleDeviceSelectionMode.CRITERIA); + assertThat(saved.getValue().getDeviceCriteria()).isEqualTo(criteria); + } + + @Test + @DisplayName("getMachineIds: delegates to the target resolver (CRITERIA resolved live)") + void getMachineIds_delegatesToResolver() { + SoftwareSchedule existing = existingActive(); + existing.setSelectionMode(ScheduleDeviceSelectionMode.CRITERIA); + when(scheduleRepository.findByTenantIdAndId(TENANT_ID, SCHEDULE_ID)).thenReturn(Optional.of(existing)); + when(targetResolver.resolveMachineIds(existing)).thenReturn(List.of("m-7", "m-8")); + + assertThat(service.getMachineIds(SCHEDULE_ID)).containsExactly("m-7", "m-8"); + } + private static SoftwareSchedule existingActive() { return SoftwareSchedule.builder() .id(SCHEDULE_ID).tenantId(TENANT_ID).name("Nightly Slack") diff --git a/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java new file mode 100644 index 0000000000..238555ba25 --- /dev/null +++ b/openframe-api-service-core/src/test/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolverTest.java @@ -0,0 +1,93 @@ +package com.openframe.data.service.rmm; + +import com.openframe.data.document.device.DeviceType; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.device.filter.MachineQueryFilter; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.repository.device.MachineRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static com.openframe.data.document.rmm.script.OsType.MAC_OS; +import static com.openframe.data.document.rmm.script.OsType.WINDOWS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScheduleCriteriaDeviceResolverTest { + + private static final String TENANT = "tenant-1"; + + @Mock private MachineRepository machineRepository; + @InjectMocks private ScheduleCriteriaDeviceResolver resolver; + + @Test + @DisplayName("resolveMachineIds: criteria OS ∩ supportedPlatforms is passed as the scope; org/type land on the filter") + void resolve_intersectsOsScopeAndBuildsFilter() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")) + .deviceTypes(List.of(DeviceType.DESKTOP)) + .osTypes(List.of(WINDOWS, MAC_OS)) + .build(); + when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(List.of(WINDOWS)))) + .thenReturn(List.of("m-1", "m-2")); + + List result = resolver.resolveMachineIds(TENANT, criteria, List.of(WINDOWS)); + + assertThat(result).containsExactly("m-1", "m-2"); + ArgumentCaptor filter = ArgumentCaptor.forClass(MachineQueryFilter.class); + verify(machineRepository).findMachineIdsByCriteria(eq(TENANT), filter.capture(), eq(List.of(WINDOWS))); + assertThat(filter.getValue().getOrganizationIds()).containsExactly("org-1"); + assertThat(filter.getValue().getDeviceTypes()).containsExactly(DeviceType.DESKTOP.name()); + } + + @Test + @DisplayName("resolveMachineIds: a contradictory OS scope (criteria OS disjoint from supported) matches nothing — no query") + void resolve_contradictoryScope_shortCircuits() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().osTypes(List.of(WINDOWS)).build(); + + assertThat(resolver.resolveMachineIds(TENANT, criteria, List.of(MAC_OS))).isEmpty(); + verify(machineRepository, never()).findMachineIdsByCriteria(any(), any(), any()); + } + + @Test + @DisplayName("resolveMachineIds: no OS anywhere → unconstrained scope (null) passed through to the repository") + void resolve_unconstrained_passesNullScope() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder().organizationIds(List.of("org-1")).build(); + when(machineRepository.findMachineIdsByCriteria(eq(TENANT), any(), eq(null))).thenReturn(List.of("m-9")); + + assertThat(resolver.resolveMachineIds(TENANT, criteria, null)).containsExactly("m-9"); + } + + @Test + @DisplayName("matches: device satisfies every constrained dimension; a wrong OS or org fails it") + void matches_perDimension() { + ScheduleDeviceCriteria criteria = ScheduleDeviceCriteria.builder() + .organizationIds(List.of("org-1")).osTypes(List.of(WINDOWS)).build(); + + assertThat(resolver.matches(machine("org-1", WINDOWS), criteria, null)).isTrue(); + assertThat(resolver.matches(machine("org-1", MAC_OS), criteria, null)).isFalse(); // wrong OS + assertThat(resolver.matches(machine("org-2", WINDOWS), criteria, null)).isFalse(); // wrong org + assertThat(resolver.matches(null, criteria, null)).isFalse(); + } + + private static Machine machine(String orgId, OsType osType) { + Machine m = new Machine(); + m.setMachineId("m"); + m.setOrganizationId(orgId); + m.setOsType(osType); + return m; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareDeviceLocalScheduleService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareDeviceLocalScheduleService.java index 962bbddc12..50f2bc1161 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareDeviceLocalScheduleService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareDeviceLocalScheduleService.java @@ -16,8 +16,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DuplicateKeyException; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.time.Instant; @@ -185,7 +185,7 @@ private void evaluateOffline(SoftwareSchedule schedule, Machine machine, LocalDa } private List resolveTargets(SoftwareSchedule schedule) { - return targetResolver.resolveMachineIds(schedule.getTenantId(), schedule.getId()); + return targetResolver.resolveMachineIds(schedule); } private ZoneId parseZone(SoftwareSchedule schedule, String machineId, String zoneId) { diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareScheduleExecutionService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareScheduleExecutionService.java index 3e49cc0d59..baa31e8404 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareScheduleExecutionService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareScheduleExecutionService.java @@ -40,7 +40,7 @@ private void runDueServerSchedules(Instant now) { log.info("Found {} due software schedule(s) — running", due.size()); for (SoftwareSchedule schedule : due) { try { - List targets = targetResolver.resolveMachineIds(schedule.getTenantId(), schedule.getId()); + List targets = targetResolver.resolveMachineIds(schedule); fireDispatcher.dispatch(schedule, targets, now); schedule.setLastRunAt(now); } catch (Exception e) { diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareDeviceLocalScheduleServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareDeviceLocalScheduleServiceTest.java index 9a4ef9bb5f..50668bc94f 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareDeviceLocalScheduleServiceTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareDeviceLocalScheduleServiceTest.java @@ -99,7 +99,7 @@ private void given(SoftwareSchedule schedule, Machine machine) { when(scheduleRepository.findByStatusAndTriggerAndTimeReference( ScriptStatus.ACTIVE, ScheduleScriptTrigger.DATE_TIME, ScheduleTimeReference.DEVICE_LOCAL)) .thenReturn(List.of(schedule)); - when(targetResolver.resolveMachineIds(TENANT, SCHEDULE_ID)) + when(targetResolver.resolveMachineIds(schedule)) .thenReturn(List.of(machine.getMachineId())); when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())).thenReturn(List.of(machine)); when(dispatchRepository.findByScheduleIdAndMachineIdIn(eq(SCHEDULE_ID), any())).thenReturn(List.of()); diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareScheduleExecutionServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareScheduleExecutionServiceTest.java index 55b5ed72e6..334462e827 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareScheduleExecutionServiceTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/SoftwareScheduleExecutionServiceTest.java @@ -43,7 +43,7 @@ void runDue_firesAndAdvancesRepeat() { .build(); when(scheduleRepository.findByStatusAndNextRunAtLessThanEqual(eq(ScriptStatus.ACTIVE), any())) .thenReturn(List.of(schedule)); - when(targetResolver.resolveMachineIds(TENANT, "ss-1")).thenReturn(List.of("m-1", "m-2")); + when(targetResolver.resolveMachineIds(schedule)).thenReturn(List.of("m-1", "m-2")); service.runDueSchedules(); @@ -63,7 +63,7 @@ void runDue_oneShotClearsNextRunAt() { .build(); when(scheduleRepository.findByStatusAndNextRunAtLessThanEqual(eq(ScriptStatus.ACTIVE), any())) .thenReturn(List.of(schedule)); - when(targetResolver.resolveMachineIds(TENANT, "ss-1")).thenReturn(List.of("m-1")); + when(targetResolver.resolveMachineIds(schedule)).thenReturn(List.of("m-1")); service.runDueSchedules(); diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/SoftwareSchedule.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/SoftwareSchedule.java index 32404dfafc..13406f5952 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/SoftwareSchedule.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/SoftwareSchedule.java @@ -42,6 +42,8 @@ public class SoftwareSchedule implements TenantScoped { @Builder.Default private ScheduleDeviceSelectionMode selectionMode = ScheduleDeviceSelectionMode.SPECIFIC; + private ScheduleDeviceCriteria deviceCriteria; + @Builder.Default private ScheduleScriptTrigger trigger = ScheduleScriptTrigger.DATE_TIME; diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolver.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolver.java new file mode 100644 index 0000000000..70cbb2945b --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleCriteriaDeviceResolver.java @@ -0,0 +1,107 @@ +package com.openframe.data.service.rmm; + +import com.openframe.data.document.device.DeviceType; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.device.filter.MachineQueryFilter; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.repository.device.MachineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ScheduleCriteriaDeviceResolver { + + private final MachineRepository machineRepository; + + public List resolveMachineIds(String tenantId, ScheduleDeviceCriteria criteria, Collection supportedPlatforms) { + List scope = platformScope(criteria, supportedPlatforms); + if (scope != null && scope.isEmpty()) { + return List.of(); + } + return machineRepository.findMachineIdsByCriteria(tenantId, buildFilter(criteria), scope); + } + + public long count(String tenantId, ScheduleDeviceCriteria criteria, Collection supportedPlatforms) { + List scope = platformScope(criteria, supportedPlatforms); + if (scope != null && scope.isEmpty()) { + return 0L; + } + return machineRepository.countMachinesByCriteria(tenantId, buildFilter(criteria), scope); + } + + public boolean matches(Machine machine, ScheduleDeviceCriteria criteria, Collection supportedPlatforms) { + if (machine == null) { + return false; + } + List organizationIds = criteria == null ? null : criteria.getOrganizationIds(); + List deviceTypes = criteria == null ? null : criteria.getDeviceTypes(); + + if (isNotEmpty(organizationIds) && !organizationIds.contains(machine.getOrganizationId())) { + return false; + } + if (isNotEmpty(deviceTypes) && (machine.getType() == null || !deviceTypes.contains(machine.getType()))) { + return false; + } + List scope = platformScope(criteria, supportedPlatforms); + if (scope != null) { + OsType osType = machine.getOsType(); + if (osType == null || scope.stream().noneMatch(ps -> ps.equals(osType))) { + return false; + } + } + return true; + } + + private static MachineQueryFilter buildFilter(ScheduleDeviceCriteria criteria) { + MachineQueryFilter filter = new MachineQueryFilter(); + if (criteria != null) { + filter.setOrganizationIds(emptyToNull(criteria.getOrganizationIds())); + filter.setDeviceTypes(deviceTypeNames(criteria.getDeviceTypes())); + } + return filter; + } + + private static List platformScope(ScheduleDeviceCriteria criteria, Collection supportedPlatforms) { + List osTypes = criteria == null ? null : criteria.getOsTypes(); + Set supported = supportedPlatforms == null ? Set.of() + : supportedPlatforms.stream().filter(Objects::nonNull).collect(Collectors.toUnmodifiableSet()); + + boolean hasOs = isNotEmpty(osTypes); + if (!hasOs && supported.isEmpty()) { + return null; + } + if (!hasOs) { + return supported.stream().toList(); + } + List criteriaPlatforms = osTypes.stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + if (supported.isEmpty()) { + return criteriaPlatforms; + } + return criteriaPlatforms.stream() + .filter(supported::contains) + .toList(); + } + + private static List deviceTypeNames(List types) { + return isNotEmpty(types) ? types.stream().map(Enum::name).toList() : null; + } + + private static List emptyToNull(List list) { + return isNotEmpty(list) ? list : null; + } + + private static boolean isNotEmpty(List list) { + return list != null && !list.isEmpty(); + } +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleDeviceTargetResolver.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleDeviceTargetResolver.java index 9d4d44fa74..a712830c70 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleDeviceTargetResolver.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/ScheduleDeviceTargetResolver.java @@ -1,12 +1,8 @@ package com.openframe.data.service.rmm; import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.DeviceType; import com.openframe.data.document.device.Machine; -import com.openframe.data.document.device.filter.MachineQueryFilter; -import com.openframe.data.document.rmm.schedule.ScheduleDeviceCriteria; import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; -import com.openframe.data.document.rmm.script.OsType; import com.openframe.data.document.rmm.schedule.ScheduleScript; import com.openframe.data.document.rmm.schedule.ScheduleScriptMachineAssigned; import com.openframe.data.repository.device.MachineRepository; @@ -20,21 +16,6 @@ import java.util.Set; import java.util.stream.Collectors; -/** - * Single source of truth for "which machines does this schedule target right now". Shared by the - * dispatch engine and the DEVICE_ONLINE trigger (client-service) and the read side (api). Two modes: - * - *

- * - *

Criteria OS is always intersected with the schedule's {@code supportedPlatforms}, so a criteria - * target is guaranteed platform-compatible. Matching on {@code osType} is case-insensitive (osType is - * stored lowercase; {@link OsType} names are upper). - */ @Component @RequiredArgsConstructor @Slf4j @@ -42,11 +23,12 @@ public class ScheduleDeviceTargetResolver { private final MachineRepository machineRepository; private final ScriptScheduleMachineAssignedRepository assignedRepository; + private final ScheduleCriteriaDeviceResolver criteriaResolver; - /** The schedule's current target machineIds (deduped), resolved per its selection mode. */ public List resolveTargetMachineIds(ScheduleScript schedule) { if (schedule.getSelectionMode() == ScheduleDeviceSelectionMode.CRITERIA) { - return resolveCriteriaMachineIds(schedule); + return criteriaResolver.resolveMachineIds( + schedule.getTenantId(), schedule.getDeviceCriteria(), schedule.getSupportedPlatforms()); } List assignedIds = assignedRepository .findByTenantIdAndScriptScheduleId(schedule.getTenantId(), schedule.getId()).stream() @@ -68,104 +50,14 @@ private List keepDispatchable(String tenantId, List machineIds) return machineIds.stream().filter(valid::contains).toList(); } - /** - * Does {@code machine} match this schedule's CRITERIA rule? Always {@code false} for a - * SPECIFIC schedule (those target an explicit set, matched via join rows instead). Used by the - * DEVICE_ONLINE trigger to fire criteria schedules on a device that just came online. - */ public boolean matchesCriteria(ScheduleScript schedule, Machine machine) { - if (schedule.getSelectionMode() != ScheduleDeviceSelectionMode.CRITERIA || machine == null) { + if (schedule.getSelectionMode() != ScheduleDeviceSelectionMode.CRITERIA) { return false; } - ScheduleDeviceCriteria criteria = schedule.getDeviceCriteria(); - List organizationIds = criteria == null ? null : criteria.getOrganizationIds(); - List deviceTypes = criteria == null ? null : criteria.getDeviceTypes(); - - if (isNotEmpty(organizationIds) && !organizationIds.contains(machine.getOrganizationId())) { - return false; - } - if (isNotEmpty(deviceTypes) && (machine.getType() == null || !deviceTypes.contains(machine.getType()))) { - return false; - } - List platformScope = platformScope(schedule); - if (platformScope != null) { - OsType osType = machine.getOsType(); - if (osType == null || platformScope.stream().noneMatch(ps -> ps.equals(osType))) { - return false; - } - } - return true; - } - - /** - * Resolve CRITERIA targets. The business decisions live here — building the {@link MachineQueryFilter} - * and computing the effective OS scope (criteria ∩ supportedPlatforms); the actual Mongo query lives - * in {@link MachineRepository#findMachineIdsByCriteria}. A contradictory OS scope (criteria OS disjoint - * from the schedule's platforms) matches nothing, short-circuited without a query. - */ - private List resolveCriteriaMachineIds(ScheduleScript schedule) { - List platformScope = platformScope(schedule); - if (platformScope != null && platformScope.isEmpty()) { - return List.of(); // contradictory OS scope → no device can match - } - return machineRepository.findMachineIdsByCriteria( - schedule.getTenantId(), buildCriteriaFilter(schedule.getDeviceCriteria()), platformScope); + return criteriaResolver.matches(machine, schedule.getDeviceCriteria(), schedule.getSupportedPlatforms()); } public long countCriteriaMachines(ScheduleScript schedule) { - List platformScope = platformScope(schedule); - if (platformScope != null && platformScope.isEmpty()) { - return 0L; - } - return machineRepository.countMachinesByCriteria( - schedule.getTenantId(), buildCriteriaFilter(schedule.getDeviceCriteria()), platformScope); + return criteriaResolver.count(schedule.getTenantId(), schedule.getDeviceCriteria(), schedule.getSupportedPlatforms()); } - - private static MachineQueryFilter buildCriteriaFilter(ScheduleDeviceCriteria criteria) { - MachineQueryFilter filter = new MachineQueryFilter(); - if (criteria != null) { - filter.setOrganizationIds(emptyToNull(criteria.getOrganizationIds())); - filter.setDeviceTypes(deviceTypeNames(criteria.getDeviceTypes())); - } - return filter; - } - - private List platformScope(ScheduleScript schedule) { - ScheduleDeviceCriteria criteria = schedule.getDeviceCriteria(); - List osTypes = criteria == null ? null : criteria.getOsTypes(); - Set supported = schedule.getSupportedPlatforms() == null - ? Set.of() - : schedule.getSupportedPlatforms().stream().collect(Collectors.toUnmodifiableSet()); - - boolean hasOs = isNotEmpty(osTypes); - if (!hasOs && supported.isEmpty()) { - return null; // unconstrained - } - if (!hasOs) { - return supported.stream().toList(); // schedule platforms only - } - List criteriaPlatforms = osTypes.stream() - .filter(Objects::nonNull) - .distinct() - .toList(); - if (supported.isEmpty()) { - return criteriaPlatforms; // criteria OS only - } - return criteriaPlatforms.stream() - .filter(supported::contains) - .toList(); // possibly empty → contradictory - } - - private static List deviceTypeNames(List types) { - return isNotEmpty(types) ? types.stream().map(Enum::name).toList() : null; - } - - private static List emptyToNull(List list) { - return isNotEmpty(list) ? list : null; - } - - private static boolean isNotEmpty(List list) { - return list != null && !list.isEmpty(); - } - } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/SoftwareScheduleTargetResolver.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/SoftwareScheduleTargetResolver.java index 970ddcc43b..ace66b9811 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/SoftwareScheduleTargetResolver.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/SoftwareScheduleTargetResolver.java @@ -1,5 +1,7 @@ package com.openframe.data.service.rmm; +import com.openframe.data.document.rmm.schedule.ScheduleDeviceSelectionMode; +import com.openframe.data.document.rmm.schedule.SoftwareSchedule; import com.openframe.data.document.rmm.schedule.SoftwareScheduleMachineAssigned; import com.openframe.data.repository.rmm.SoftwareScheduleMachineAssignedRepository; import lombok.RequiredArgsConstructor; @@ -15,9 +17,13 @@ public class SoftwareScheduleTargetResolver { private final SoftwareScheduleMachineAssignedRepository assignedRepository; + private final ScheduleCriteriaDeviceResolver criteriaResolver; - public List resolveMachineIds(String tenantId, String softwareScheduleId) { - return assignedRepository.findByTenantIdAndSoftwareScheduleId(tenantId, softwareScheduleId).stream() + public List resolveMachineIds(SoftwareSchedule schedule) { + if (schedule.getSelectionMode() == ScheduleDeviceSelectionMode.CRITERIA) { + return criteriaResolver.resolveMachineIds(schedule.getTenantId(), schedule.getDeviceCriteria(), null); + } + return assignedRepository.findByTenantIdAndSoftwareScheduleId(schedule.getTenantId(), schedule.getId()).stream() .map(SoftwareScheduleMachineAssigned::getMachineId) .filter(Objects::nonNull) .distinct() From 6466e644b383532bbbcb8739b720d33f780609e8 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Mon, 14 Sep 2026 20:20:32 +0300 Subject: [PATCH 07/14] Fixed API for Vulnerabilities --- .../rmm/software/SoftwareFilterOption.java | 13 ++ .../api/dto/rmm/software/SoftwareFilters.java | 23 +++ .../software/SoftwareOnDeviceResponse.java | 8 +- .../rmm/fleet/FleetClientProvider.java | 2 +- .../rmm/fleet/FleetHostMachineResolver.java | 70 +++++++++ .../software/SoftwareInventoryService.java | 138 +++++++++++++++++- .../VulnerabilityInventoryService.java | 50 ++++++- .../SoftwareInventoryServiceTest.java | 104 +++++++++++++ .../VulnerabilityInventoryServiceTest.java | 32 +++- .../datafetcher/rmm/SoftwareDataFetcher.java | 24 +-- .../rmm/VulnerabilityDataFetcher.java | 15 +- .../main/resources/schema/software.graphqls | 17 ++- .../repository/device/MachineRepository.java | 18 ++- .../sdk/fleetmdm/FleetMdmClient.java | 12 ++ .../sdk/fleetmdm/model/HostSearchRequest.java | 35 ++++- 15 files changed, 529 insertions(+), 32 deletions(-) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java create mode 100644 openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java new file mode 100644 index 0000000000..f0690c033e --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilterOption.java @@ -0,0 +1,13 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SoftwareFilterOption { + + private String value; + private String label; + private int count; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java new file mode 100644 index 0000000000..672f6e6743 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareFilters.java @@ -0,0 +1,23 @@ +package com.openframe.api.dto.rmm.software; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class SoftwareFilters { + + private List sources; + private List versionStatuses; + private List severities; + + public static SoftwareFilters empty() { + return SoftwareFilters.builder() + .sources(List.of()) + .versionStatuses(List.of()) + .severities(List.of()) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java index 41b21aaf33..948ee056c6 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareOnDeviceResponse.java @@ -1,16 +1,14 @@ package com.openframe.api.dto.rmm.software; -import lombok.AllArgsConstructor; +import com.openframe.data.document.device.Machine; import lombok.Builder; import lombok.Data; -import lombok.NoArgsConstructor; @Data @Builder -@NoArgsConstructor -@AllArgsConstructor public class SoftwareOnDeviceResponse { - private String machineId; + + private Machine device; private String softwareVersion; private SoftwareOnDeviceStatus status; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java index 93d3d472f6..d63e757a62 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetClientProvider.java @@ -21,7 +21,7 @@ import static org.springframework.util.StringUtils.hasText; @Component -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor public class FleetClientProvider { diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java new file mode 100644 index 0000000000..af9430736f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/fleet/FleetHostMachineResolver.java @@ -0,0 +1,70 @@ +package com.openframe.api.service.rmm.fleet; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.sdk.fleetmdm.model.Host; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +public class FleetHostMachineResolver { + + private final MachineRepository machineRepository; + + public Map resolve(String tenantId, List hosts) { + if (hosts == null || hosts.isEmpty()) { + return Map.of(); + } + Set osUuids = values(hosts, Host::getUuid); + Set serials = values(hosts, Host::getHardwareSerial); + Set hostnames = values(hosts, Host::getHostname); + + Map byOsUuid = index(machineRepository.findByTenantIdAndOsUuidIn(tenantId, osUuids), Machine::getOsUuid); + Map bySerial = index(machineRepository.findByTenantIdAndSerialNumberIn(tenantId, serials), Machine::getSerialNumber); + Map byHostname = index(machineRepository.findByTenantIdAndHostnameIn(tenantId, hostnames), Machine::getHostname); + + Map resolved = new LinkedHashMap<>(); + for (Host host : hosts) { + if (host.getId() == null || resolved.containsKey(host.getId())) { + continue; + } + Machine machine = lookup(byOsUuid, host.getUuid()); + if (machine == null) { + machine = lookup(bySerial, host.getHardwareSerial()); + } + if (machine == null) { + machine = lookup(byHostname, host.getHostname()); + } + if (machine != null) { + resolved.put(host.getId(), machine); + } + } + return resolved; + } + + private static Set values(List hosts, Function key) { + return hosts.stream().map(key).filter(v -> isNotBlank(v)).collect(Collectors.toSet()); + } + + private static Map index(List machines, Function key) { + return machines.stream() + .filter(m -> isNotBlank(key.apply(m))) + .collect(Collectors.toMap(key, Function.identity(), (a, b) -> a)); + } + + private static Machine lookup(Map byKey, String key) { + return isNotBlank(key) ? byKey.get(key) : null; + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java index 7ba9c0f245..20324c91ae 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInventoryService.java @@ -1,9 +1,18 @@ package com.openframe.api.service.rmm.software; +import com.openframe.api.dto.rmm.software.SoftwareFilterOption; +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceStatus; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; import com.openframe.api.dto.shared.PageResult; import com.openframe.api.service.rmm.fleet.FleetClientProvider; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.HostSearchRequest; import com.openframe.sdk.fleetmdm.model.SoftwareTitle; import com.openframe.sdk.fleetmdm.model.SoftwareTitleRequest; import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; @@ -14,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -21,17 +31,22 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import static org.springframework.util.StringUtils.hasText; @Slf4j @Service -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor public class SoftwareInventoryService { + private static final int DEVICES_PER_VERSION_LIMIT = 500; + private final FleetClientProvider fleet; + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; public Optional findById(String softwareId) { return parseNumericId(softwareId) @@ -90,6 +105,127 @@ public PageResult listVulnerabilitiesForSoftware( return paginate(all, page, perPage); } + /** + * Devices that have a given software title installed — backs the software detail "Devices" tab. + * Fleet exposes the installed version only per software version, so we fan out one host + * query per version of the title, tag each returned host with that version, correlate the Fleet + * host to an OpenFrame {@link Machine}, and derive the status from the version vs the title's latest. + * Hosts not enrolled in OpenFrame (no matching Machine) are dropped. Paginated in memory. + */ + public PageResult listDevicesForSoftware(String softwareId, String search, + int page, Integer perPage) { + Optional titleId = parseNumericId(softwareId); + if (titleId.isEmpty()) { + return PageResult.empty(page); + } + SoftwareTitle title = fleet.call( + client -> client.getSoftwareTitle(titleId.get()), + "get Fleet software title id=" + titleId.get()); + if (title == null || title.getVersions() == null || title.getVersions().isEmpty()) { + return PageResult.empty(page); + } + String latestVersion = FleetSoftwareMapper.toResponse(title).getLatestVersion(); + + List hostVersions = new ArrayList<>(); + for (SoftwareTitleVersion version : title.getVersions()) { + if (version.getId() == null) { + continue; + } + HostSearchRequest request = new HostSearchRequest(); + request.setSoftwareVersionId(version.getId()); + request.setPerPage(DEVICES_PER_VERSION_LIMIT); + List hosts = fleet.call( + client -> client.searchHosts(request), + "list Fleet hosts for software_version_id=" + version.getId()); + hosts.forEach(host -> hostVersions.add(new HostVersion(host, version.getVersion()))); + } + + Map machinesByHostId = hostMachineResolver.resolve( + tenantIdProvider.getTenantId(), hostVersions.stream().map(HostVersion::host).toList()); + + List all = hostVersions.stream() + .map(hv -> toDeviceResponse(hv, machinesByHostId, latestVersion)) + .filter(Objects::nonNull) + .filter(row -> matchesDeviceSearch(row, search)) + .toList(); + return paginateList(all, page, perPage); + } + + private static SoftwareOnDeviceResponse toDeviceResponse(HostVersion hv, Map machinesByHostId, + String latestVersion) { + Machine device = hv.host().getId() == null ? null : machinesByHostId.get(hv.host().getId()); + if (device == null) { + return null; + } + SoftwareOnDeviceStatus status = latestVersion != null && latestVersion.equals(hv.version()) + ? SoftwareOnDeviceStatus.UP_TO_DATE : SoftwareOnDeviceStatus.OUTDATED; + return SoftwareOnDeviceResponse.builder() + .device(device) + .softwareVersion(hv.version()) + .status(status) + .build(); + } + + private static boolean matchesDeviceSearch(SoftwareOnDeviceResponse row, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + Machine d = row.getDevice(); + return (d.getHostname() != null && d.getHostname().toLowerCase(Locale.ROOT).contains(needle)) + || (row.getSoftwareVersion() != null && row.getSoftwareVersion().toLowerCase(Locale.ROOT).contains(needle)); + } + + private static PageResult paginateList(List all, int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = size == 0 ? 0 : Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } + + private record HostVersion(Host host, String version) { + } + + /** Per-title scan cap when computing filter facet counts (one Fleet page). */ + private static final int FILTERS_SCAN_LIMIT = 1000; + + /** + * Faceted filter-option counts for the software list — Source / Version-status / Severity dropdowns. + * Fleet has no facet endpoint, so we scan the (searched) software titles and tally each dimension. + * Bounded to {@link #FILTERS_SCAN_LIMIT} titles. + */ + public SoftwareFilters getSoftwareFilters(String search) { + List titles = listSoftware(search, 0, FILTERS_SCAN_LIMIT, null, null, null).items(); + return SoftwareFilters.builder() + .sources(facet(titles, SoftwareResponse::getSource)) + .versionStatuses(facet(titles, SoftwareResponse::getVersionStatus)) + .severities(facet(titles, row -> row.getVulnerabilitySummary() == null + ? null : row.getVulnerabilitySummary().getHighestSeverity())) + .build(); + } + + private static > List facet(List titles, + Function dimension) { + Map counts = titles.stream() + .map(dimension) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + return counts.entrySet().stream() + .sorted(Comparator.comparingInt(e -> e.getKey().ordinal())) + .map(e -> SoftwareFilterOption.builder() + .value(e.getKey().name()) + .label(humanize(e.getKey().name())) + .count(e.getValue().intValue()) + .build()) + .toList(); + } + + private static String humanize(String enumName) { + String lower = enumName.toLowerCase(Locale.ROOT).replace('_', ' '); + return lower.isEmpty() ? lower : Character.toUpperCase(lower.charAt(0)) + lower.substring(1); + } + private static Optional parseNumericId(String softwareId) { if (!hasText(softwareId)) { return Optional.empty(); diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java index ecccb71beb..8f33236a13 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryService.java @@ -4,6 +4,11 @@ import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; import com.openframe.api.dto.shared.PageResult; import com.openframe.api.service.rmm.fleet.FleetClientProvider; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.HostSearchRequest; import com.openframe.sdk.fleetmdm.model.VulnerabilitiesResponse; import com.openframe.sdk.fleetmdm.model.Vulnerability; import com.openframe.sdk.fleetmdm.model.VulnerabilityRequest; @@ -13,6 +18,8 @@ import org.springframework.stereotype.Service; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -20,11 +27,52 @@ @Slf4j @Service -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor public class VulnerabilityInventoryService { + private static final int DEVICES_LIMIT = 500; + private final FleetClientProvider fleet; + private final FleetHostMachineResolver hostMachineResolver; + private final TenantIdProvider tenantIdProvider; + + public PageResult listDevicesForCve(String cveId, String search, int page, Integer perPage) { + if (!hasText(cveId)) { + return PageResult.empty(page); + } + HostSearchRequest request = new HostSearchRequest(); + request.setCve(cveId); + request.setPerPage(DEVICES_LIMIT); + List hosts = fleet.call( + client -> client.searchHosts(request), + "list Fleet hosts for vulnerability=" + cveId); + Map machinesByHostId = hostMachineResolver.resolve(tenantIdProvider.getTenantId(), hosts); + + List all = hosts.stream() + .map(host -> host.getId() == null ? null : machinesByHostId.get(host.getId())) + .filter(Objects::nonNull) + .distinct() + .filter(machine -> matchesDeviceSearch(machine, search)) + .toList(); + return paginate(all, page, perPage); + } + + private static boolean matchesDeviceSearch(Machine machine, String search) { + if (!hasText(search)) { + return true; + } + String needle = search.toLowerCase(Locale.ROOT); + return machine.getHostname() != null && machine.getHostname().toLowerCase(Locale.ROOT).contains(needle); + } + + private static PageResult paginate(List all, int page, Integer perPage) { + int size = perPage != null && perPage > 0 ? perPage : all.size(); + int from = Math.max(0, page * size); + int to = size == 0 ? 0 : Math.min(all.size(), from + size); + List slice = from >= to ? List.of() : List.copyOf(all.subList(from, to)); + return new PageResult<>(slice, to < all.size(), from > 0, all.size(), page); + } public Optional findByCveId(String cveId) { if (!hasText(cveId)) { diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java new file mode 100644 index 0000000000..361b0e2959 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareInventoryServiceTest.java @@ -0,0 +1,104 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; +import com.openframe.api.dto.shared.PageResult; +import com.openframe.api.service.rmm.fleet.FleetClientProvider; +import com.openframe.api.service.rmm.fleet.FleetHostMachineResolver; +import com.openframe.data.document.device.Machine; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.sdk.fleetmdm.model.Host; +import com.openframe.sdk.fleetmdm.model.SoftwareTitle; +import com.openframe.sdk.fleetmdm.model.SoftwareTitleVersion; +import com.openframe.sdk.fleetmdm.model.SoftwareTitlesResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareInventoryServiceTest { + + @Mock private FleetClientProvider fleet; + @Mock private FleetHostMachineResolver hostMachineResolver; + @Mock private TenantIdProvider tenantIdProvider; + + private SoftwareInventoryService service; + + @BeforeEach + void setUp() { + service = new SoftwareInventoryService(fleet, hostMachineResolver, tenantIdProvider); + } + + @Test + @DisplayName("listDevicesForSoftware: a non-numeric id short-circuits to empty without touching Fleet") + void listDevices_nonNumericId_emptyNoFleet() { + assertThat(service.listDevicesForSoftware("not-a-number", null, 0, 50).items()).isEmpty(); + verifyNoInteractions(fleet, hostMachineResolver); + } + + @Test + @DisplayName("listDevicesForSoftware: fans out per version, tags each host with its version, correlates to a Machine, and drops hosts not enrolled in OpenFrame") + void listDevices_correlatesAndDropsUnenrolled() { + SoftwareTitle title = new SoftwareTitle(); + title.setName("Google Chrome"); + SoftwareTitleVersion version = new SoftwareTitleVersion(); + version.setId(10L); + version.setVersion("1.2.3"); + title.setVersions(List.of(version)); + + Host enrolled = host(1L, "u1", "host-1"); + Host foreign = host(2L, "u2", "host-2"); // no matching Machine → dropped + + // call 1 = getSoftwareTitle, call 2 = searchHosts(software_version_id=10) + when(fleet.call(any(), any())).thenReturn(title, List.of(enrolled, foreign)); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + Machine machine = new Machine(); + machine.setMachineId("m-1"); + machine.setHostname("host-1"); + when(hostMachineResolver.resolve(eq("t1"), anyList())).thenReturn(Map.of(1L, machine)); + + PageResult result = service.listDevicesForSoftware("42", null, 0, 50); + + assertThat(result.items()).hasSize(1); + SoftwareOnDeviceResponse row = result.items().get(0); + assertThat(row.getDevice().getMachineId()).isEqualTo("m-1"); + assertThat(row.getSoftwareVersion()).isEqualTo("1.2.3"); + assertThat(row.getStatus()).isNotNull(); + } + + @Test + @DisplayName("getSoftwareFilters: no titles → valid empty facet lists (never null)") + void getSoftwareFilters_noTitles_emptyFacets() { + SoftwareTitlesResponse response = mock(SoftwareTitlesResponse.class); + when(response.getSoftwareTitles()).thenReturn(List.of()); + when(fleet.call(any(), any())).thenReturn(response); + + SoftwareFilters filters = service.getSoftwareFilters(null); + + assertThat(filters.getSources()).isEmpty(); + assertThat(filters.getVersionStatuses()).isEmpty(); + assertThat(filters.getSeverities()).isEmpty(); + } + + private static Host host(long id, String uuid, String hostname) { + Host h = new Host(); + h.setId(id); + h.setUuid(uuid); + h.setHostname(hostname); + return h; + } +} diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java index 6b1c3a89c2..40df69fef7 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/vulnerability/VulnerabilityInventoryServiceTest.java @@ -33,6 +33,8 @@ class VulnerabilityInventoryServiceTest { @Mock private FleetClientProvider fleet; @Mock private FleetMdmClient fleetClient; + @Mock private com.openframe.api.service.rmm.fleet.FleetHostMachineResolver hostMachineResolver; + @Mock private com.openframe.data.service.TenantIdProvider tenantIdProvider; @Captor private ArgumentCaptor> callCaptor; @@ -40,7 +42,35 @@ class VulnerabilityInventoryServiceTest { @BeforeEach void setUp() { - service = new VulnerabilityInventoryService(fleet); + service = new VulnerabilityInventoryService(fleet, hostMachineResolver, tenantIdProvider); + } + + @Test + void listDevicesForCve_blankCve_returnsEmptyWithoutHittingFleet() { + assertTrue(service.listDevicesForCve("", null, 0, 50).items().isEmpty()); + assertTrue(service.listDevicesForCve(null, null, 0, 50).items().isEmpty()); + } + + @Test + void listDevicesForCve_correlatesHostsToMachines_dropsUnenrolled() { + com.openframe.sdk.fleetmdm.model.Host h1 = new com.openframe.sdk.fleetmdm.model.Host(); + h1.setId(1L); + h1.setHostname("host-1"); + com.openframe.sdk.fleetmdm.model.Host h2 = new com.openframe.sdk.fleetmdm.model.Host(); + h2.setId(2L); + h2.setHostname("host-2"); + when(fleet.call(any(), any())).thenReturn(List.of(h1, h2)); + when(tenantIdProvider.getTenantId()).thenReturn("t1"); + com.openframe.data.document.device.Machine m1 = new com.openframe.data.document.device.Machine(); + m1.setMachineId("m-1"); + m1.setHostname("host-1"); + when(hostMachineResolver.resolve(eq("t1"), any())).thenReturn(java.util.Map.of(1L, m1)); + + PageResult result = + service.listDevicesForCve("CVE-2024-38063", null, 0, 50); + + assertEquals(1, result.items().size()); // h2 dropped (no matching Machine) + assertEquals("m-1", result.items().get(0).getMachineId()); } @Test diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java index 28e23fc71d..121ce4afd4 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareDataFetcher.java @@ -7,6 +7,8 @@ import com.openframe.api.dto.GenericEdge; import com.openframe.api.dto.rmm.software.SoftwareCveSeverity; import com.openframe.api.dto.rmm.software.SoftwareFilterInput; +import com.openframe.api.dto.rmm.software.SoftwareFilters; +import com.openframe.api.dto.rmm.software.SoftwareOnDeviceResponse; import com.openframe.api.dto.rmm.software.SoftwareResponse; import com.openframe.api.dto.rmm.software.SoftwareVulnerabilityResponse; import com.openframe.api.dto.shared.SortDirection; @@ -20,7 +22,7 @@ import java.util.Map; @DgsComponent -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor @Slf4j public class SoftwareDataFetcher { @@ -56,12 +58,15 @@ public CountedGenericConnection> softwares( } @DgsQuery - public Object softwareDevices(@InputArgument String softwareId, @InputArgument Object filter, - @InputArgument Integer first, @InputArgument String after, - @InputArgument Integer last, @InputArgument String before, - @InputArgument String search, @InputArgument Object sort) { - log.debug("[software-mgmt stub] softwareDevices query softwareId={}", softwareId); - return null; + public CountedGenericConnection> softwareDevices( + @InputArgument String softwareId, @InputArgument Object filter, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument Object sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + return PageCursors.toConnection( + softwareInventoryService.listDevicesForSoftware(softwareId, search, page, perPage)); } @DgsQuery @@ -79,8 +84,7 @@ public CountedGenericConnection> soft } @DgsQuery - public Object softwareFilters(@InputArgument Object filter, @InputArgument String search) { - log.debug("[software-mgmt stub] softwareFilters query"); - return null; + public SoftwareFilters softwareFilters(@InputArgument Object filter, @InputArgument String search) { + return softwareInventoryService.getSoftwareFilters(search); } } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java index be62017411..13c2e9e69d 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/VulnerabilityDataFetcher.java @@ -9,6 +9,7 @@ import com.openframe.api.dto.rmm.vulnerability.VulnerabilityResponse; import com.openframe.api.dto.shared.SortInput; import com.openframe.api.service.rmm.vulnerability.VulnerabilityInventoryService; +import com.openframe.data.document.device.Machine; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -17,7 +18,7 @@ import java.util.Map; @DgsComponent -@ConditionalOnProperty(name = "openframe.software-management.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor @Slf4j public class VulnerabilityDataFetcher { @@ -55,4 +56,16 @@ public CountedGenericConnection> vulnerabilit search, page, perPage, orderKey, orderDirection, exploit, filter == null ? null : filter.getMinSeverity())); } + + @DgsQuery + public CountedGenericConnection> vulnerabilityDevices( + @InputArgument String cveId, + @InputArgument Integer first, @InputArgument String after, + @InputArgument Integer last, @InputArgument String before, + @InputArgument String search, @InputArgument SortInput sort) { + int page = PageCursors.decodePage(after != null ? after : before); + Integer perPage = first != null ? first : last; + return PageCursors.toConnection( + vulnerabilityInventoryService.listDevicesForCve(cveId, search, page, perPage)); + } } diff --git a/openframe-api-service-core/src/main/resources/schema/software.graphqls b/openframe-api-service-core/src/main/resources/schema/software.graphqls index 1088aee5b9..5c7b772598 100644 --- a/openframe-api-service-core/src/main/resources/schema/software.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software.graphqls @@ -1,8 +1,8 @@ # Software inventory (read side, thin proxy over Fleet MDM REST). # # The wire contract is stable so the frontend can start binding against it; -# resolvers currently return null / empty because the feature is stubbed under -# a Conditional-property. Enable via openframe.software-management.enabled=true. +# resolvers are gated behind a Conditional-property and only wire up when the +# Software feature is enabled via openframe.rmm.software.enabled=true. extend type Query { """Single software row by id (Relay global id). Null when unknown.""" @@ -65,6 +65,19 @@ extend type Query { search: String, sort: SortInput ): VulnerabilityConnection! + + """Devices affected by a given CVE — the hosts that have an affected software version, correlated + to their OpenFrame Machine. Powers the vulnerability-detail "Devices" table. Each node is a Machine + (same shape as the top-level `devices` query).""" + vulnerabilityDevices( + cveId: String!, + first: Int, + after: String, + last: Int, + before: String, + search: String, + sort: SortInput + ): DeviceConnection! } # ────────── Software (aggregate row) ────────── diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/device/MachineRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/device/MachineRepository.java index c4a1eb0124..b51aa3f307 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/device/MachineRepository.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/device/MachineRepository.java @@ -14,21 +14,27 @@ @Repository public interface MachineRepository extends MongoRepository, CustomMachineRepository{ Optional findByMachineId(String machineId); - + List findByHostnameContainingIgnoreCase(String hostname); - + List findByTypeAndHostnameContainingIgnoreCase(DeviceType deviceType, String hostname); - + List findByType(DeviceType deviceType); - + List findByMachineIdIn(Collection machineIds); List findByTenantIdAndMachineIdIn(String tenantId, Collection machineIds); + List findByTenantIdAndOsUuidIn(String tenantId, Collection osUuids); + + List findByTenantIdAndSerialNumberIn(String tenantId, Collection serialNumbers); + + List findByTenantIdAndHostnameIn(String tenantId, Collection hostnames); + Optional findByTenantIdAndMachineId(String tenantId, String machineId); List findByMachineIdInAndStatus(Collection machineIds, DeviceStatus status); - + List findByStatusIn(Collection statuses); long countByStatusIn(Collection statuses); @@ -42,4 +48,4 @@ public interface MachineRepository extends MongoRepository, Cus boolean existsByOrganizationId(String organizationId); boolean existsByOrganizationIdAndStatusNotIn(String organizationId, Collection statuses); -} \ No newline at end of file +} diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java index 64e97117a8..6f0ee91186 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/FleetMdmClient.java @@ -239,6 +239,18 @@ private String buildSearchUrl(HostSearchRequest searchRequest) { params.add("order_direction=" + URLEncoder.encode(searchRequest.getOrderDirection(), StandardCharsets.UTF_8)); } + if (searchRequest.getSoftwareTitleId() != null) { + params.add("software_title_id=" + searchRequest.getSoftwareTitleId()); + } + + if (searchRequest.getSoftwareVersionId() != null) { + params.add("software_version_id=" + searchRequest.getSoftwareVersionId()); + } + + if (searchRequest.getCve() != null && !searchRequest.getCve().trim().isEmpty()) { + params.add("vulnerability=" + URLEncoder.encode(searchRequest.getCve(), StandardCharsets.UTF_8)); + } + if (!params.isEmpty()) { urlBuilder.append("?").append(String.join("&", params)); } diff --git a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/HostSearchRequest.java b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/HostSearchRequest.java index 26b32d54dd..2418f61c97 100644 --- a/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/HostSearchRequest.java +++ b/sdk/fleetmdm/src/main/java/com/openframe/sdk/fleetmdm/model/HostSearchRequest.java @@ -4,24 +4,27 @@ * Request parameters for host search */ public class HostSearchRequest { - + private String query; private Integer page; private Integer perPage; private String orderKey; private String orderDirection; - + private Long softwareTitleId; + private Long softwareVersionId; + private String cve; + public HostSearchRequest() { // Default values this.page = 0; this.perPage = 100; } - + public HostSearchRequest(String query) { this(); this.query = query; } - + public HostSearchRequest(String query, Integer page, Integer perPage) { this.query = query; this.page = page != null ? page : 0; @@ -67,4 +70,28 @@ public String getOrderDirection() { public void setOrderDirection(String orderDirection) { this.orderDirection = orderDirection; } + + public Long getSoftwareTitleId() { + return softwareTitleId; + } + + public void setSoftwareTitleId(Long softwareTitleId) { + this.softwareTitleId = softwareTitleId; + } + + public Long getSoftwareVersionId() { + return softwareVersionId; + } + + public void setSoftwareVersionId(Long softwareVersionId) { + this.softwareVersionId = softwareVersionId; + } + + public String getCve() { + return cve; + } + + public void setCve(String cve) { + this.cve = cve; + } } From 096e5af96d2178328c93f5c71da4d56519e20bc0 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Thu, 17 Sep 2026 15:50:05 +0300 Subject: [PATCH 08/14] Added Bundle level --- .../software/CreateSoftwareBundleInput.java | 24 +++ .../rmm/software/SoftwareBundleResponse.java | 26 +++ .../software/UpdateSoftwareBundleInput.java | 23 +++ .../rmm/software/SoftwareBundleService.java | 180 ++++++++++++++++++ ...oftwareInstallUpdateManagementService.java | 20 +- .../software/SoftwareBundleServiceTest.java | 176 +++++++++++++++++ .../rmm/SoftwareBundleDataFetcher.java | 68 +++++++ .../resources/schema/software-bundle.graphqls | 80 ++++++++ ...areInstallUpdateManagementServiceTest.java | 62 +++++- .../document/rmm/software/SoftwareBundle.java | 36 ++++ .../rmm/software/SoftwareBundlePackage.java | 19 ++ .../rmm/software/SoftwareBundleStatus.java | 6 + .../rmm/SoftwareBundleRepository.java | 19 ++ .../service/rmm/MachinePlatformResolver.java | 44 +++++ ...reateSoftwareBundleTtlIndexChangeUnit.java | 34 ++++ 15 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java create mode 100644 openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java create mode 100644 openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java create mode 100644 openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java create mode 100644 openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundle.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundlePackage.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleStatus.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareBundleRepository.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java create mode 100644 openframe-management-service-core/src/main/java/com/openframe/management/migration/CreateSoftwareBundleTtlIndexChangeUnit.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java new file mode 100644 index 0000000000..3282107bf8 --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java @@ -0,0 +1,24 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareAction; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.util.List; + +@Data +public class CreateSoftwareBundleInput { + + @NotNull(message = "action must not be null") + private SoftwareAction action; + + @NotEmpty(message = "machineIds must not be empty") + private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", + message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; + + @Valid + private List packages; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java new file mode 100644 index 0000000000..7e92a6d43a --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java @@ -0,0 +1,26 @@ +package com.openframe.api.dto.rmm.software; + +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import lombok.Builder; +import lombok.Data; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +public class SoftwareBundleResponse { + + private String id; + private SoftwareAction action; + private SoftwareBundleStatus status; + private List machineIds; + private List packages; + private String createdBy; + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private List executionIds; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java new file mode 100644 index 0000000000..9d47132c2f --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java @@ -0,0 +1,23 @@ +package com.openframe.api.dto.rmm.software; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.util.List; + +@Data +public class UpdateSoftwareBundleInput { + + @NotBlank(message = "id must not be blank") + private String id; + + @NotEmpty(message = "machineIds must not be empty") + private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", + message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; + + @Valid + private List packages; +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java new file mode 100644 index 0000000000..0cda0d74cb --- /dev/null +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java @@ -0,0 +1,180 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; +import com.openframe.api.dto.rmm.software.SoftwareManagementInput; +import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.core.exception.NotFoundException; +import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import com.openframe.data.service.TenantIdProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@ConditionalOnProperty(name = {"spring.cloud.stream.enabled", "openframe.rmm.software.enabled"}, havingValue = "true") +@RequiredArgsConstructor +public class SoftwareBundleService { + + private final SoftwareBundleRepository bundleRepository; + private final SoftwareInstallUpdateManagementService installUpdateService; + private final TenantIdProvider tenantIdProvider; + + /** TTL after which a still-PENDING bundle is considered abandoned; refreshed on every edit. */ + @Value("${openframe.rmm.software.bundle.pending-ttl:1h}") + private Duration pendingTtl; + + public SoftwareBundleResponse create(CreateSoftwareBundleInput input, String createdBy) { + String tenantId = tenantIdProvider.getTenantId(); + Instant now = Instant.now(); + SoftwareBundle entity = SoftwareBundle.builder() + .tenantId(tenantId) + .action(input.getAction()) + .status(SoftwareBundleStatus.PENDING) + .machineIds(List.copyOf(input.getMachineIds())) + .packages(toDomainPackages(input.getPackages())) + .createdBy(createdBy) + .createdAt(now) + .updatedAt(now) + .expireAt(now.plus(pendingTtl)) + .build(); + SoftwareBundle saved = bundleRepository.save(entity); + log.info("Created software bundle id={} action={} devices={} packages={} tenantId={}", + saved.getId(), saved.getAction(), saved.getMachineIds().size(), + saved.getPackages().size(), tenantId); + return toResponse(saved); + } + + public SoftwareBundleResponse update(UpdateSoftwareBundleInput input, String actor) { + SoftwareBundle entity = loadPendingOrThrow(input.getId()); + entity.setMachineIds(List.copyOf(input.getMachineIds())); + entity.setPackages(toDomainPackages(input.getPackages())); + Instant now = Instant.now(); + entity.setUpdatedAt(now); + entity.setExpireAt(now.plus(pendingTtl)); + SoftwareBundle saved = bundleRepository.save(entity); + log.debug("Updated software bundle id={} devices={} packages={} actor={}", + saved.getId(), saved.getMachineIds().size(), saved.getPackages().size(), actor); + return toResponse(saved); + } + + public boolean delete(String id, String actor) { + SoftwareBundle entity = loadPendingOrThrow(id); + bundleRepository.delete(entity); + log.info("Deleted PENDING software bundle id={} actor={}", id, actor); + return true; + } + + public SoftwareBundleResponse get(String id) { + return toResponse(loadOrThrow(id)); + } + + public Optional findById(String id) { + return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id).map(SoftwareBundleService::toResponse); + } + + public List list(SoftwareBundleStatus status) { + String tenantId = tenantIdProvider.getTenantId(); + List bundles = status == null + ? bundleRepository.findByTenantIdOrderByIdDesc(tenantId) + : bundleRepository.findByTenantIdAndStatusOrderByIdDesc(tenantId, status); + return bundles.stream().map(SoftwareBundleService::toResponse).toList(); + } + + public List run(String id, String actor) { + SoftwareBundle entity = loadPendingOrThrow(id); + if (entity.getPackages() == null || entity.getPackages().isEmpty()) { + throw new BadRequestException("Cannot run software bundle " + id + ": no packages selected"); + } + + SoftwareManagementInput input = new SoftwareManagementInput(); + input.setMachineIds(entity.getMachineIds()); + input.setPackages(toInputPackages(entity.getPackages())); + + List results = entity.getAction() == SoftwareAction.INSTALL + ? installUpdateService.install(input, actor, ExecutionSource.MANUAL) + : installUpdateService.update(input, actor, ExecutionSource.MANUAL); + + Instant now = Instant.now(); + entity.setStatus(SoftwareBundleStatus.COMPLETED); + entity.setCompletedAt(now); + entity.setUpdatedAt(now); + entity.setExpireAt(null); // completed bundles are history — never reaped + entity.setExecutionIds(results.stream().map(SoftwareDispatchResult::getExecutionId).toList()); + bundleRepository.save(entity); + + log.info("Ran software bundle id={} action={} dispatched={} actor={}", + id, entity.getAction(), results.size(), actor); + return results; + } + + private SoftwareBundle loadOrThrow(String id) { + return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id) + .orElseThrow(() -> new NotFoundException("Software bundle not found: " + id)); + } + + private SoftwareBundle loadPendingOrThrow(String id) { + SoftwareBundle entity = loadOrThrow(id); + if (entity.getStatus() != SoftwareBundleStatus.PENDING) { + throw new BadRequestException("Software bundle " + id + " is " + entity.getStatus() + + " and can no longer be modified or run"); + } + return entity; + } + + private static List toDomainPackages(List packages) { + if (packages == null) { + return List.of(); + } + return packages.stream() + .map(p -> SoftwareBundlePackage.builder() + .packageManager(p.getPackageManager()) + .packageName(p.getPackageName()) + .brewPackageType(p.getBrewPackageType()) + .build()) + .toList(); + } + + private static List toInputPackages(List packages) { + return packages.stream() + .map(p -> { + SoftwarePackageInput input = new SoftwarePackageInput(); + input.setPackageManager(p.getPackageManager()); + input.setPackageName(p.getPackageName()); + input.setBrewPackageType(p.getBrewPackageType()); + return input; + }) + .toList(); + } + + private static SoftwareBundleResponse toResponse(SoftwareBundle b) { + return SoftwareBundleResponse.builder() + .id(b.getId()) + .action(b.getAction()) + .status(b.getStatus()) + .machineIds(b.getMachineIds()) + .packages(b.getPackages()) + .createdBy(b.getCreatedBy()) + .createdAt(b.getCreatedAt()) + .updatedAt(b.getUpdatedAt()) + .completedAt(b.getCompletedAt()) + .executionIds(b.getExecutionIds()) + .build(); + } +} diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java index c848db1a13..3fcb8eeb70 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementService.java @@ -6,8 +6,10 @@ import com.openframe.api.dto.rmm.software.SoftwarePackageInput; import com.openframe.api.service.rmm.script.ScriptService; import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; import com.openframe.data.document.rmm.software.SoftwareAction; import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.service.rmm.MachinePlatformResolver; import com.openframe.data.service.rmm.software.PackageManagerHandler; import com.openframe.data.service.rmm.software.PackageManagerRegistry; import lombok.RequiredArgsConstructor; @@ -29,6 +31,7 @@ public class SoftwareInstallUpdateManagementService { private final PackageManagerRegistry packageManagerRegistry; private final ScriptService scriptService; private final SoftwareDispatchService softwareDispatchService; + private final MachinePlatformResolver machinePlatformResolver; public List install(SoftwareManagementInput input, String initiatedBy, ExecutionSource source) { return dispatch(SoftwareAction.INSTALL, input, initiatedBy, source); @@ -41,6 +44,7 @@ public List update(SoftwareManagementInput input, String private List dispatch(SoftwareAction action, SoftwareManagementInput input, String initiatedBy, ExecutionSource source) { List machineIds = input.getMachineIds(); + Map osTypes = machinePlatformResolver.osTypesByMachineId(machineIds); Map scriptCache = new EnumMap<>(SoftwareScriptCode.class); List results = new ArrayList<>(input.getPackages().size()); @@ -48,9 +52,17 @@ private List dispatch(SoftwareAction action, SoftwareMan PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager()); SoftwareScriptCode code = handler.scriptCode(action); ScriptResponse script = scriptCache.computeIfAbsent(code, scriptService::getSoftwareScript); - List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType()); - String executionId = softwareDispatchService.dispatch(script, machineIds, args, initiatedBy, source, + List targets = machinePlatformResolver.compatible(machineIds, osTypes, script.getSupportedPlatforms()); + if (targets.isEmpty()) { + log.warn("Software {} skipped package {}/{}: no OS-compatible device among {} target(s) (supports {})", + action, pkg.getPackageManager(), pkg.getPackageName(), machineIds.size(), + script.getSupportedPlatforms()); + continue; + } + + List args = handler.buildArgs(pkg.getPackageName(), pkg.getBrewPackageType()); + String executionId = softwareDispatchService.dispatch(script, targets, args, initiatedBy, source, pkg.getPackageManager(), pkg.getPackageName(), action); results.add(SoftwareDispatchResult.builder() @@ -60,8 +72,8 @@ private List dispatch(SoftwareAction action, SoftwareMan .build()); } - log.info("Software {} dispatched: packages={} machines={} initiatedBy={} source={}", - action, input.getPackages().size(), machineIds.size(), initiatedBy, source); + log.info("Software {} dispatched: packages={}/{} machines={} initiatedBy={} source={}", + action, results.size(), input.getPackages().size(), machineIds.size(), initiatedBy, source); return results; } } diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java new file mode 100644 index 0000000000..a171506af7 --- /dev/null +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java @@ -0,0 +1,176 @@ +package com.openframe.api.service.rmm.software; + +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; +import com.openframe.api.dto.rmm.software.SoftwareManagementInput; +import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.core.exception.BadRequestException; +import com.openframe.data.document.packagesearch.PackageManagerType; +import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import com.openframe.data.service.TenantIdProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SoftwareBundleServiceTest { + + private static final String TENANT = "t1"; + private static final String USER = "user-1"; + private static final String BUNDLE_ID = "b1"; + + @Mock private SoftwareBundleRepository bundleRepository; + @Mock private SoftwareInstallUpdateManagementService installUpdateService; + @Mock private TenantIdProvider tenantIdProvider; + + private SoftwareBundleService service; + + @BeforeEach + void setUp() { + service = new SoftwareBundleService(bundleRepository, installUpdateService, tenantIdProvider); + ReflectionTestUtils.setField(service, "pendingTtl", Duration.ofHours(1)); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + @Test + @DisplayName("create: born PENDING with a TTL anchor, devices and packages persisted") + void create_bornPending() { + when(bundleRepository.save(any())).thenAnswer(inv -> withId(inv.getArgument(0))); + + SoftwareBundleResponse res = service.create(createInput(), USER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(captor.capture()); + SoftwareBundle saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThat(saved.getTenantId()).isEqualTo(TENANT); + assertThat(saved.getMachineIds()).containsExactly("m1"); + assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("slack"); + assertThat(saved.getExpireAt()).isNotNull(); + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + } + + @Test + @DisplayName("run: dispatches via the install engine, then flips to COMPLETED and clears the TTL anchor") + void run_dispatchesAndCompletes() { + SoftwareBundle pending = pending(SoftwareAction.INSTALL, + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(installUpdateService.install(any(), eq(USER), eq(ExecutionSource.MANUAL))) + .thenReturn(List.of(result("exec-1"))); + + List results = service.run(BUNDLE_ID, USER); + + // The engine receives the bundle's devices + packages. + ArgumentCaptor inputCaptor = ArgumentCaptor.forClass(SoftwareManagementInput.class); + verify(installUpdateService).install(inputCaptor.capture(), eq(USER), eq(ExecutionSource.MANUAL)); + assertThat(inputCaptor.getValue().getMachineIds()).containsExactly("m1"); + assertThat(inputCaptor.getValue().getPackages()).extracting(SoftwarePackageInput::getPackageName).containsExactly("slack"); + + ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saveCaptor.capture()); + SoftwareBundle saved = saveCaptor.getValue(); + assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getCompletedAt()).isNotNull(); + assertThat(saved.getExpireAt()).isNull(); + assertThat(saved.getExecutionIds()).containsExactly("exec-1"); + assertThat(results).extracting(SoftwareDispatchResult::getExecutionId).containsExactly("exec-1"); + } + + @Test + @DisplayName("run: an already-COMPLETED bundle cannot be re-run (no double dispatch)") + void run_completed_rejected() { + SoftwareBundle completed = pending(SoftwareAction.INSTALL, + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(installUpdateService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("run: a bundle with no packages is rejected before any dispatch") + void run_noPackages_rejected() { + SoftwareBundle empty = pending(SoftwareAction.INSTALL); + empty.setPackages(List.of()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(installUpdateService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("delete: a COMPLETED bundle is protected (history is immutable)") + void delete_completed_rejected() { + SoftwareBundle completed = pending(SoftwareAction.INSTALL); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + assertThatThrownBy(() -> service.delete(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).delete(any()); + } + + private static CreateSoftwareBundleInput createInput() { + CreateSoftwareBundleInput input = new CreateSoftwareBundleInput(); + input.setAction(SoftwareAction.INSTALL); + input.setMachineIds(List.of("m1")); + SoftwarePackageInput pkg = new SoftwarePackageInput(); + pkg.setPackageManager(PackageManagerType.BREW); + pkg.setPackageName("slack"); + input.setPackages(List.of(pkg)); + return input; + } + + private static SoftwareBundle pending(SoftwareAction action, SoftwareBundlePackage... packages) { + return SoftwareBundle.builder() + .id(BUNDLE_ID) + .tenantId(TENANT) + .action(action) + .status(SoftwareBundleStatus.PENDING) + .machineIds(List.of("m1")) + .packages(List.of(packages)) + .build(); + } + + private static SoftwareBundle withId(SoftwareBundle b) { + if (b.getId() == null) { + b.setId(BUNDLE_ID); + } + return b; + } + + private static SoftwareDispatchResult result(String executionId) { + return SoftwareDispatchResult.builder() + .packageManager(PackageManagerType.BREW) + .packageName("slack") + .executionId(executionId) + .build(); + } +} diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java new file mode 100644 index 0000000000..c3f5d82606 --- /dev/null +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java @@ -0,0 +1,68 @@ +package com.openframe.api.datafetcher.rmm; + +import com.netflix.graphql.dgs.DgsComponent; +import com.netflix.graphql.dgs.DgsMutation; +import com.netflix.graphql.dgs.DgsQuery; +import com.netflix.graphql.dgs.InputArgument; +import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; +import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; +import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; +import com.openframe.api.service.rmm.software.SoftwareBundleService; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.security.authentication.AuthPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +@DgsComponent +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +@Validated +public class SoftwareBundleDataFetcher { + + private final SoftwareBundleService softwareBundleService; + + @DgsQuery + public SoftwareBundleResponse softwareBundle(@InputArgument String id) { + return softwareBundleService.findById(id).orElse(null); + } + + @DgsQuery + public List softwareBundles(@InputArgument SoftwareBundleStatus status) { + return softwareBundleService.list(status); + } + + @DgsMutation + public SoftwareBundleResponse createSoftwareBundle(@InputArgument @Valid CreateSoftwareBundleInput input) { + return softwareBundleService.create(input, getCurrentUserId()); + } + + @DgsMutation + public SoftwareBundleResponse updateSoftwareBundle(@InputArgument @Valid UpdateSoftwareBundleInput input) { + return softwareBundleService.update(input, getCurrentUserId()); + } + + @DgsMutation + public boolean deleteSoftwareBundle(@InputArgument String id) { + return softwareBundleService.delete(id, getCurrentUserId()); + } + + @DgsMutation + public List runSoftwareBundle(@InputArgument String id) { + return softwareBundleService.run(id, getCurrentUserId()); + } + + private String getCurrentUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return AuthPrincipal.fromJwt((Jwt) auth.getPrincipal()).getId(); + } +} diff --git a/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls new file mode 100644 index 0000000000..dee0bacea2 --- /dev/null +++ b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls @@ -0,0 +1,80 @@ +# Software bundle — the staging layer for immediate Install / Update Software. +# +# The frontend creates a bundle the moment the first device is assigned on the Install/Update screen, then +# edits it (devices/packages) as the user builds the request. Running the bundle dispatches every package to +# the matching-OS subset of its devices (brew → macOS, winget/choco → Windows) and flips it to COMPLETED. +# A PENDING bundle left unrun is disposable and reaped by a Mongo TTL index. +# +# Gated behind openframe.rmm.software.enabled=true (+ spring.cloud.stream.enabled for the run dispatch). +# Reuses SoftwareAction, SoftwarePackageInput, SoftwareDispatchResult, PackageManagerType and BrewPackageType +# from software.graphqls. + +extend type Query { + """One bundle by id. Null when it does not exist (or was already reaped).""" + softwareBundle(id: ID!): SoftwareBundle + + """Bundles for the tenant, newest first. Optionally filtered by status.""" + softwareBundles(status: SoftwareBundleStatus): [SoftwareBundle!]! +} + +extend type Mutation { + """Create a staged bundle (status PENDING). Called when the first device is assigned; packages may be + empty at this point and added later via updateSoftwareBundle.""" + createSoftwareBundle(input: CreateSoftwareBundleInput!): SoftwareBundle! + + """Replace a PENDING bundle's devices and/or packages. Fails if the bundle is already COMPLETED.""" + updateSoftwareBundle(input: UpdateSoftwareBundleInput!): SoftwareBundle! + + """Discard a PENDING bundle. Fails if the bundle is already COMPLETED (history is immutable).""" + deleteSoftwareBundle(id: ID!): Boolean! + + """Run a PENDING bundle now: dispatch every package to its OS-compatible devices and mark it COMPLETED. + Returns one dispatch result (with its executionId) per dispatched package. Fails if already COMPLETED.""" + runSoftwareBundle(id: ID!): [SoftwareDispatchResult!]! +} + +"""A staged install/update operation: devices + packages, plus lifecycle state.""" +type SoftwareBundle { + id: ID! + "INSTALL or UPDATE — fixed at creation." + action: SoftwareAction! + "PENDING while drafting, COMPLETED once run. Server-controlled; never settable." + status: SoftwareBundleStatus! + "Assigned device machineIds (1..n)." + machineIds: [String!]! + "Packages to act on. May be empty while PENDING." + packages: [SoftwareBundlePackage!]! + createdBy: String + createdAt: Instant + updatedAt: Instant + "When the bundle was run. Null while PENDING." + completedAt: Instant + "Execution ids from the run (one per dispatched package). Null/empty while PENDING." + executionIds: [String!] +} + +"""One catalog package staged in a bundle.""" +type SoftwareBundlePackage { + packageManager: PackageManagerType! + packageName: String! + brewPackageType: BrewPackageType +} + +enum SoftwareBundleStatus { + PENDING + COMPLETED +} + +"""Create a bundle. machineIds must have at least one device; packages may be omitted while drafting.""" +input CreateSoftwareBundleInput { + action: SoftwareAction! + machineIds: [String!]! + packages: [SoftwarePackageInput!] +} + +"""Edit a PENDING bundle. action and status are immutable and cannot be set.""" +input UpdateSoftwareBundleInput { + id: ID! + machineIds: [String!]! + packages: [SoftwarePackageInput!] +} diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java index d9d55a50c8..e3d28b3f3b 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java @@ -5,14 +5,20 @@ import com.openframe.api.dto.rmm.software.SoftwareManagementInput; import com.openframe.api.dto.rmm.software.SoftwarePackageInput; import com.openframe.api.service.rmm.script.ScriptService; +import com.openframe.data.document.device.Machine; import com.openframe.data.document.packagesearch.BrewPackageType; import com.openframe.data.document.packagesearch.PackageManagerType; import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; import com.openframe.data.document.rmm.script.PrivilegeLevel; import com.openframe.data.document.rmm.software.SoftwareAction; import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.service.TenantIdProvider; +import com.openframe.data.service.rmm.MachinePlatformResolver; import com.openframe.data.service.rmm.software.BrewPackageManagerHandler; import com.openframe.data.service.rmm.software.PackageManagerRegistry; +import com.openframe.data.service.rmm.software.WingetPackageManagerHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,22 +42,35 @@ class SoftwareInstallUpdateManagementServiceTest { private static final String USER = "user-1"; private static final List MACHINES = List.of("m1", "m2"); + private static final String TENANT = "t1"; + @Mock private PackageManagerRegistry registry; @Mock private ScriptService scriptService; @Mock private SoftwareDispatchService softwareDispatchService; + @Mock private MachineRepository machineRepository; + @Mock private TenantIdProvider tenantIdProvider; private SoftwareInstallUpdateManagementService service; @org.junit.jupiter.api.BeforeEach void setUp() { - service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService); + MachinePlatformResolver platformResolver = new MachinePlatformResolver(machineRepository, tenantIdProvider); + service = new SoftwareInstallUpdateManagementService(registry, scriptService, softwareDispatchService, + platformResolver); // A real brew handler so we exercise real script-code + arg building. when(registry.handlerFor(PackageManagerType.BREW)).thenReturn(new BrewPackageManagerHandler()); + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + } + + private void allMacTargets() { + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m1", OsType.MAC_OS), machine("m2", OsType.MAC_OS))); } @Test @DisplayName("install: each package dispatched with the right script + args; system script resolved once") void install_dispatchesPerPackage() { + allMacTargets(); ScriptResponse installScript = systemScript("brew-install-id"); when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(installScript); when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), @@ -87,6 +106,7 @@ void install_dispatchesPerPackage() { @Test @DisplayName("update: routes to the BREW_UPDATE system script and propagates the caller's ExecutionSource (e.g. AI_ASSISTANT for MingoAI)") void update_usesUpdateScript_andPropagatesSource() { + allMacTargets(); ScriptResponse updateScript = systemScript("brew-update-id"); when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_UPDATE)).thenReturn(updateScript); when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.AI_ASSISTANT), @@ -102,6 +122,34 @@ void update_usesUpdateScript_andPropagatesSource() { eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.UPDATE)); } + @Test + @DisplayName("install: a mixed brew+winget bundle routes each package to its own OS — brew→macOS, winget→Windows") + void install_mixedOs_routesPerPackage() { + when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler()); + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m-mac", OsType.MAC_OS), machine("m-win", OsType.WINDOWS))); + ScriptResponse brew = script("brew-install-id", OsType.MAC_OS); + ScriptResponse winget = script("winget-install-id", OsType.WINDOWS); + when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew); + when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget); + when(softwareDispatchService.dispatch(any(), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), + any(), any(), any())) + .thenReturn("exec-brew", "exec-winget"); + + SoftwareManagementInput mixed = new SoftwareManagementInput(); + mixed.setMachineIds(List.of("m-mac", "m-win")); + mixed.setPackages(List.of( + pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK), + pkg(PackageManagerType.WINGET, "vscode", null))); + + service.install(mixed, USER, ExecutionSource.MANUAL); + + verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL)); + verify(softwareDispatchService).dispatch(eq(winget), eq(List.of("m-win")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.WINGET), eq("vscode"), eq(SoftwareAction.INSTALL)); + } + private static SoftwareManagementInput input(SoftwarePackageInput... packages) { SoftwareManagementInput input = new SoftwareManagementInput(); input.setMachineIds(MACHINES); @@ -118,10 +166,22 @@ private static SoftwarePackageInput pkg(PackageManagerType manager, String id, B } private static ScriptResponse systemScript(String id) { + return script(id, OsType.MAC_OS); + } + + private static ScriptResponse script(String id, OsType supported) { return ScriptResponse.builder() .id(id) .privilegeLevel(PrivilegeLevel.ADMIN) .defaultTimeoutSeconds(600) + .supportedPlatforms(List.of(supported)) .build(); } + + private static Machine machine(String machineId, OsType osType) { + Machine m = new Machine(); + m.setMachineId(machineId); + m.setOsType(osType); + return m; + } } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundle.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundle.java new file mode 100644 index 0000000000..20a0c82bf2 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundle.java @@ -0,0 +1,36 @@ +package com.openframe.data.document.rmm.software; + +import com.openframe.data.document.TenantScoped; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.util.List; + +@Document(collection = "software_bundles") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareBundle implements TenantScoped { + + @Id + private String id; + + private String tenantId; + private SoftwareAction action; + private SoftwareBundleStatus status; + private List machineIds; + private List packages; + private List executionIds; + + private String createdBy; + private Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + private Instant expireAt; +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundlePackage.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundlePackage.java new file mode 100644 index 0000000000..8c644e719a --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundlePackage.java @@ -0,0 +1,19 @@ +package com.openframe.data.document.rmm.software; + +import com.openframe.data.document.packagesearch.BrewPackageType; +import com.openframe.data.document.packagesearch.PackageManagerType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SoftwareBundlePackage { + + private PackageManagerType packageManager; + private String packageName; + private BrewPackageType brewPackageType; +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleStatus.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleStatus.java new file mode 100644 index 0000000000..f0be1fb10c --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleStatus.java @@ -0,0 +1,6 @@ +package com.openframe.data.document.rmm.software; + +public enum SoftwareBundleStatus { + PENDING, + COMPLETED +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareBundleRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareBundleRepository.java new file mode 100644 index 0000000000..79c63f7090 --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareBundleRepository.java @@ -0,0 +1,19 @@ +package com.openframe.data.repository.rmm; + +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SoftwareBundleRepository extends MongoRepository { + + Optional findByTenantIdAndId(String tenantId, String id); + + List findByTenantIdOrderByIdDesc(String tenantId); + + List findByTenantIdAndStatusOrderByIdDesc(String tenantId, SoftwareBundleStatus status); +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java new file mode 100644 index 0000000000..d8a48278fa --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java @@ -0,0 +1,44 @@ +package com.openframe.data.service.rmm; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.service.TenantIdProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class MachinePlatformResolver { + + private final MachineRepository machineRepository; + private final TenantIdProvider tenantIdProvider; + + public Map osTypesByMachineId(Collection machineIds) { + if (machineIds == null || machineIds.isEmpty()) { + return Map.of(); + } + return machineRepository + .findByTenantIdAndMachineIdIn(tenantIdProvider.getTenantId(), new HashSet<>(machineIds)).stream() + .filter(m -> m.getOsType() != null) + .collect(Collectors.toMap(Machine::getMachineId, Machine::getOsType, (a, b) -> a)); + } + + public List compatible(List machineIds, Map osTypes, + Collection supportedPlatforms) { + if (supportedPlatforms == null || supportedPlatforms.isEmpty()) { + return List.of(); + } + Set platforms = supportedPlatforms instanceof Set set ? set : new HashSet<>(supportedPlatforms); + return machineIds.stream() + .filter(id -> platforms.contains(osTypes.get(id))) + .collect(Collectors.toList()); + } +} diff --git a/openframe-management-service-core/src/main/java/com/openframe/management/migration/CreateSoftwareBundleTtlIndexChangeUnit.java b/openframe-management-service-core/src/main/java/com/openframe/management/migration/CreateSoftwareBundleTtlIndexChangeUnit.java new file mode 100644 index 0000000000..47f83e3b41 --- /dev/null +++ b/openframe-management-service-core/src/main/java/com/openframe/management/migration/CreateSoftwareBundleTtlIndexChangeUnit.java @@ -0,0 +1,34 @@ +package com.openframe.management.migration; + +import io.mongock.api.annotations.ChangeUnit; +import io.mongock.api.annotations.Execution; +import io.mongock.api.annotations.RollbackExecution; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; + +import java.time.Duration; + +@Slf4j +@ChangeUnit(id = "create-software-bundle-ttl-index", order = "014", author = "openframe") +public class CreateSoftwareBundleTtlIndexChangeUnit { + + private static final String COLLECTION = "software_bundles"; + private static final String EXPIRE_AT_FIELD = "expireAt"; + + @Execution + public void execution(MongoTemplate mongoTemplate) { + Index ttlIndex = new Index() + .on(EXPIRE_AT_FIELD, Sort.Direction.ASC) + .expire(Duration.ZERO) + .named("software_bundles_expireAt_ttl"); + String name = mongoTemplate.indexOps(COLLECTION).ensureIndex(ttlIndex); + log.info("Ensured TTL index '{}' on {}.{}", name, COLLECTION, EXPIRE_AT_FIELD); + } + + @RollbackExecution + public void rollback(MongoTemplate mongoTemplate) { + mongoTemplate.indexOps(COLLECTION).dropIndex("software_bundles_expireAt_ttl"); + } +} From 771ee1e149c97bd82aef5092986bbe97a5a9494b Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Thu, 17 Sep 2026 15:59:22 +0300 Subject: [PATCH 09/14] Added MachinePlatformResolverTest --- .../rmm/software/SoftwareBundleService.java | 9 +- .../software/SoftwareBundleServiceTest.java | 85 +++++++++++++++++++ ...areInstallUpdateManagementServiceTest.java | 32 +++++++ .../service/rmm/MachinePlatformResolver.java | 2 +- .../rmm/MachinePlatformResolverTest.java | 84 ++++++++++++++++++ 5 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 openframe-data-mongo-sync/src/test/java/com/openframe/data/service/rmm/MachinePlatformResolverTest.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java index 0cda0d74cb..c3161066c6 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java @@ -81,10 +81,6 @@ public boolean delete(String id, String actor) { return true; } - public SoftwareBundleResponse get(String id) { - return toResponse(loadOrThrow(id)); - } - public Optional findById(String id) { return bundleRepository.findByTenantIdAndId(tenantIdProvider.getTenantId(), id).map(SoftwareBundleService::toResponse); } @@ -111,6 +107,11 @@ public List run(String id, String actor) { ? installUpdateService.install(input, actor, ExecutionSource.MANUAL) : installUpdateService.update(input, actor, ExecutionSource.MANUAL); + if (results.isEmpty()) { + throw new BadRequestException("Cannot run software bundle " + id + + ": no package could be dispatched to a compatible device"); + } + Instant now = Instant.now(); entity.setStatus(SoftwareBundleStatus.COMPLETED); entity.setCompletedAt(now); diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java index a171506af7..30c7dec946 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java @@ -5,6 +5,7 @@ import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; import com.openframe.api.dto.rmm.software.SoftwareManagementInput; import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; import com.openframe.core.exception.BadRequestException; import com.openframe.data.document.packagesearch.PackageManagerType; import com.openframe.data.document.rmm.script.ExecutionSource; @@ -137,6 +138,90 @@ void delete_completed_rejected() { verify(bundleRepository, never()).delete(any()); } + @Test + @DisplayName("update: a PENDING bundle's devices/packages are replaced and its TTL anchor refreshed") + void update_pending_replacesAndRefreshes() { + SoftwareBundle pending = pending(SoftwareAction.INSTALL, + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(bundleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); + input.setId(BUNDLE_ID); + input.setMachineIds(List.of("m1", "m2")); + SoftwarePackageInput pkg = new SoftwarePackageInput(); + pkg.setPackageManager(PackageManagerType.BREW); + pkg.setPackageName("chrome"); + input.setPackages(List.of(pkg)); + + service.update(input, USER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(captor.capture()); + SoftwareBundle saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThat(saved.getMachineIds()).containsExactly("m1", "m2"); + assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("chrome"); + assertThat(saved.getExpireAt()).isNotNull(); + } + + @Test + @DisplayName("update: a COMPLETED bundle cannot be edited") + void update_completed_rejected() { + SoftwareBundle completed = pending(SoftwareAction.INSTALL); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + + UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); + input.setId(BUNDLE_ID); + input.setMachineIds(List.of("m1")); + + assertThatThrownBy(() -> service.update(input, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("delete: a PENDING bundle is removed") + void delete_pending_ok() { + SoftwareBundle pending = pending(SoftwareAction.INSTALL); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + + assertThat(service.delete(BUNDLE_ID, USER)).isTrue(); + verify(bundleRepository).delete(pending); + } + + @Test + @DisplayName("run: an UPDATE bundle dispatches via the update engine (not install)") + void run_updateAction_usesUpdateEngine() { + SoftwareBundle pending = pending(SoftwareAction.UPDATE, + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(installUpdateService.update(any(), eq(USER), eq(ExecutionSource.MANUAL))) + .thenReturn(List.of(result("exec-9"))); + + List results = service.run(BUNDLE_ID, USER); + + verify(installUpdateService).update(any(), eq(USER), eq(ExecutionSource.MANUAL)); + verify(installUpdateService, never()).install(any(), any(), any()); + assertThat(results).extracting(SoftwareDispatchResult::getExecutionId).containsExactly("exec-9"); + ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + } + + @Test + @DisplayName("run: when nothing dispatches (no OS-compatible device), the bundle stays PENDING and is not completed") + void run_nothingDispatched_staysPending() { + SoftwareBundle pending = pending(SoftwareAction.INSTALL, + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(installUpdateService.install(any(), eq(USER), eq(ExecutionSource.MANUAL))).thenReturn(List.of()); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).save(any()); + assertThat(pending.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + } + private static CreateSoftwareBundleInput createInput() { CreateSoftwareBundleInput input = new CreateSoftwareBundleInput(); input.setAction(SoftwareAction.INSTALL); diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java index e3d28b3f3b..f113171b08 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/software/SoftwareInstallUpdateManagementServiceTest.java @@ -32,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -150,6 +151,37 @@ void install_mixedOs_routesPerPackage() { eq(ExecutionSource.MANUAL), eq(PackageManagerType.WINGET), eq("vscode"), eq(SoftwareAction.INSTALL)); } + @Test + @DisplayName("install: a package with no OS-compatible device is skipped, while a compatible one still dispatches") + void install_incompatiblePackage_skipped() { + when(registry.handlerFor(PackageManagerType.WINGET)).thenReturn(new WingetPackageManagerHandler()); + // Only a macOS device is assigned — the winget package has nowhere to go. + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m-mac", OsType.MAC_OS))); + ScriptResponse brew = script("brew-install-id", OsType.MAC_OS); + ScriptResponse winget = script("winget-install-id", OsType.WINDOWS); + when(scriptService.getSoftwareScript(SoftwareScriptCode.BREW_INSTALL)).thenReturn(brew); + when(scriptService.getSoftwareScript(SoftwareScriptCode.WINGET_INSTALL)).thenReturn(winget); + when(softwareDispatchService.dispatch(eq(brew), anyList(), anyList(), eq(USER), eq(ExecutionSource.MANUAL), + any(), any(), any())) + .thenReturn("exec-brew"); + + SoftwareManagementInput in = new SoftwareManagementInput(); + in.setMachineIds(List.of("m-mac")); + in.setPackages(List.of( + pkg(PackageManagerType.BREW, "slack", BrewPackageType.CASK), + pkg(PackageManagerType.WINGET, "vscode", null))); + + List results = service.install(in, USER, ExecutionSource.MANUAL); + + // brew dispatched to the macOS device; winget skipped entirely (no Windows device). + assertThat(results).extracting(SoftwareDispatchResult::getPackageName).containsExactly("slack"); + verify(softwareDispatchService).dispatch(eq(brew), eq(List.of("m-mac")), anyList(), eq(USER), + eq(ExecutionSource.MANUAL), eq(PackageManagerType.BREW), eq("slack"), eq(SoftwareAction.INSTALL)); + verify(softwareDispatchService, never()).dispatch(eq(winget), anyList(), anyList(), any(), any(), + any(), any(), any()); + } + private static SoftwareManagementInput input(SoftwarePackageInput... packages) { SoftwareManagementInput input = new SoftwareManagementInput(); input.setMachineIds(MACHINES); diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java index d8a48278fa..e64c5b7762 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/service/rmm/MachinePlatformResolver.java @@ -36,7 +36,7 @@ public List compatible(List machineIds, Map osTy if (supportedPlatforms == null || supportedPlatforms.isEmpty()) { return List.of(); } - Set platforms = supportedPlatforms instanceof Set set ? set : new HashSet<>(supportedPlatforms); + Set platforms = new HashSet<>(supportedPlatforms); return machineIds.stream() .filter(id -> platforms.contains(osTypes.get(id))) .collect(Collectors.toList()); diff --git a/openframe-data-mongo-sync/src/test/java/com/openframe/data/service/rmm/MachinePlatformResolverTest.java b/openframe-data-mongo-sync/src/test/java/com/openframe/data/service/rmm/MachinePlatformResolverTest.java new file mode 100644 index 0000000000..4c410c1bbe --- /dev/null +++ b/openframe-data-mongo-sync/src/test/java/com/openframe/data/service/rmm/MachinePlatformResolverTest.java @@ -0,0 +1,84 @@ +package com.openframe.data.service.rmm; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.service.TenantIdProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MachinePlatformResolverTest { + + private static final String TENANT = "t1"; + + @Mock private MachineRepository machineRepository; + @Mock private TenantIdProvider tenantIdProvider; + + private MachinePlatformResolver resolver() { + return new MachinePlatformResolver(machineRepository, tenantIdProvider); + } + + @Test + @DisplayName("osTypesByMachineId: empty input short-circuits without touching the repository") + void osTypes_empty_noQuery() { + assertThat(resolver().osTypesByMachineId(List.of())).isEmpty(); + assertThat(resolver().osTypesByMachineId(null)).isEmpty(); + verifyNoInteractions(machineRepository, tenantIdProvider); + } + + @Test + @DisplayName("osTypesByMachineId: maps machineId → OS, dropping machines with no recorded OS") + void osTypes_mapsAndDropsNull() { + when(tenantIdProvider.getTenantId()).thenReturn(TENANT); + when(machineRepository.findByTenantIdAndMachineIdIn(eq(TENANT), any())) + .thenReturn(List.of(machine("m1", OsType.MAC_OS), machine("m2", OsType.WINDOWS), machine("m3", null))); + + Map result = resolver().osTypesByMachineId(List.of("m1", "m2", "m3")); + + assertThat(result).containsOnly( + Map.entry("m1", OsType.MAC_OS), + Map.entry("m2", OsType.WINDOWS)); + } + + @Test + @DisplayName("compatible: an empty or absent platform set matches nothing") + void compatible_noPlatforms_empty() { + Map osTypes = Map.of("m1", OsType.MAC_OS); + assertThat(resolver().compatible(List.of("m1"), osTypes, List.of())).isEmpty(); + assertThat(resolver().compatible(List.of("m1"), osTypes, null)).isEmpty(); + } + + @Test + @DisplayName("compatible: keeps only OS-matching machines, drops unknown-OS ones, preserves input order") + void compatible_filtersByOs() { + Map osTypes = Map.of( + "m-mac", OsType.MAC_OS, + "m-win", OsType.WINDOWS); + List ids = List.of("m-win", "m-unknown", "m-mac"); + + assertThat(resolver().compatible(ids, osTypes, List.of(OsType.MAC_OS))) + .containsExactly("m-mac"); + assertThat(resolver().compatible(ids, osTypes, List.of(OsType.MAC_OS, OsType.WINDOWS))) + .containsExactly("m-win", "m-mac"); + } + + private static Machine machine(String machineId, OsType osType) { + Machine m = new Machine(); + m.setMachineId(machineId); + m.setOsType(osType); + return m; + } +} From 642ba27535e238aa387e333221b9f9379b0ce5c0 Mon Sep 17 00:00:00 2001 From: Andrii Koropets Date: Fri, 18 Sep 2026 01:02:16 +0300 Subject: [PATCH 10/14] Changed logic for schedule and run now --- .../software/CreateSoftwareBundleInput.java | 7 + .../rmm/software/SoftwareBundleResponse.java | 4 + .../software/UpdateSoftwareBundleInput.java | 8 + .../rmm/software/SoftwareBundleService.java | 136 ++++++++---- .../software/SoftwareBundleServiceTest.java | 209 +++++++++++------- .../rmm/SoftwareBundleDataFetcher.java | 3 +- .../resources/schema/software-bundle.graphqls | 25 ++- ...SoftwareBundleOnlineDispatchScheduler.java | 31 +++ ...ftwareScheduleOnlineDispatchScheduler.java | 31 +++ .../SoftwareBundleOnlineDispatchService.java | 108 +++++++++ .../rmm/SoftwareBundleOnlineDispatcher.java | 105 +++++++++ .../rmm/SoftwareScheduleExecutionService.java | 80 ++++++- .../rmm/SoftwareScheduleFireDispatcher.java | 25 ++- ...SoftwareScheduleOnlineDispatchService.java | 119 ++++++++++ .../SoftwareScheduleExecutionServiceTest.java | 91 +++++++- .../SoftwareScheduleFireDispatcherTest.java | 95 ++++++-- ...ftwareBundleOnlineDispatchServiceTest.java | 104 +++++++++ ...wareScheduleOnlineDispatchServiceTest.java | 114 ++++++++++ .../SoftwareScheduleOnlineDispatch.java | 44 ++++ .../document/rmm/software/SoftwareBundle.java | 3 + .../rmm/software/SoftwareBundleMode.java | 6 + .../SoftwareBundleOnlineDispatch.java | 43 ++++ ...oftwareBundleOnlineDispatchRepository.java | 22 ++ .../rmm/SoftwareBundleRepository.java | 3 + ...twareScheduleOnlineDispatchRepository.java | 22 ++ 25 files changed, 1272 insertions(+), 166 deletions(-) create mode 100644 openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareScheduleOnlineDispatchService.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchServiceTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/SoftwareScheduleOnlineDispatchServiceTest.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/SoftwareScheduleOnlineDispatch.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleMode.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/software/SoftwareBundleOnlineDispatch.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareBundleOnlineDispatchRepository.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/SoftwareScheduleOnlineDispatchRepository.java diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java index 3282107bf8..8f72f6cebb 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/CreateSoftwareBundleInput.java @@ -1,12 +1,14 @@ package com.openframe.api.dto.rmm.software; import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; +import java.time.Instant; import java.util.List; @Data @@ -15,10 +17,15 @@ public class CreateSoftwareBundleInput { @NotNull(message = "action must not be null") private SoftwareAction action; + @NotNull(message = "mode must not be null") + private SoftwareBundleMode mode; + @NotEmpty(message = "machineIds must not be empty") private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; @Valid private List packages; + + private Instant startAt; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java index 7e92a6d43a..8a2bd2669a 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/SoftwareBundleResponse.java @@ -1,6 +1,7 @@ package com.openframe.api.dto.rmm.software; import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; import com.openframe.data.document.rmm.software.SoftwareBundlePackage; import com.openframe.data.document.rmm.software.SoftwareBundleStatus; import lombok.Builder; @@ -15,9 +16,12 @@ public class SoftwareBundleResponse { private String id; private SoftwareAction action; + private SoftwareBundleMode mode; private SoftwareBundleStatus status; private List machineIds; private List packages; + private Instant startAt; + private String scheduleId; private String createdBy; private Instant createdAt; private Instant updatedAt; diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java index 9d47132c2f..bb3033c995 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/software/UpdateSoftwareBundleInput.java @@ -1,11 +1,14 @@ package com.openframe.api.dto.rmm.software; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; +import java.time.Instant; import java.util.List; @Data @@ -14,10 +17,15 @@ public class UpdateSoftwareBundleInput { @NotBlank(message = "id must not be blank") private String id; + @NotNull(message = "mode must not be null") + private SoftwareBundleMode mode; + @NotEmpty(message = "machineIds must not be empty") private List<@Pattern(regexp = "^[A-Za-z0-9_-]+$", message = "each machineId must be a single subject-safe token (A-Za-z0-9_-)") String> machineIds; @Valid private List packages; + + private Instant startAt; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java index c3161066c6..c8610bd784 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/software/SoftwareBundleService.java @@ -1,54 +1,66 @@ package com.openframe.api.service.rmm.software; import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput; import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; -import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; -import com.openframe.api.dto.rmm.software.SoftwareManagementInput; import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareSchedulePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; import com.openframe.core.exception.BadRequestException; import com.openframe.core.exception.NotFoundException; -import com.openframe.data.document.rmm.script.ExecutionSource; -import com.openframe.data.document.rmm.software.SoftwareAction; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; import com.openframe.data.document.rmm.software.SoftwareBundlePackage; import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; import com.openframe.data.repository.rmm.SoftwareBundleRepository; import com.openframe.data.service.TenantIdProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; @Slf4j @Service -@ConditionalOnProperty(name = {"spring.cloud.stream.enabled", "openframe.rmm.software.enabled"}, havingValue = "true") +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") @RequiredArgsConstructor public class SoftwareBundleService { private final SoftwareBundleRepository bundleRepository; - private final SoftwareInstallUpdateManagementService installUpdateService; + private final SoftwareBundleOnlineDispatchRepository onlineDispatchRepository; + private final SoftwareScheduleService softwareScheduleService; private final TenantIdProvider tenantIdProvider; - /** TTL after which a still-PENDING bundle is considered abandoned; refreshed on every edit. */ - @Value("${openframe.rmm.software.bundle.pending-ttl:1h}") + @Value("${openframe.rmm.software.bundle.pending-ttl}") private Duration pendingTtl; + @Value("${openframe.rmm.software.bundle.schedule.reconnect-window-seconds}") + private long scheduleReconnectWindowSeconds; + public SoftwareBundleResponse create(CreateSoftwareBundleInput input, String createdBy) { String tenantId = tenantIdProvider.getTenantId(); Instant now = Instant.now(); SoftwareBundle entity = SoftwareBundle.builder() .tenantId(tenantId) .action(input.getAction()) + .mode(input.getMode()) .status(SoftwareBundleStatus.PENDING) .machineIds(List.copyOf(input.getMachineIds())) .packages(toDomainPackages(input.getPackages())) + .startAt(input.getStartAt()) .createdBy(createdBy) .createdAt(now) .updatedAt(now) @@ -63,8 +75,10 @@ public SoftwareBundleResponse create(CreateSoftwareBundleInput input, String cre public SoftwareBundleResponse update(UpdateSoftwareBundleInput input, String actor) { SoftwareBundle entity = loadPendingOrThrow(input.getId()); + entity.setMode(input.getMode()); entity.setMachineIds(List.copyOf(input.getMachineIds())); entity.setPackages(toDomainPackages(input.getPackages())); + entity.setStartAt(input.getStartAt()); Instant now = Instant.now(); entity.setUpdatedAt(now); entity.setExpireAt(now.plus(pendingTtl)); @@ -93,36 +107,93 @@ public List list(SoftwareBundleStatus status) { return bundles.stream().map(SoftwareBundleService::toResponse).toList(); } - public List run(String id, String actor) { + public SoftwareBundleResponse run(String id, String actor) { SoftwareBundle entity = loadPendingOrThrow(id); if (entity.getPackages() == null || entity.getPackages().isEmpty()) { throw new BadRequestException("Cannot run software bundle " + id + ": no packages selected"); } - - SoftwareManagementInput input = new SoftwareManagementInput(); - input.setMachineIds(entity.getMachineIds()); - input.setPackages(toInputPackages(entity.getPackages())); - - List results = entity.getAction() == SoftwareAction.INSTALL - ? installUpdateService.install(input, actor, ExecutionSource.MANUAL) - : installUpdateService.update(input, actor, ExecutionSource.MANUAL); - - if (results.isEmpty()) { - throw new BadRequestException("Cannot run software bundle " + id - + ": no package could be dispatched to a compatible device"); + if (entity.getMachineIds() == null || entity.getMachineIds().isEmpty()) { + throw new BadRequestException("Cannot run software bundle " + id + ": no devices selected"); } Instant now = Instant.now(); + if (entity.getMode() == SoftwareBundleMode.SCHEDULED) { + runScheduled(entity, actor); + } else { + armOnlineDispatch(entity, now); + } + entity.setStatus(SoftwareBundleStatus.COMPLETED); entity.setCompletedAt(now); entity.setUpdatedAt(now); entity.setExpireAt(null); // completed bundles are history — never reaped - entity.setExecutionIds(results.stream().map(SoftwareDispatchResult::getExecutionId).toList()); bundleRepository.save(entity); - log.info("Ran software bundle id={} action={} dispatched={} actor={}", - id, entity.getAction(), results.size(), actor); - return results; + log.info("Ran software bundle id={} mode={} action={} devices={} actor={}", + id, entity.getMode(), entity.getAction(), entity.getMachineIds().size(), actor); + return toResponse(entity); + } + + private void runScheduled(SoftwareBundle bundle, String actor) { + if (bundle.getStartAt() == null) { + throw new BadRequestException("Cannot run SCHEDULED software bundle " + bundle.getId() + + ": startAt is required"); + } + CreateSoftwareScheduleInput input = new CreateSoftwareScheduleInput(); + input.setName(scheduleName(bundle)); + input.setAction(bundle.getAction()); + input.setPackages(toSchedulePackages(bundle.getPackages())); + input.setTimeReference(ScheduleTimeReference.SERVER); + input.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + input.setReconnectWindowSeconds(scheduleReconnectWindowSeconds); + input.setStartAt(bundle.getStartAt()); + input.setMachineIds(bundle.getMachineIds()); + + SoftwareScheduleResponse schedule = softwareScheduleService.create(input, actor); + bundle.setScheduleId(schedule.getId()); + log.info("SCHEDULED software bundle id={} → created schedule id={} startAt={} reconnectWindowSeconds={}", + bundle.getId(), schedule.getId(), bundle.getStartAt(), scheduleReconnectWindowSeconds); + } + + private static String scheduleName(SoftwareBundle bundle) { + String first = bundle.getPackages().get(0).getPackageName(); + int extra = bundle.getPackages().size() - 1; + String label = extra > 0 ? first + " +" + extra + " more" : first; + return bundle.getAction() + " " + label + " [" + bundle.getId() + "]"; + } + + private static List toSchedulePackages(List packages) { + return packages.stream() + .map(p -> { + SoftwareSchedulePackageInput i = new SoftwareSchedulePackageInput(); + i.setPackageManager(p.getPackageManager()); + i.setPackageName(p.getPackageName()); + i.setBrewPackageType(p.getBrewPackageType()); + return i; + }) + .toList(); + } + + private void armOnlineDispatch(SoftwareBundle bundle, Instant now) { + for (String machineId : new LinkedHashSet<>(bundle.getMachineIds())) { + boolean alreadyArmed = onlineDispatchRepository + .findByTenantIdAndMachineIdAndBundleId(bundle.getTenantId(), machineId, bundle.getId()) + .isPresent(); + if (alreadyArmed) { + continue; + } + try { + onlineDispatchRepository.save(SoftwareBundleOnlineDispatch.builder() + .tenantId(bundle.getTenantId()) + .machineId(machineId) + .bundleId(bundle.getId()) + .firstSeenAt(now) + .status(DeviceOnlineDispatchStatus.NEW) + .build()); + } catch (DuplicateKeyException raced) { + log.debug("Bundle online sentinel already armed bundleId={} machineId={}", bundle.getId(), machineId); + } + } } private SoftwareBundle loadOrThrow(String id) { @@ -152,25 +223,16 @@ private static List toDomainPackages(List toInputPackages(List packages) { - return packages.stream() - .map(p -> { - SoftwarePackageInput input = new SoftwarePackageInput(); - input.setPackageManager(p.getPackageManager()); - input.setPackageName(p.getPackageName()); - input.setBrewPackageType(p.getBrewPackageType()); - return input; - }) - .toList(); - } - private static SoftwareBundleResponse toResponse(SoftwareBundle b) { return SoftwareBundleResponse.builder() .id(b.getId()) .action(b.getAction()) + .mode(b.getMode()) .status(b.getStatus()) .machineIds(b.getMachineIds()) .packages(b.getPackages()) + .startAt(b.getStartAt()) + .scheduleId(b.getScheduleId()) .createdBy(b.getCreatedBy()) .createdAt(b.getCreatedAt()) .updatedAt(b.getUpdatedAt()) diff --git a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java index 30c7dec946..b06e924c82 100644 --- a/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java +++ b/openframe-api-lib/src/test/java/com/openframe/api/service/rmm/software/SoftwareBundleServiceTest.java @@ -1,18 +1,23 @@ package com.openframe.api.service.rmm.software; import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; +import com.openframe.api.dto.rmm.software.CreateSoftwareScheduleInput; import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; -import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; -import com.openframe.api.dto.rmm.software.SoftwareManagementInput; import com.openframe.api.dto.rmm.software.SoftwarePackageInput; +import com.openframe.api.dto.rmm.software.SoftwareScheduleResponse; import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; import com.openframe.core.exception.BadRequestException; import com.openframe.data.document.packagesearch.PackageManagerType; -import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.software.SoftwareAction; import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleMode; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; import com.openframe.data.document.rmm.software.SoftwareBundlePackage; import com.openframe.data.document.rmm.software.SoftwareBundleStatus; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; import com.openframe.data.repository.rmm.SoftwareBundleRepository; import com.openframe.data.service.TenantIdProvider; import org.junit.jupiter.api.BeforeEach; @@ -25,14 +30,17 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -43,22 +51,25 @@ class SoftwareBundleServiceTest { private static final String TENANT = "t1"; private static final String USER = "user-1"; private static final String BUNDLE_ID = "b1"; + private static final long RECONNECT_WINDOW = 604800L; @Mock private SoftwareBundleRepository bundleRepository; - @Mock private SoftwareInstallUpdateManagementService installUpdateService; + @Mock private SoftwareBundleOnlineDispatchRepository onlineDispatchRepository; + @Mock private SoftwareScheduleService softwareScheduleService; @Mock private TenantIdProvider tenantIdProvider; private SoftwareBundleService service; @BeforeEach void setUp() { - service = new SoftwareBundleService(bundleRepository, installUpdateService, tenantIdProvider); + service = new SoftwareBundleService(bundleRepository, onlineDispatchRepository, softwareScheduleService, tenantIdProvider); ReflectionTestUtils.setField(service, "pendingTtl", Duration.ofHours(1)); + ReflectionTestUtils.setField(service, "scheduleReconnectWindowSeconds", RECONNECT_WINDOW); when(tenantIdProvider.getTenantId()).thenReturn(TENANT); } @Test - @DisplayName("create: born PENDING with a TTL anchor, devices and packages persisted") + @DisplayName("create: born PENDING with a TTL anchor, mode/devices/packages persisted") void create_bornPending() { when(bundleRepository.save(any())).thenAnswer(inv -> withId(inv.getArgument(0))); @@ -68,7 +79,7 @@ void create_bornPending() { verify(bundleRepository).save(captor.capture()); SoftwareBundle saved = captor.getValue(); assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); - assertThat(saved.getTenantId()).isEqualTo(TENANT); + assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.NOW); assertThat(saved.getMachineIds()).containsExactly("m1"); assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("slack"); assertThat(saved.getExpireAt()).isNotNull(); @@ -76,79 +87,129 @@ void create_bornPending() { } @Test - @DisplayName("run: dispatches via the install engine, then flips to COMPLETED and clears the TTL anchor") - void run_dispatchesAndCompletes() { - SoftwareBundle pending = pending(SoftwareAction.INSTALL, + @DisplayName("run NOW: arms one NEW device-online sentinel per assigned device and marks the bundle COMPLETED") + void runNow_armsSentinelsAndCompletes() { + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1", "m2"), SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); - when(installUpdateService.install(any(), eq(USER), eq(ExecutionSource.MANUAL))) - .thenReturn(List.of(result("exec-1"))); - - List results = service.run(BUNDLE_ID, USER); - - // The engine receives the bundle's devices + packages. - ArgumentCaptor inputCaptor = ArgumentCaptor.forClass(SoftwareManagementInput.class); - verify(installUpdateService).install(inputCaptor.capture(), eq(USER), eq(ExecutionSource.MANUAL)); - assertThat(inputCaptor.getValue().getMachineIds()).containsExactly("m1"); - assertThat(inputCaptor.getValue().getPackages()).extracting(SoftwarePackageInput::getPackageName).containsExactly("slack"); - - ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(SoftwareBundle.class); - verify(bundleRepository).save(saveCaptor.capture()); - SoftwareBundle saved = saveCaptor.getValue(); - assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); - assertThat(saved.getCompletedAt()).isNotNull(); - assertThat(saved.getExpireAt()).isNull(); - assertThat(saved.getExecutionIds()).containsExactly("exec-1"); - assertThat(results).extracting(SoftwareDispatchResult::getExecutionId).containsExactly("exec-1"); + when(onlineDispatchRepository.findByTenantIdAndMachineIdAndBundleId(eq(TENANT), anyString(), eq(BUNDLE_ID))) + .thenReturn(Optional.empty()); + + SoftwareBundleResponse res = service.run(BUNDLE_ID, USER); + + ArgumentCaptor sentinels = ArgumentCaptor.forClass(SoftwareBundleOnlineDispatch.class); + verify(onlineDispatchRepository, times(2)).save(sentinels.capture()); + assertThat(sentinels.getAllValues()).extracting(SoftwareBundleOnlineDispatch::getMachineId) + .containsExactlyInAnyOrder("m1", "m2"); + assertThat(sentinels.getAllValues()).allSatisfy(s -> { + assertThat(s.getBundleId()).isEqualTo(BUNDLE_ID); + assertThat(s.getStatus()).isEqualTo(DeviceOnlineDispatchStatus.NEW); + }); + verifyNoInteractions(softwareScheduleService); + + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getValue().getExpireAt()).isNull(); + assertThat(res.getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); } @Test - @DisplayName("run: an already-COMPLETED bundle cannot be re-run (no double dispatch)") + @DisplayName("run SCHEDULED: creates a DATE_TIME SoftwareSchedule (SERVER, RETRY_ON_RECONNECT) and links it, no sentinels armed") + void runScheduled_createsScheduleWithRetry() { + Instant startAt = Instant.parse("2026-09-20T02:00:00Z"); + SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, startAt, List.of("m1", "m2"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + when(softwareScheduleService.create(any(), eq(USER))) + .thenReturn(SoftwareScheduleResponse.builder().id("sched-1").build()); + + SoftwareBundleResponse res = service.run(BUNDLE_ID, USER); + + ArgumentCaptor input = ArgumentCaptor.forClass(CreateSoftwareScheduleInput.class); + verify(softwareScheduleService).create(input.capture(), eq(USER)); + CreateSoftwareScheduleInput created = input.getValue(); + assertThat(created.getAction()).isEqualTo(SoftwareAction.INSTALL); + assertThat(created.getTimeReference()).isEqualTo(ScheduleTimeReference.SERVER); + assertThat(created.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(created.getReconnectWindowSeconds()).isEqualTo(RECONNECT_WINDOW); + assertThat(created.getStartAt()).isEqualTo(startAt); + assertThat(created.getRepeat()).isNull(); + assertThat(created.getMachineIds()).containsExactly("m1", "m2"); + assertThat(created.getPackages()).extracting("packageName").containsExactly("slack"); + assertThat(created.getName()).contains(BUNDLE_ID); // unique per tenant + + verifyNoInteractions(onlineDispatchRepository); + ArgumentCaptor saved = ArgumentCaptor.forClass(SoftwareBundle.class); + verify(bundleRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); + assertThat(saved.getValue().getScheduleId()).isEqualTo("sched-1"); + assertThat(res.getScheduleId()).isEqualTo("sched-1"); + } + + @Test + @DisplayName("run SCHEDULED without startAt is rejected before creating a schedule") + void runScheduled_noStartAt_rejected() { + SoftwareBundle pending = pending(SoftwareBundleMode.SCHEDULED, null, List.of("m1"), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(softwareScheduleService); + verify(bundleRepository, never()).save(any()); + } + + @Test + @DisplayName("run: an already-COMPLETED bundle cannot be re-run") void run_completed_rejected() { - SoftwareBundle completed = pending(SoftwareAction.INSTALL, + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1"), SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); completed.setStatus(SoftwareBundleStatus.COMPLETED); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); - verifyNoInteractions(installUpdateService); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); verify(bundleRepository, never()).save(any()); } @Test - @DisplayName("run: a bundle with no packages is rejected before any dispatch") + @DisplayName("run: a bundle with no packages is rejected before arming") void run_noPackages_rejected() { - SoftwareBundle empty = pending(SoftwareAction.INSTALL); + SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of("m1")); empty.setPackages(List.of()); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty)); assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); - verifyNoInteractions(installUpdateService); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); verify(bundleRepository, never()).save(any()); } @Test - @DisplayName("delete: a COMPLETED bundle is protected (history is immutable)") - void delete_completed_rejected() { - SoftwareBundle completed = pending(SoftwareAction.INSTALL); - completed.setStatus(SoftwareBundleStatus.COMPLETED); - when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); + @DisplayName("run: a bundle with no devices is rejected before arming") + void run_noDevices_rejected() { + SoftwareBundle empty = pending(SoftwareBundleMode.NOW, null, List.of(), + SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(empty)); - assertThatThrownBy(() -> service.delete(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); - verify(bundleRepository, never()).delete(any()); + assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verifyNoInteractions(onlineDispatchRepository, softwareScheduleService); + verify(bundleRepository, never()).save(any()); } @Test - @DisplayName("update: a PENDING bundle's devices/packages are replaced and its TTL anchor refreshed") + @DisplayName("update: a PENDING bundle's mode/devices/packages/startAt are replaced and its TTL anchor refreshed") void update_pending_replacesAndRefreshes() { - SoftwareBundle pending = pending(SoftwareAction.INSTALL, + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1"), SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); when(bundleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + Instant startAt = Instant.parse("2026-09-20T02:00:00Z"); UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); input.setId(BUNDLE_ID); + input.setMode(SoftwareBundleMode.SCHEDULED); input.setMachineIds(List.of("m1", "m2")); + input.setStartAt(startAt); SoftwarePackageInput pkg = new SoftwarePackageInput(); pkg.setPackageManager(PackageManagerType.BREW); pkg.setPackageName("chrome"); @@ -160,7 +221,9 @@ void update_pending_replacesAndRefreshes() { verify(bundleRepository).save(captor.capture()); SoftwareBundle saved = captor.getValue(); assertThat(saved.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThat(saved.getMode()).isEqualTo(SoftwareBundleMode.SCHEDULED); assertThat(saved.getMachineIds()).containsExactly("m1", "m2"); + assertThat(saved.getStartAt()).isEqualTo(startAt); assertThat(saved.getPackages()).extracting(SoftwareBundlePackage::getPackageName).containsExactly("chrome"); assertThat(saved.getExpireAt()).isNotNull(); } @@ -168,12 +231,13 @@ void update_pending_replacesAndRefreshes() { @Test @DisplayName("update: a COMPLETED bundle cannot be edited") void update_completed_rejected() { - SoftwareBundle completed = pending(SoftwareAction.INSTALL); + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1")); completed.setStatus(SoftwareBundleStatus.COMPLETED); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); UpdateSoftwareBundleInput input = new UpdateSoftwareBundleInput(); input.setId(BUNDLE_ID); + input.setMode(SoftwareBundleMode.NOW); input.setMachineIds(List.of("m1")); assertThatThrownBy(() -> service.update(input, USER)).isInstanceOf(BadRequestException.class); @@ -183,7 +247,7 @@ void update_completed_rejected() { @Test @DisplayName("delete: a PENDING bundle is removed") void delete_pending_ok() { - SoftwareBundle pending = pending(SoftwareAction.INSTALL); + SoftwareBundle pending = pending(SoftwareBundleMode.NOW, null, List.of("m1")); when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); assertThat(service.delete(BUNDLE_ID, USER)).isTrue(); @@ -191,40 +255,20 @@ void delete_pending_ok() { } @Test - @DisplayName("run: an UPDATE bundle dispatches via the update engine (not install)") - void run_updateAction_usesUpdateEngine() { - SoftwareBundle pending = pending(SoftwareAction.UPDATE, - SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); - when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); - when(installUpdateService.update(any(), eq(USER), eq(ExecutionSource.MANUAL))) - .thenReturn(List.of(result("exec-9"))); - - List results = service.run(BUNDLE_ID, USER); - - verify(installUpdateService).update(any(), eq(USER), eq(ExecutionSource.MANUAL)); - verify(installUpdateService, never()).install(any(), any(), any()); - assertThat(results).extracting(SoftwareDispatchResult::getExecutionId).containsExactly("exec-9"); - ArgumentCaptor captor = ArgumentCaptor.forClass(SoftwareBundle.class); - verify(bundleRepository).save(captor.capture()); - assertThat(captor.getValue().getStatus()).isEqualTo(SoftwareBundleStatus.COMPLETED); - } - - @Test - @DisplayName("run: when nothing dispatches (no OS-compatible device), the bundle stays PENDING and is not completed") - void run_nothingDispatched_staysPending() { - SoftwareBundle pending = pending(SoftwareAction.INSTALL, - SoftwareBundlePackage.builder().packageManager(PackageManagerType.BREW).packageName("slack").build()); - when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(pending)); - when(installUpdateService.install(any(), eq(USER), eq(ExecutionSource.MANUAL))).thenReturn(List.of()); + @DisplayName("delete: a COMPLETED bundle is protected (history is immutable)") + void delete_completed_rejected() { + SoftwareBundle completed = pending(SoftwareBundleMode.NOW, null, List.of("m1")); + completed.setStatus(SoftwareBundleStatus.COMPLETED); + when(bundleRepository.findByTenantIdAndId(TENANT, BUNDLE_ID)).thenReturn(Optional.of(completed)); - assertThatThrownBy(() -> service.run(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); - verify(bundleRepository, never()).save(any()); - assertThat(pending.getStatus()).isEqualTo(SoftwareBundleStatus.PENDING); + assertThatThrownBy(() -> service.delete(BUNDLE_ID, USER)).isInstanceOf(BadRequestException.class); + verify(bundleRepository, never()).delete(any()); } private static CreateSoftwareBundleInput createInput() { CreateSoftwareBundleInput input = new CreateSoftwareBundleInput(); input.setAction(SoftwareAction.INSTALL); + input.setMode(SoftwareBundleMode.NOW); input.setMachineIds(List.of("m1")); SoftwarePackageInput pkg = new SoftwarePackageInput(); pkg.setPackageManager(PackageManagerType.BREW); @@ -233,14 +277,17 @@ private static CreateSoftwareBundleInput createInput() { return input; } - private static SoftwareBundle pending(SoftwareAction action, SoftwareBundlePackage... packages) { + private static SoftwareBundle pending(SoftwareBundleMode mode, Instant startAt, List machineIds, + SoftwareBundlePackage... packages) { return SoftwareBundle.builder() .id(BUNDLE_ID) .tenantId(TENANT) - .action(action) + .action(SoftwareAction.INSTALL) + .mode(mode) .status(SoftwareBundleStatus.PENDING) - .machineIds(List.of("m1")) + .machineIds(machineIds) .packages(List.of(packages)) + .startAt(startAt) .build(); } @@ -250,12 +297,4 @@ private static SoftwareBundle withId(SoftwareBundle b) { } return b; } - - private static SoftwareDispatchResult result(String executionId) { - return SoftwareDispatchResult.builder() - .packageManager(PackageManagerType.BREW) - .packageName("slack") - .executionId(executionId) - .build(); - } } diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java index c3f5d82606..b21c8bd076 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/rmm/SoftwareBundleDataFetcher.java @@ -6,7 +6,6 @@ import com.netflix.graphql.dgs.InputArgument; import com.openframe.api.dto.rmm.software.CreateSoftwareBundleInput; import com.openframe.api.dto.rmm.software.SoftwareBundleResponse; -import com.openframe.api.dto.rmm.software.SoftwareDispatchResult; import com.openframe.api.dto.rmm.software.UpdateSoftwareBundleInput; import com.openframe.api.service.rmm.software.SoftwareBundleService; import com.openframe.data.document.rmm.software.SoftwareBundleStatus; @@ -57,7 +56,7 @@ public boolean deleteSoftwareBundle(@InputArgument String id) { } @DgsMutation - public List runSoftwareBundle(@InputArgument String id) { + public SoftwareBundleResponse runSoftwareBundle(@InputArgument String id) { return softwareBundleService.run(id, getCurrentUserId()); } diff --git a/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls index dee0bacea2..baa92008e0 100644 --- a/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/software-bundle.graphqls @@ -28,9 +28,10 @@ extend type Mutation { """Discard a PENDING bundle. Fails if the bundle is already COMPLETED (history is immutable).""" deleteSoftwareBundle(id: ID!): Boolean! - """Run a PENDING bundle now: dispatch every package to its OS-compatible devices and mark it COMPLETED. - Returns one dispatch result (with its executionId) per dispatched package. Fails if already COMPLETED.""" - runSoftwareBundle(id: ID!): [SoftwareDispatchResult!]! + """Run a PENDING bundle now (Device-Online): arm each assigned device so its OS-compatible packages run as + soon as it is online — immediately for online devices, on reconnect for offline ones — and mark the bundle + COMPLETED. Returns the COMPLETED bundle. Fails if already COMPLETED.""" + runSoftwareBundle(id: ID!): SoftwareBundle! } """A staged install/update operation: devices + packages, plus lifecycle state.""" @@ -38,12 +39,18 @@ type SoftwareBundle { id: ID! "INSTALL or UPDATE — fixed at creation." action: SoftwareAction! + "NOW (Device-Online) or SCHEDULED (fires a DATE_TIME schedule at startAt)." + mode: SoftwareBundleMode! "PENDING while drafting, COMPLETED once run. Server-controlled; never settable." status: SoftwareBundleStatus! "Assigned device machineIds (1..n)." machineIds: [String!]! "Packages to act on. May be empty while PENDING." packages: [SoftwareBundlePackage!]! + "For SCHEDULED bundles: when the run fires (absolute instant, SERVER time). Null for NOW." + startAt: Instant + "For SCHEDULED bundles: the schedule created on run. Null for NOW / while PENDING." + scheduleId: String createdBy: String createdAt: Instant updatedAt: Instant @@ -53,6 +60,11 @@ type SoftwareBundle { executionIds: [String!] } +enum SoftwareBundleMode { + NOW + SCHEDULED +} + """One catalog package staged in a bundle.""" type SoftwareBundlePackage { packageManager: PackageManagerType! @@ -65,16 +77,21 @@ enum SoftwareBundleStatus { COMPLETED } -"""Create a bundle. machineIds must have at least one device; packages may be omitted while drafting.""" +"""Create a bundle. machineIds must have at least one device; packages may be omitted while drafting. +startAt is required for SCHEDULED bundles (enforced at run) and ignored for NOW.""" input CreateSoftwareBundleInput { action: SoftwareAction! + mode: SoftwareBundleMode! machineIds: [String!]! packages: [SoftwarePackageInput!] + startAt: Instant } """Edit a PENDING bundle. action and status are immutable and cannot be set.""" input UpdateSoftwareBundleInput { id: ID! + mode: SoftwareBundleMode! machineIds: [String!]! packages: [SoftwarePackageInput!] + startAt: Instant } diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java new file mode 100644 index 0000000000..7106eab07e --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareBundleOnlineDispatchScheduler.java @@ -0,0 +1,31 @@ +package com.openframe.client.scheduler; + +import com.openframe.client.service.rmm.SoftwareBundleOnlineDispatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatchScheduler { + + private final SoftwareBundleOnlineDispatchService dispatchService; + + @Scheduled(fixedDelayString = "${openframe.rmm.software.bundle.online-dispatch.interval:60000}") + @SchedulerLock(name = "softwareBundleOnlineDispatch", + lockAtMostFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-most-for:2m}", + lockAtLeastFor = "${openframe.rmm.software.bundle.online-dispatch.lock-at-least-for:10s}" + ) + public void run() { + try { + dispatchService.processDevicesBecameOnline(); + } catch (Exception e) { + log.error("Software bundle online-dispatch sweep failed", e); + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java new file mode 100644 index 0000000000..e07057b401 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/SoftwareScheduleOnlineDispatchScheduler.java @@ -0,0 +1,31 @@ +package com.openframe.client.scheduler; + +import com.openframe.client.service.rmm.SoftwareScheduleOnlineDispatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareScheduleOnlineDispatchScheduler { + + private final SoftwareScheduleOnlineDispatchService dispatchService; + + @Scheduled(fixedDelayString = "${openframe.rmm.software.schedule.online-dispatch.interval:60000}") + @SchedulerLock(name = "softwareScheduleOnlineDispatch", + lockAtMostFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-most-for:2m}", + lockAtLeastFor = "${openframe.rmm.software.schedule.online-dispatch.lock-at-least-for:10s}" + ) + public void run() { + try { + dispatchService.processReconnectedDevices(); + } catch (Exception e) { + log.error("Software schedule reconnect sweep failed", e); + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java new file mode 100644 index 0000000000..3a319043d7 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatchService.java @@ -0,0 +1,108 @@ +package com.openframe.client.service.rmm; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.schedule.DeviceOnlineDispatchStatus; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundleOnlineDispatch; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.rmm.SoftwareBundleOnlineDispatchRepository; +import com.openframe.data.repository.rmm.SoftwareBundleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.toSet; + +@Service +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatchService { + + private static final String FIELD_FIRST_SEEN_AT = "firstSeenAt"; + + private final SoftwareBundleOnlineDispatchRepository dispatchRepository; + private final MachineRepository machineRepository; + private final SoftwareBundleRepository bundleRepository; + private final SoftwareBundleOnlineDispatcher bundleDispatcher; + + @Value("${openframe.rmm.software.bundle.online-dispatch.batch-size:500}") + private int batchSize; + + public void processDevicesBecameOnline() { + List batch = dispatchRepository.findByStatus( + DeviceOnlineDispatchStatus.NEW, PageRequest.of(0, batchSize, Sort.by(Sort.Direction.ASC, FIELD_FIRST_SEEN_AT))); + if (batch.isEmpty()) { + return; + } + log.info("Software bundle online-dispatch tick: processing up to {} pending row(s) (oldest first)", batch.size()); + + Instant now = Instant.now(); + Map> rowsByTenant = + batch.stream().collect(groupingBy(SoftwareBundleOnlineDispatch::getTenantId)); + + List changed = new ArrayList<>(); + for (Map.Entry> e : rowsByTenant.entrySet()) { + changed.addAll(processTenant(e.getKey(), e.getValue(), now)); + } + + if (!changed.isEmpty()) { + dispatchRepository.saveAll(changed); + } + } + + private List processTenant(String tenantId, + List tenantRows, Instant now) { + Set machineIds = tenantRows.stream() + .map(SoftwareBundleOnlineDispatch::getMachineId).collect(toSet()); + Map machinesById = machineRepository + .findByTenantIdAndMachineIdIn(tenantId, machineIds).stream() + .collect(toMap(Machine::getMachineId, m -> m)); + + Set bundleIds = tenantRows.stream() + .map(SoftwareBundleOnlineDispatch::getBundleId).collect(toSet()); + Map bundlesById = bundleRepository + .findByTenantIdAndIdIn(tenantId, bundleIds).stream() + .collect(toMap(SoftwareBundle::getId, b -> b)); + + List changed = new ArrayList<>(tenantRows.size()); + for (SoftwareBundleOnlineDispatch row : tenantRows) { + try { + Machine machine = machinesById.get(row.getMachineId()); + if (machine == null || machine.getStatus() != DeviceStatus.ONLINE) { + continue; + } + + SoftwareBundle bundle = bundlesById.get(row.getBundleId()); + if (bundle != null) { + bundleDispatcher.dispatch(bundle, machine); + log.info("Software bundle online-dispatched: machineId={} bundleId={} tenantId={}", + row.getMachineId(), row.getBundleId(), tenantId); + } else { + log.warn("Software bundle online-dispatch: bundle missing bundleId={} machineId={} tenantId={} — draining", + row.getBundleId(), row.getMachineId(), tenantId); + } + row.setStatus(DeviceOnlineDispatchStatus.DISPATCHED); + row.setDispatchedAt(now); + changed.add(row); + } catch (Exception ex) { + log.error("Software bundle online-dispatch failed: tenantId={} machineId={} bundleId={} (will retry next tick)", + row.getTenantId(), row.getMachineId(), row.getBundleId(), ex); + } + } + return changed; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java new file mode 100644 index 0000000000..5d4d967d7e --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/SoftwareBundleOnlineDispatcher.java @@ -0,0 +1,105 @@ +package com.openframe.client.service.rmm; + +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.script.DeliveryChannel; +import com.openframe.data.document.rmm.script.ExecutionSource; +import com.openframe.data.document.rmm.script.OsType; +import com.openframe.data.document.rmm.script.RunningExecutionRows; +import com.openframe.data.document.rmm.script.Script; +import com.openframe.data.document.rmm.script.ScriptType; +import com.openframe.data.document.rmm.software.SoftwareBundle; +import com.openframe.data.document.rmm.software.SoftwareBundlePackage; +import com.openframe.data.document.rmm.software.SoftwareScriptCode; +import com.openframe.data.nats.rmm.model.ScriptMessage; +import com.openframe.data.nats.rmm.publisher.SoftwareNatsPublisher; +import com.openframe.data.repository.rmm.ScriptExecutionRepository; +import com.openframe.data.repository.rmm.ScriptRepository; +import com.openframe.data.service.rmm.software.PackageManagerHandler; +import com.openframe.data.service.rmm.software.PackageManagerRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.software.enabled", havingValue = "true") +@RequiredArgsConstructor +@Slf4j +public class SoftwareBundleOnlineDispatcher { + + private final ScriptRepository scriptRepository; + private final PackageManagerRegistry packageManagerRegistry; + private final ScriptExecutionRepository scriptExecutionRepository; + private final SoftwareNatsPublisher softwareNatsPublisher; + private final ScriptDeliveryRetryStore retryStore; + + public void dispatch(SoftwareBundle bundle, Machine machine) { + if (bundle.getPackages() == null || bundle.getPackages().isEmpty()) { + return; + } + for (SoftwareBundlePackage pkg : bundle.getPackages()) { + dispatchPackage(bundle, pkg, machine); + } + } + + private void dispatchPackage(SoftwareBundle bundle, SoftwareBundlePackage pkg, Machine machine) { + String machineId = machine.getMachineId(); + PackageManagerHandler handler = packageManagerRegistry.handlerFor(pkg.getPackageManager()); + SoftwareScriptCode code = handler.scriptCode(bundle.getAction()); + + Optional