From 0d768a303bc3cbeaf9fcf1a51a0e08313638063e Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Tue, 15 Sep 2026 18:00:44 +0200 Subject: [PATCH 01/13] feat(rmm): delivery spec skeleton for application-level retry (RFC) Shape only, nothing wired: DeliveryKind/DeliveryStatus/DeliveryFailure, MachineDelivery document + repository, DeliverySpec + registry (mirrors NotificationTypeRegistry), DeliveryProperties (defaults + per-kind YAML overrides, gated by openframe.rmm.delivery.enabled) and the first spec, ToolInstallationDeliverySpec, on a new publish(machineId, message) overload of ToolInstallationNatsPublisher. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../rmm/delivery/DeliveryProperties.java | 70 ++++++++++++++++ .../service/rmm/delivery/DeliverySpec.java | 16 ++++ .../rmm/delivery/DeliverySpecRegistry.java | 42 ++++++++++ .../ToolInstallationDeliverySpec.java | 38 +++++++++ .../rmm/delivery/DeliveryPropertiesTest.java | 81 +++++++++++++++++++ .../delivery/DeliverySpecRegistryTest.java | 81 +++++++++++++++++++ .../rmm/delivery/DeliveryFailure.java | 7 ++ .../document/rmm/delivery/DeliveryKind.java | 9 +++ .../document/rmm/delivery/DeliveryStatus.java | 8 ++ .../rmm/delivery/MachineDelivery.java | 54 +++++++++++++ .../rmm/MachineDeliveryRepository.java | 18 +++++ .../ToolInstallationNatsPublisher.java | 6 +- 12 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java create mode 100644 openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java new file mode 100644 index 0000000000..ac28811912 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java @@ -0,0 +1,70 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import java.util.EnumMap; +import java.util.Map; + +import static java.util.Objects.requireNonNullElse; + +@Getter +@Setter +@Validated +@Component +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "openframe.rmm.delivery") +public class DeliveryProperties { + + @Valid + @NotNull + private Policy defaults; + + // deliberately not @Valid: a per-kind entry lists only the fields it overrides + private Map kinds = new EnumMap<>(DeliveryKind.class); + + public Policy resolve(DeliveryKind kind) { + Policy override = kinds.get(kind); + if (override == null) { + return defaults; + } + return override.mergeOver(defaults); + } + + @Getter + @Setter + public static class Policy { + + @NotNull + private Long ackThresholdSeconds; + @NotNull + private Integer maxAttempts; + @NotNull + private ScheduleOfflineBehavior offlineBehavior; + @NotNull + private Long reconnectWindowSeconds; + @NotNull + private Long resultTimeoutSeconds; + @NotNull + private Long ttlSeconds; + + Policy mergeOver(Policy base) { + Policy merged = new Policy(); + merged.ackThresholdSeconds = requireNonNullElse(ackThresholdSeconds, base.ackThresholdSeconds); + merged.maxAttempts = requireNonNullElse(maxAttempts, base.maxAttempts); + merged.offlineBehavior = requireNonNullElse(offlineBehavior, base.offlineBehavior); + merged.reconnectWindowSeconds = requireNonNullElse(reconnectWindowSeconds, base.reconnectWindowSeconds); + merged.resultTimeoutSeconds = requireNonNullElse(resultTimeoutSeconds, base.resultTimeoutSeconds); + merged.ttlSeconds = requireNonNullElse(ttlSeconds, base.ttlSeconds); + return merged; + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java new file mode 100644 index 0000000000..3c1fdf9108 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java @@ -0,0 +1,16 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; + +public interface DeliverySpec

{ + + DeliveryKind getKind(); + + Class

getPayloadClass(); + + void publish(String machineId, P payload); + + void onFailed(MachineDelivery delivery, DeliveryFailure failure); +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java new file mode 100644 index 0000000000..da9423070b --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java @@ -0,0 +1,42 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toUnmodifiableMap; + +@Slf4j +@Component +public class DeliverySpecRegistry { + + private final Map> byKind; + + // ObjectProvider, not List: a service with zero specs on the classpath must still boot. + // toUnmodifiableMap throws IllegalStateException on a duplicate kind — the wanted fail-fast. + public DeliverySpecRegistry(ObjectProvider> specs) { + this.byKind = specs.stream() + .collect(toUnmodifiableMap(DeliverySpec::getKind, identity())); + Set registered = byKind.keySet(); + Set sortedKinds = new TreeSet<>(registered); + log.info("Registered {} delivery spec(s): {}", byKind.size(), sortedKinds); + } + + public DeliverySpec require(DeliveryKind kind) { + DeliverySpec spec = byKind.get(kind); + if (spec == null) { + throw new IllegalArgumentException("No spec registered for delivery kind: " + kind.name()); + } + return spec; + } + + public Set kinds() { + return byKind.keySet(); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java new file mode 100644 index 0000000000..36b7cf1eed --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java @@ -0,0 +1,38 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.nats.model.ToolInstallationMessage; +import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty("spring.cloud.stream.enabled") +public class ToolInstallationDeliverySpec implements DeliverySpec { + + private final ToolInstallationNatsPublisher publisher; + + @Override + public DeliveryKind getKind() { + return DeliveryKind.TOOL_INSTALLATION; + } + + @Override + public Class getPayloadClass() { + return ToolInstallationMessage.class; + } + + @Override + public void publish(String machineId, ToolInstallationMessage payload) { + publisher.publish(machineId, payload); + } + + @Override + public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { + // nothing beyond FAILED + metric: an install is safe to re-run by hand + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java new file mode 100644 index 0000000000..2b45123dc7 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java @@ -0,0 +1,81 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryPropertiesTest { + + private static final long DEFAULT_ACK_THRESHOLD = 30L; + private static final int DEFAULT_MAX_ATTEMPTS = 3; + private static final long DEFAULT_RECONNECT_WINDOW = 86_400L; + private static final long DEFAULT_RESULT_TIMEOUT = 600L; + private static final long DEFAULT_TTL = 604_800L; + private static final int UNINSTALL_MAX_ATTEMPTS = 5; + + private DeliveryProperties properties; + + @BeforeEach + void setUp() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(DEFAULT_ACK_THRESHOLD); + defaults.setMaxAttempts(DEFAULT_MAX_ATTEMPTS); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(DEFAULT_RECONNECT_WINDOW); + defaults.setResultTimeoutSeconds(DEFAULT_RESULT_TIMEOUT); + defaults.setTtlSeconds(DEFAULT_TTL); + properties = new DeliveryProperties(); + properties.setDefaults(defaults); + } + + @Test + void resolve_kindWithoutOverride_defaultsReturned() { + // setup + Policy defaults = properties.getDefaults(); + + // execution + Policy resolved = properties.resolve(DeliveryKind.TOOL_INSTALLATION); + + // verifications + assertThat(resolved).isSameAs(defaults); + } + + @Test + void resolve_kindOverridesOneField_otherFieldsFromDefaults() { + // setup + Policy uninstall = new Policy(); + uninstall.setMaxAttempts(UNINSTALL_MAX_ATTEMPTS); + properties.setKinds(Map.of(DeliveryKind.CLIENT_UNINSTALL, uninstall)); + + // execution + Policy resolved = properties.resolve(DeliveryKind.CLIENT_UNINSTALL); + + // verifications + assertThat(resolved.getMaxAttempts()).isEqualTo(UNINSTALL_MAX_ATTEMPTS); + assertThat(resolved.getAckThresholdSeconds()).isEqualTo(DEFAULT_ACK_THRESHOLD); + assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(DEFAULT_RECONNECT_WINDOW); + assertThat(resolved.getResultTimeoutSeconds()).isEqualTo(DEFAULT_RESULT_TIMEOUT); + assertThat(resolved.getTtlSeconds()).isEqualTo(DEFAULT_TTL); + } + + @Test + void resolve_kindOverridesOfflineBehavior_overrideWins() { + // setup + Policy scripts = new Policy(); + scripts.setOfflineBehavior(ScheduleOfflineBehavior.SKIP); + properties.setKinds(Map.of(DeliveryKind.SCRIPT_SCHEDULE, scripts)); + + // execution + Policy resolved = properties.resolve(DeliveryKind.SCRIPT_SCHEDULE); + + // verifications + assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.SKIP); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java new file mode 100644 index 0000000000..5e408b5d5a --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java @@ -0,0 +1,81 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DeliverySpecRegistryTest { + + @Test + void require_registeredKind_specReturned() { + // setup + DeliverySpec spec = spec(DeliveryKind.TOOL_INSTALLATION); + ObjectProvider> specs = provider(spec); + DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); + + // execution + DeliverySpec resolved = registry.require(DeliveryKind.TOOL_INSTALLATION); + + // verifications + assertThat(resolved).isSameAs(spec); + } + + @Test + void require_unregisteredKind_throwsIllegalArgument() { + // setup + ObjectProvider> specs = provider(); + DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); + + // execution + verifications + assertThatThrownBy(() -> registry.require(DeliveryKind.CLIENT_UNINSTALL)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CLIENT_UNINSTALL"); + } + + @Test + void constructor_duplicateKind_throwsIllegalState() { + // setup + DeliverySpec first = spec(DeliveryKind.TOOL_INSTALLATION); + DeliverySpec second = spec(DeliveryKind.TOOL_INSTALLATION); + ObjectProvider> duplicates = provider(first, second); + + // execution + verifications + assertThatThrownBy(() -> new DeliverySpecRegistry(duplicates)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TOOL_INSTALLATION"); + } + + @Test + void kinds_twoSpecs_bothKindsListed() { + // setup + DeliverySpec install = spec(DeliveryKind.TOOL_INSTALLATION); + DeliverySpec uninstall = spec(DeliveryKind.CLIENT_UNINSTALL); + ObjectProvider> specs = provider(install, uninstall); + + // execution + DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); + + // verifications + assertThat(registry.kinds()).containsExactlyInAnyOrder(DeliveryKind.TOOL_INSTALLATION, DeliveryKind.CLIENT_UNINSTALL); + } + + @SuppressWarnings("unchecked") + private static ObjectProvider> provider(DeliverySpec... specs) { + ObjectProvider> provider = mock(ObjectProvider.class); + when(provider.stream()).thenReturn(Stream.of(specs)); + return provider; + } + + private static DeliverySpec spec(DeliveryKind kind) { + DeliverySpec spec = mock(DeliverySpec.class); + when(spec.getKind()).thenReturn(kind); + return spec; + } +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java new file mode 100644 index 0000000000..260fb07849 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java @@ -0,0 +1,7 @@ +package com.openframe.data.document.rmm.delivery; + +public enum DeliveryFailure { + EXHAUSTED, + OFFLINE_EXPIRED, + TIMEOUT +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java new file mode 100644 index 0000000000..094f34f57b --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java @@ -0,0 +1,9 @@ +package com.openframe.data.document.rmm.delivery; + +public enum DeliveryKind { + SCRIPT_SCHEDULE, + TOOL_INSTALLATION, + TOOL_UPDATE, + CLIENT_UPDATE, + CLIENT_UNINSTALL +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java new file mode 100644 index 0000000000..2a89a16f04 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java @@ -0,0 +1,8 @@ +package com.openframe.data.document.rmm.delivery; + +public enum DeliveryStatus { + PENDING, + ACKED, + DONE, + FAILED +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java new file mode 100644 index 0000000000..df854ea959 --- /dev/null +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java @@ -0,0 +1,54 @@ +package com.openframe.data.document.rmm.delivery; + +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "machine_delivery") +@CompoundIndex(name = "machine_delivery_sweep", def = "{'kind': 1, 'status': 1, 'lastAttemptAt': 1}") +public class MachineDelivery { + + private static final String ID_SEPARATOR = ":"; + + @Id + private String id; + + private DeliveryKind kind; + private String targetId; + private String machineId; + private String tenantId; + + private DeliveryStatus status; + private int attempts; + private String payloadJson; + + private Instant dispatchedAt; + private Instant lastAttemptAt; + private Instant ackedAt; + private Instant finishedAt; + + private DeliveryFailure failure; + private String error; + + private ScheduleOfflineBehavior offlineBehavior; + private Long reconnectWindowSeconds; + + @Indexed(name = "machine_delivery_ttl", expireAfterSeconds = 0) + private Instant expiresAt; + + public static String id(DeliveryKind kind, String targetId, String machineId) { + return kind.name() + ID_SEPARATOR + targetId + ID_SEPARATOR + machineId; + } +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java new file mode 100644 index 0000000000..53856f938e --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java @@ -0,0 +1,18 @@ +package com.openframe.data.repository.rmm; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface MachineDeliveryRepository extends MongoRepository { + + List findByKindAndStatusAndLastAttemptAtBefore(DeliveryKind kind, DeliveryStatus status, Instant before); + + List findByKindAndStatusAndAckedAtBefore(DeliveryKind kind, DeliveryStatus status, Instant before); +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java index f0c47b9ded..4a6c6a5495 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java @@ -35,8 +35,12 @@ public void publish(String machineId, IntegratedToolAgent toolAgent, IntegratedT } public void publish(String machineId, IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { - String topicName = buildTopicName(machineId); ToolInstallationMessage message = buildMessage(toolAgent, tool, reinstall); + publish(machineId, message); + } + + public void publish(String machineId, ToolInstallationMessage message) { + String topicName = buildTopicName(machineId); natsMessagePublisher.publishPersistent(topicName, message); } From 591b3cbae47dde872bf55997f69c55d7ffdca0d9 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Tue, 15 Sep 2026 18:21:51 +0200 Subject: [PATCH 02/13] feat(rmm): outline the delivery engine around the spec skeleton Adds the pieces that make the shape reviewable end to end, still unwired: DeliveryDispatch (row + publish, data-nats), DeliverySweepService with the three PENDING branches (offline wait/skip, republish, exhausted), DeliveryWatchdogService for silent ACKED rows, DeliveryTracker (acknowledge/complete), DeliveryFailureRecorder, DeliveryMetrics, DeliverySweepScheduler under ShedLock, row-level policy override (DeliveryProperties.resolve(MachineDelivery)) and a second spec, ClientUninstallDeliverySpec, whose onFailed restores a machine parked in PENDING_DELETION. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../client/metrics/DeliveryMetrics.java | 36 +++ .../scheduler/DeliverySweepScheduler.java | 33 +++ .../delivery/ClientUninstallDeliverySpec.java | 55 +++++ .../rmm/delivery/DeliveryFailureRecorder.java | 43 ++++ .../rmm/delivery/DeliveryProperties.java | 10 + .../rmm/delivery/DeliverySweepService.java | 134 ++++++++++++ .../service/rmm/delivery/DeliveryTracker.java | 65 ++++++ .../rmm/delivery/DeliveryWatchdogService.java | 37 ++++ .../ClientUninstallDeliverySpecTest.java | 75 +++++++ .../delivery/DeliveryFailureRecorderTest.java | 78 +++++++ .../rmm/delivery/DeliveryPropertiesTest.java | 40 ++++ .../delivery/DeliverySweepServiceTest.java | 207 ++++++++++++++++++ .../rmm/delivery/DeliveryTrackerTest.java | 127 +++++++++++ .../delivery/DeliveryWatchdogServiceTest.java | 87 ++++++++ .../rmm/delivery/DeliveryFailure.java | 2 +- .../data/nats/delivery/DeliveryDispatch.java | 59 +++++ .../data/nats/delivery/DeliveryRequest.java | 17 ++ .../ClientUninstallNatsPublisher.java | 7 +- .../nats/delivery/DeliveryDispatchTest.java | 66 ++++++ 19 files changed, 1175 insertions(+), 3 deletions(-) create mode 100644 openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java create mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java create mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java create mode 100644 openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java diff --git a/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java b/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java new file mode 100644 index 0000000000..59d95fa656 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java @@ -0,0 +1,36 @@ +package com.openframe.client.metrics; + +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Locale; + +@Component +@RequiredArgsConstructor +public class DeliveryMetrics { + + private static final String RETRIED_COUNTER = "openframe.rmm.delivery.retried"; + private static final String FAILED_COUNTER = "openframe.rmm.delivery.failed"; + private static final String TAG_KIND = "kind"; + private static final String TAG_REASON = "reason"; + + private final MeterRegistry meterRegistry; + + public void recordRetried(DeliveryKind kind) { + String kindTag = tagValue(kind.name()); + meterRegistry.counter(RETRIED_COUNTER, TAG_KIND, kindTag).increment(); + } + + public void recordFailed(DeliveryKind kind, DeliveryFailure failure) { + String kindTag = tagValue(kind.name()); + String reasonTag = tagValue(failure.name()); + meterRegistry.counter(FAILED_COUNTER, TAG_KIND, kindTag, TAG_REASON, reasonTag).increment(); + } + + private static String tagValue(String enumName) { + return enumName.toLowerCase(Locale.ROOT); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java new file mode 100644 index 0000000000..679fabd107 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java @@ -0,0 +1,33 @@ +package com.openframe.client.scheduler; + +import com.openframe.client.service.rmm.delivery.DeliverySweepService; +import com.openframe.client.service.rmm.delivery.DeliveryWatchdogService; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +public class DeliverySweepScheduler { + + private final DeliverySweepService sweepService; + private final DeliveryWatchdogService watchdogService; + + @Scheduled(fixedDelayString = "${openframe.rmm.delivery.sweep-interval}") + @SchedulerLock(name = "deliverySweep", + lockAtMostFor = "${openframe.rmm.delivery.lock-at-most-for}", + lockAtLeastFor = "${openframe.rmm.delivery.lock-at-least-for}") + public void tick() { + try { + sweepService.retryPending(); + watchdogService.reapAcked(); + } catch (Exception e) { + log.error("Delivery sweep tick failed", e); + } + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java new file mode 100644 index 0000000000..06342664a5 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java @@ -0,0 +1,55 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.nats.model.ClientUninstallMessage; +import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; +import com.openframe.data.repository.device.MachineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty("spring.cloud.stream.enabled") +public class ClientUninstallDeliverySpec implements DeliverySpec { + + private final ClientUninstallNatsPublisher publisher; + private final MachineRepository machineRepository; + + @Override + public DeliveryKind getKind() { + return DeliveryKind.CLIENT_UNINSTALL; + } + + @Override + public Class getPayloadClass() { + return ClientUninstallMessage.class; + } + + @Override + public void publish(String machineId, ClientUninstallMessage payload) { + publisher.publish(machineId, payload); + } + + // the agent never ran the uninstall: keep the machine visible instead of parking it in PENDING_DELETION forever + @Override + public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { + String machineId = delivery.getMachineId(); + machineRepository.findByMachineId(machineId) + .filter(ClientUninstallDeliverySpec::isPendingDeletion) + .ifPresent(this::restoreOffline); + } + + private void restoreOffline(Machine machine) { + machine.setStatus(DeviceStatus.OFFLINE); + machineRepository.save(machine); + } + + private static boolean isPendingDeletion(Machine machine) { + return machine.getStatus() == DeviceStatus.PENDING_DELETION; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java new file mode 100644 index 0000000000..6c388efb93 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java @@ -0,0 +1,43 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.metrics.DeliveryMetrics; +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryFailureRecorder { + + private final MachineDeliveryRepository repository; + private final DeliverySpecRegistry registry; + private final DeliveryProperties properties; + private final DeliveryMetrics metrics; + + public void fail(MachineDelivery delivery, DeliveryFailure failure, Instant now) { + DeliveryKind kind = delivery.getKind(); + Policy policy = properties.resolve(kind); + long ttlSeconds = policy.getTtlSeconds(); + + delivery.setStatus(DeliveryStatus.FAILED); + delivery.setFailure(failure); + delivery.setFinishedAt(now); + delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); + repository.save(delivery); + + metrics.recordFailed(kind, failure); + DeliverySpec spec = registry.require(kind); + spec.onFailed(delivery, failure); + log.warn("Delivery FAILED: kind={} targetId={} machineId={} attempts={} reason={}", + kind, delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts(), failure); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java index ac28811912..a6b726fbe4 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java @@ -1,6 +1,7 @@ package com.openframe.client.service.rmm.delivery; import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -39,6 +40,15 @@ public Policy resolve(DeliveryKind kind) { return override.mergeOver(defaults); } + // row-level overrides come from ScheduleScript (per-schedule offline behaviour) and win over the kind policy + public Policy resolve(MachineDelivery delivery) { + Policy kindPolicy = resolve(delivery.getKind()); + Policy rowOverride = new Policy(); + rowOverride.setOfflineBehavior(delivery.getOfflineBehavior()); + rowOverride.setReconnectWindowSeconds(delivery.getReconnectWindowSeconds()); + return rowOverride.mergeOver(kindPolicy); + } + @Getter @Setter public static class Policy { diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java new file mode 100644 index 0000000000..918eea3cad --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java @@ -0,0 +1,134 @@ +package com.openframe.client.service.rmm.delivery; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.client.metrics.DeliveryMetrics; +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static java.util.stream.Collectors.toSet; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeliverySweepService { + + private final MachineDeliveryRepository repository; + private final MachineRepository machineRepository; + private final DeliverySpecRegistry registry; + private final DeliveryProperties properties; + private final DeliveryFailureRecorder failureRecorder; + private final DeliveryMetrics metrics; + private final ObjectMapper objectMapper; + + public void retryPending() { + Instant now = Instant.now(); + Set kinds = registry.kinds(); + kinds.forEach(kind -> retryPending(kind, now)); + } + + private void retryPending(DeliveryKind kind, Instant now) { + Policy policy = properties.resolve(kind); + Instant threshold = now.minusSeconds(policy.getAckThresholdSeconds()); + List unacked = repository.findByKindAndStatusAndLastAttemptAtBefore(kind, DeliveryStatus.PENDING, threshold); + if (unacked.isEmpty()) { + return; + } + Set offlineMachineIds = offlineMachineIds(unacked); + unacked.forEach(delivery -> retryOrFail(delivery, offlineMachineIds, now)); + } + + private Set offlineMachineIds(List deliveries) { + Set machineIds = deliveries.stream().map(MachineDelivery::getMachineId).collect(toSet()); + List offline = machineRepository.findByMachineIdInAndStatus(machineIds, DeviceStatus.OFFLINE); + return offline.stream().map(Machine::getMachineId).collect(toSet()); + } + + private void retryOrFail(MachineDelivery delivery, Set offlineMachineIds, Instant now) { + Policy policy = properties.resolve(delivery); + if (isOffline(delivery, offlineMachineIds)) { + waitOrFailOffline(delivery, policy, now); + return; + } + if (hasAttemptsLeft(delivery, policy)) { + republish(delivery, now); + return; + } + failureRecorder.fail(delivery, DeliveryFailure.EXHAUSTED, now); + } + + private void waitOrFailOffline(MachineDelivery delivery, Policy policy, Instant now) { + if (shouldSkipOffline(policy)) { + failureRecorder.fail(delivery, DeliveryFailure.OFFLINE, now); + return; + } + if (isReconnectWindowOver(delivery, policy, now)) { + failureRecorder.fail(delivery, DeliveryFailure.OFFLINE, now); + return; + } + log.debug("Delivery waits for machine to come online: kind={} targetId={} machineId={}", + delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); + } + + private void republish(MachineDelivery delivery, Instant now) { + DeliveryKind kind = delivery.getKind(); + DeliverySpec spec = registry.require(kind); + publish(spec, delivery); + + int attempt = delivery.getAttempts() + 1; + delivery.setAttempts(attempt); + delivery.setLastAttemptAt(now); + repository.save(delivery); + metrics.recordRetried(kind); + log.info("Delivery re-published: kind={} targetId={} machineId={} attempt={}", + kind, delivery.getTargetId(), delivery.getMachineId(), attempt); + } + + private

void publish(DeliverySpec

spec, MachineDelivery delivery) { + Class

payloadClass = spec.getPayloadClass(); + P payload = readPayload(delivery, payloadClass); + String machineId = delivery.getMachineId(); + spec.publish(machineId, payload); + } + + private

P readPayload(MachineDelivery delivery, Class

payloadClass) { + String payloadJson = delivery.getPayloadJson(); + try { + return objectMapper.readValue(payloadJson, payloadClass); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Corrupt delivery payload: " + delivery.getId(), e); + } + } + + private static boolean isOffline(MachineDelivery delivery, Set offlineMachineIds) { + return offlineMachineIds.contains(delivery.getMachineId()); + } + + private static boolean hasAttemptsLeft(MachineDelivery delivery, Policy policy) { + return delivery.getAttempts() < policy.getMaxAttempts(); + } + + private static boolean shouldSkipOffline(Policy policy) { + return policy.getOfflineBehavior() == ScheduleOfflineBehavior.SKIP; + } + + private static boolean isReconnectWindowOver(MachineDelivery delivery, Policy policy, Instant now) { + Instant deadline = delivery.getDispatchedAt().plusSeconds(policy.getReconnectWindowSeconds()); + return now.isAfter(deadline); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java new file mode 100644 index 0000000000..b045895282 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java @@ -0,0 +1,65 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeliveryTracker { + + private final MachineDeliveryRepository repository; + private final DeliveryProperties properties; + + public void acknowledge(DeliveryKind kind, String targetId, String machineId) { + String id = MachineDelivery.id(kind, targetId, machineId); + repository.findById(id) + .filter(DeliveryTracker::isPending) + .ifPresent(this::markAcked); + } + + public void complete(DeliveryKind kind, String targetId, String machineId) { + String id = MachineDelivery.id(kind, targetId, machineId); + repository.findById(id) + .filter(DeliveryTracker::isOpen) + .ifPresent(this::markDone); + } + + private void markAcked(MachineDelivery delivery) { + Instant now = Instant.now(); + delivery.setStatus(DeliveryStatus.ACKED); + delivery.setAckedAt(now); + repository.save(delivery); + log.info("Delivery ACKED: kind={} targetId={} machineId={}", + delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); + } + + private void markDone(MachineDelivery delivery) { + Instant now = Instant.now(); + Policy policy = properties.resolve(delivery.getKind()); + long ttlSeconds = policy.getTtlSeconds(); + delivery.setStatus(DeliveryStatus.DONE); + delivery.setFinishedAt(now); + delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); + repository.save(delivery); + log.info("Delivery DONE: kind={} targetId={} machineId={} attempts={}", + delivery.getKind(), delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts()); + } + + private static boolean isPending(MachineDelivery delivery) { + return delivery.getStatus() == DeliveryStatus.PENDING; + } + + private static boolean isOpen(MachineDelivery delivery) { + DeliveryStatus status = delivery.getStatus(); + return status == DeliveryStatus.PENDING || status == DeliveryStatus.ACKED; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java new file mode 100644 index 0000000000..be65e73bc2 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java @@ -0,0 +1,37 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class DeliveryWatchdogService { + + private final MachineDeliveryRepository repository; + private final DeliverySpecRegistry registry; + private final DeliveryProperties properties; + private final DeliveryFailureRecorder failureRecorder; + + public void reapAcked() { + Instant now = Instant.now(); + Set kinds = registry.kinds(); + kinds.forEach(kind -> reapAcked(kind, now)); + } + + private void reapAcked(DeliveryKind kind, Instant now) { + Policy policy = properties.resolve(kind); + Instant threshold = now.minusSeconds(policy.getResultTimeoutSeconds()); + List silent = repository.findByKindAndStatusAndAckedAtBefore(kind, DeliveryStatus.ACKED, threshold); + silent.forEach(delivery -> failureRecorder.fail(delivery, DeliveryFailure.TIMEOUT, now)); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java new file mode 100644 index 0000000000..40aeadf66c --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java @@ -0,0 +1,75 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; +import com.openframe.data.repository.device.MachineRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ClientUninstallDeliverySpecTest { + + private static final String MACHINE_ID = "mach-42"; + + @Mock private ClientUninstallNatsPublisher publisher; + @Mock private MachineRepository machineRepository; + + @InjectMocks private ClientUninstallDeliverySpec spec; + + private MachineDelivery delivery; + private Machine machine; + + @BeforeEach + void setUp() { + delivery = MachineDelivery.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .targetId(MACHINE_ID) + .machineId(MACHINE_ID) + .build(); + machine = new Machine(); + machine.setMachineId(MACHINE_ID); + } + + @Test + void onFailed_machinePendingDeletion_restoredToOffline() { + // setup + machine.setStatus(DeviceStatus.PENDING_DELETION); + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + + // execution + spec.onFailed(delivery, DeliveryFailure.EXHAUSTED); + + // verifications + assertThat(machine.getStatus()).isEqualTo(DeviceStatus.OFFLINE); + verify(machineRepository).save(machine); + } + + @Test + void onFailed_machineAlreadyDeleted_untouched() { + // setup + machine.setStatus(DeviceStatus.DELETED); + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + + // execution + spec.onFailed(delivery, DeliveryFailure.OFFLINE); + + // verifications + assertThat(machine.getStatus()).isEqualTo(DeviceStatus.DELETED); + verify(machineRepository, never()).save(machine); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java new file mode 100644 index 0000000000..fdab397bd3 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java @@ -0,0 +1,78 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.metrics.DeliveryMetrics; +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.nats.model.ClientUninstallMessage; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import org.junit.jupiter.api.BeforeEach; +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.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryFailureRecorderTest { + + private static final long TTL = 604_800L; + + @Mock private MachineDeliveryRepository repository; + @Mock private DeliverySpecRegistry registry; + @Mock private DeliveryMetrics metrics; + @Mock private DeliverySpec spec; + + private DeliveryFailureRecorder recorder; + + private MachineDelivery delivery; + + @BeforeEach + void setUp() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(30L); + defaults.setMaxAttempts(3); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(86_400L); + defaults.setResultTimeoutSeconds(600L); + defaults.setTtlSeconds(TTL); + DeliveryProperties properties = new DeliveryProperties(); + properties.setDefaults(defaults); + + delivery = MachineDelivery.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .status(DeliveryStatus.PENDING) + .attempts(5) + .build(); + + recorder = new DeliveryFailureRecorder(repository, registry, properties, metrics); + } + + @Test + void fail_exhausted_rowFailedMetricCountedSpecNotified() { + // setup + Instant now = Instant.now(); + doReturn(spec).when(registry).require(DeliveryKind.CLIENT_UNINSTALL); + + // execution + recorder.fail(delivery, DeliveryFailure.EXHAUSTED, now); + + // verifications + assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.FAILED); + assertThat(delivery.getFailure()).isEqualTo(DeliveryFailure.EXHAUSTED); + assertThat(delivery.getFinishedAt()).isEqualTo(now); + assertThat(delivery.getExpiresAt()).isEqualTo(now.plusSeconds(TTL)); + verify(repository).save(delivery); + verify(metrics).recordFailed(DeliveryKind.CLIENT_UNINSTALL, DeliveryFailure.EXHAUSTED); + verify(spec).onFailed(delivery, DeliveryFailure.EXHAUSTED); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java index 2b45123dc7..1205f08d5e 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java @@ -2,6 +2,7 @@ import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,6 +19,7 @@ class DeliveryPropertiesTest { private static final long DEFAULT_RESULT_TIMEOUT = 600L; private static final long DEFAULT_TTL = 604_800L; private static final int UNINSTALL_MAX_ATTEMPTS = 5; + private static final long ROW_RECONNECT_WINDOW = 3_600L; private DeliveryProperties properties; @@ -78,4 +80,42 @@ void resolve_kindOverridesOfflineBehavior_overrideWins() { // verifications assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.SKIP); } + + @Test + void resolve_rowWithoutOverrides_kindPolicyReturned() { + // setup + Policy uninstall = new Policy(); + uninstall.setMaxAttempts(UNINSTALL_MAX_ATTEMPTS); + properties.setKinds(Map.of(DeliveryKind.CLIENT_UNINSTALL, uninstall)); + MachineDelivery delivery = MachineDelivery.builder().kind(DeliveryKind.CLIENT_UNINSTALL).build(); + + // execution + Policy resolved = properties.resolve(delivery); + + // verifications + assertThat(resolved.getMaxAttempts()).isEqualTo(UNINSTALL_MAX_ATTEMPTS); + assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(DEFAULT_RECONNECT_WINDOW); + } + + @Test + void resolve_rowOverridesOfflineFields_rowWinsOverKind() { + // setup + Policy scripts = new Policy(); + scripts.setOfflineBehavior(ScheduleOfflineBehavior.SKIP); + properties.setKinds(Map.of(DeliveryKind.SCRIPT_SCHEDULE, scripts)); + MachineDelivery delivery = MachineDelivery.builder() + .kind(DeliveryKind.SCRIPT_SCHEDULE) + .offlineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT) + .reconnectWindowSeconds(ROW_RECONNECT_WINDOW) + .build(); + + // execution + Policy resolved = properties.resolve(delivery); + + // verifications + assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(ROW_RECONNECT_WINDOW); + assertThat(resolved.getMaxAttempts()).isEqualTo(DEFAULT_MAX_ATTEMPTS); + } } diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java new file mode 100644 index 0000000000..56634b3ae1 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java @@ -0,0 +1,207 @@ +package com.openframe.client.service.rmm.delivery; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.client.metrics.DeliveryMetrics; +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.nats.model.ToolInstallationMessage; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +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.time.Instant; +import java.util.List; +import java.util.Set; + +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.doReturn; +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 DeliverySweepServiceTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String TOOL_AGENT_ID = "tactical-agent"; + private static final String PAYLOAD_JSON = "{\"toolAgentId\":\"tactical-agent\"}"; + private static final long ACK_THRESHOLD = 30L; + private static final int MAX_ATTEMPTS = 3; + private static final long RECONNECT_WINDOW = 86_400L; + private static final long RESULT_TIMEOUT = 600L; + private static final long TTL = 604_800L; + private static final long TWO_DAYS_SECONDS = 172_800L; + + @Mock private MachineDeliveryRepository repository; + @Mock private MachineRepository machineRepository; + @Mock private DeliverySpecRegistry registry; + @Mock private DeliveryFailureRecorder failureRecorder; + @Mock private DeliveryMetrics metrics; + @Mock private DeliverySpec spec; + + @Captor private ArgumentCaptor payloadCaptor; + + private DeliverySweepService service; + + private DeliveryProperties properties; + private MachineDelivery delivery; + private Machine offlineMachine; + + @BeforeEach + void setUp() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(ACK_THRESHOLD); + defaults.setMaxAttempts(MAX_ATTEMPTS); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(RECONNECT_WINDOW); + defaults.setResultTimeoutSeconds(RESULT_TIMEOUT); + defaults.setTtlSeconds(TTL); + properties = new DeliveryProperties(); + properties.setDefaults(defaults); + + Instant dispatchedAt = Instant.now().minusSeconds(ACK_THRESHOLD * 2); + delivery = MachineDelivery.builder() + .id(MachineDelivery.id(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID)) + .kind(DeliveryKind.TOOL_INSTALLATION) + .targetId(TOOL_AGENT_ID) + .machineId(MACHINE_ID) + .status(DeliveryStatus.PENDING) + .attempts(0) + .payloadJson(PAYLOAD_JSON) + .dispatchedAt(dispatchedAt) + .lastAttemptAt(dispatchedAt) + .build(); + + offlineMachine = new Machine(); + offlineMachine.setMachineId(MACHINE_ID); + offlineMachine.setStatus(DeviceStatus.OFFLINE); + + service = new DeliverySweepService(repository, machineRepository, registry, properties, failureRecorder, metrics, new ObjectMapper()); + } + + @Test + void retryPending_onlineWithAttemptsLeft_republishedAndAttemptCounted() { + // setup + stubOneStuckDelivery(); + stubMachineOnline(); + doReturn(spec).when(registry).require(DeliveryKind.TOOL_INSTALLATION); + when(spec.getPayloadClass()).thenReturn(ToolInstallationMessage.class); + + // execution + service.retryPending(); + + // verifications + verify(spec).publish(eq(MACHINE_ID), payloadCaptor.capture()); + assertThat(payloadCaptor.getValue().getToolAgentId()).isEqualTo(TOOL_AGENT_ID); + assertThat(delivery.getAttempts()).isEqualTo(1); + assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.PENDING); + verify(repository).save(delivery); + verify(metrics).recordRetried(DeliveryKind.TOOL_INSTALLATION); + verifyNoInteractions(failureRecorder); + } + + @Test + void retryPending_onlineAttemptsExhausted_failedExhausted() { + // setup + delivery.setAttempts(MAX_ATTEMPTS); + stubOneStuckDelivery(); + stubMachineOnline(); + + // execution + service.retryPending(); + + // verifications + verify(failureRecorder).fail(eq(delivery), eq(DeliveryFailure.EXHAUSTED), any(Instant.class)); + verify(repository, never()).save(delivery); + verifyNoInteractions(metrics); + } + + @Test + void retryPending_offlineInsideReconnectWindow_leftPendingWithoutAttempt() { + // setup + stubOneStuckDelivery(); + stubMachineOffline(); + + // execution + service.retryPending(); + + // verifications + assertThat(delivery.getAttempts()).isZero(); + verify(repository, never()).save(delivery); + verifyNoInteractions(failureRecorder); + verifyNoInteractions(metrics); + } + + @Test + void retryPending_offlineReconnectWindowOver_failedOffline() { + // setup + Instant twoDaysAgo = Instant.now().minusSeconds(TWO_DAYS_SECONDS); + delivery.setDispatchedAt(twoDaysAgo); + stubOneStuckDelivery(); + stubMachineOffline(); + + // execution + service.retryPending(); + + // verifications + verify(failureRecorder).fail(eq(delivery), eq(DeliveryFailure.OFFLINE), any(Instant.class)); + } + + @Test + void retryPending_offlineWithRowSkipOverride_failedOfflineImmediately() { + // setup + delivery.setOfflineBehavior(ScheduleOfflineBehavior.SKIP); + stubOneStuckDelivery(); + stubMachineOffline(); + + // execution + service.retryPending(); + + // verifications + verify(failureRecorder).fail(eq(delivery), eq(DeliveryFailure.OFFLINE), any(Instant.class)); + } + + @Test + void retryPending_nothingStuck_machinesNotQueried() { + // setup + when(registry.kinds()).thenReturn(Set.of(DeliveryKind.TOOL_INSTALLATION)); + when(repository.findByKindAndStatusAndLastAttemptAtBefore(eq(DeliveryKind.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) + .thenReturn(List.of()); + + // execution + service.retryPending(); + + // verifications + verifyNoInteractions(machineRepository); + verifyNoInteractions(failureRecorder); + } + + private void stubOneStuckDelivery() { + when(registry.kinds()).thenReturn(Set.of(DeliveryKind.TOOL_INSTALLATION)); + when(repository.findByKindAndStatusAndLastAttemptAtBefore(eq(DeliveryKind.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) + .thenReturn(List.of(delivery)); + } + + private void stubMachineOnline() { + when(machineRepository.findByMachineIdInAndStatus(Set.of(MACHINE_ID), DeviceStatus.OFFLINE)).thenReturn(List.of()); + } + + private void stubMachineOffline() { + when(machineRepository.findByMachineIdInAndStatus(Set.of(MACHINE_ID), DeviceStatus.OFFLINE)).thenReturn(List.of(offlineMachine)); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java new file mode 100644 index 0000000000..367560f9b7 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java @@ -0,0 +1,127 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import org.junit.jupiter.api.BeforeEach; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryTrackerTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String TOOL_AGENT_ID = "tactical-agent"; + private static final String DELIVERY_ID = MachineDelivery.id(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + private static final long TTL = 604_800L; + + @Mock private MachineDeliveryRepository repository; + + private DeliveryTracker tracker; + + private MachineDelivery delivery; + + @BeforeEach + void setUp() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(30L); + defaults.setMaxAttempts(3); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(86_400L); + defaults.setResultTimeoutSeconds(600L); + defaults.setTtlSeconds(TTL); + DeliveryProperties properties = new DeliveryProperties(); + properties.setDefaults(defaults); + + delivery = MachineDelivery.builder() + .id(DELIVERY_ID) + .kind(DeliveryKind.TOOL_INSTALLATION) + .targetId(TOOL_AGENT_ID) + .machineId(MACHINE_ID) + .status(DeliveryStatus.PENDING) + .build(); + + tracker = new DeliveryTracker(repository, properties); + } + + @Test + void acknowledge_pendingRow_ackedWithTimestamp() { + // setup + when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); + + // execution + tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + + // verifications + assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.ACKED); + assertThat(delivery.getAckedAt()).isNotNull(); + verify(repository).save(delivery); + } + + @Test + void acknowledge_alreadyAcked_untouched() { + // setup + delivery.setStatus(DeliveryStatus.ACKED); + when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); + + // execution + tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + + // verifications + verify(repository, never()).save(delivery); + } + + @Test + void acknowledge_unknownRow_noop() { + // setup + when(repository.findById(DELIVERY_ID)).thenReturn(Optional.empty()); + + // execution + tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + + // verifications + verify(repository, never()).save(delivery); + } + + @Test + void complete_ackedRow_doneWithExpiry() { + // setup + delivery.setStatus(DeliveryStatus.ACKED); + when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); + + // execution + tracker.complete(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + + // verifications + assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.DONE); + assertThat(delivery.getFinishedAt()).isNotNull(); + assertThat(delivery.getExpiresAt()).isEqualTo(delivery.getFinishedAt().plusSeconds(TTL)); + verify(repository).save(delivery); + } + + @Test + void complete_failedRow_untouched() { + // setup + delivery.setStatus(DeliveryStatus.FAILED); + when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); + + // execution + tracker.complete(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + + // verifications + assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.FAILED); + verify(repository, never()).save(delivery); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java new file mode 100644 index 0000000000..d77cdb9410 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java @@ -0,0 +1,87 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryFailure; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import org.junit.jupiter.api.BeforeEach; +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.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryWatchdogServiceTest { + + private static final long RESULT_TIMEOUT = 600L; + + @Mock private MachineDeliveryRepository repository; + @Mock private DeliverySpecRegistry registry; + @Mock private DeliveryFailureRecorder failureRecorder; + + private DeliveryWatchdogService service; + + private MachineDelivery silentDelivery; + + @BeforeEach + void setUp() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(30L); + defaults.setMaxAttempts(3); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(86_400L); + defaults.setResultTimeoutSeconds(RESULT_TIMEOUT); + defaults.setTtlSeconds(604_800L); + DeliveryProperties properties = new DeliveryProperties(); + properties.setDefaults(defaults); + + silentDelivery = MachineDelivery.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .status(DeliveryStatus.ACKED) + .ackedAt(Instant.now().minusSeconds(RESULT_TIMEOUT * 2)) + .build(); + + service = new DeliveryWatchdogService(repository, registry, properties, failureRecorder); + } + + @Test + void reapAcked_ackedOlderThanResultTimeout_failedTimeout() { + // setup + when(registry.kinds()).thenReturn(Set.of(DeliveryKind.CLIENT_UNINSTALL)); + when(repository.findByKindAndStatusAndAckedAtBefore(eq(DeliveryKind.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) + .thenReturn(List.of(silentDelivery)); + + // execution + service.reapAcked(); + + // verifications + verify(failureRecorder).fail(eq(silentDelivery), eq(DeliveryFailure.TIMEOUT), any(Instant.class)); + } + + @Test + void reapAcked_nothingSilent_noFailure() { + // setup + when(registry.kinds()).thenReturn(Set.of(DeliveryKind.CLIENT_UNINSTALL)); + when(repository.findByKindAndStatusAndAckedAtBefore(eq(DeliveryKind.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) + .thenReturn(List.of()); + + // execution + service.reapAcked(); + + // verifications + verifyNoInteractions(failureRecorder); + } +} diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java index 260fb07849..3c9e7804a9 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java @@ -2,6 +2,6 @@ public enum DeliveryFailure { EXHAUSTED, - OFFLINE_EXPIRED, + OFFLINE, TIMEOUT } diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java new file mode 100644 index 0000000000..632fd5abc7 --- /dev/null +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java @@ -0,0 +1,59 @@ +package com.openframe.data.nats.delivery; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +public class DeliveryDispatch { + + private final MachineDeliveryRepository repository; + private final ObjectMapper objectMapper; + + public void send(DeliveryRequest request, Runnable publish) { + MachineDelivery delivery = pendingRow(request); + repository.save(delivery); + publish.run(); + log.info("Delivery dispatched: kind={} targetId={} machineId={}", + request.getKind(), request.getTargetId(), request.getMachineId()); + } + + private MachineDelivery pendingRow(DeliveryRequest request) { + Instant now = Instant.now(); + String id = MachineDelivery.id(request.getKind(), request.getTargetId(), request.getMachineId()); + String payloadJson = toJson(request.getPayload()); + return MachineDelivery.builder() + .id(id) + .kind(request.getKind()) + .targetId(request.getTargetId()) + .machineId(request.getMachineId()) + .status(DeliveryStatus.PENDING) + .attempts(0) + .payloadJson(payloadJson) + .dispatchedAt(now) + .lastAttemptAt(now) + .offlineBehavior(request.getOfflineBehavior()) + .reconnectWindowSeconds(request.getReconnectWindowSeconds()) + .build(); + } + + private String toJson(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + String payloadType = payload.getClass().getSimpleName(); + throw new IllegalArgumentException("Delivery payload is not serializable: " + payloadType, e); + } + } +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java new file mode 100644 index 0000000000..a8dcfc6728 --- /dev/null +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java @@ -0,0 +1,17 @@ +package com.openframe.data.nats.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DeliveryRequest { + private final DeliveryKind kind; + private final String targetId; + private final String machineId; + private final Object payload; + private final ScheduleOfflineBehavior offlineBehavior; + private final Long reconnectWindowSeconds; +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java index 064a827672..f9a6025c2e 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java @@ -21,10 +21,13 @@ public class ClientUninstallNatsPublisher { private final NatsMessagePublisher natsMessagePublisher; public void publish(String machineId) { - String topicName = format(TOPIC_NAME_TEMPLATE, machineId); ClientUninstallMessage message = buildMessage(); - natsMessagePublisher.publishPersistent(topicName, message); + publish(machineId, message); + } + public void publish(String machineId, ClientUninstallMessage message) { + String topicName = format(TOPIC_NAME_TEMPLATE, machineId); + natsMessagePublisher.publishPersistent(topicName, message); log.info("Published client uninstall command for machine {}", machineId); } diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java new file mode 100644 index 0000000000..e1568af5c6 --- /dev/null +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java @@ -0,0 +1,66 @@ +package com.openframe.data.nats.delivery; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.data.nats.model.ClientUninstallMessage; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class DeliveryDispatchTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String ISSUED_AT = "2026-09-15T10:00:00Z"; + + @Mock private MachineDeliveryRepository repository; + @Mock private Runnable publish; + + @Captor private ArgumentCaptor deliveryCaptor; + + private DeliveryDispatch dispatch; + + @BeforeEach + void setUp() { + dispatch = new DeliveryDispatch(repository, new ObjectMapper()); + } + + @Test + void send_uninstallRequest_pendingRowSavedThenPublished() { + // setup + ClientUninstallMessage message = new ClientUninstallMessage(); + message.setIssuedAt(ISSUED_AT); + DeliveryRequest request = DeliveryRequest.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .targetId(MACHINE_ID) + .machineId(MACHINE_ID) + .payload(message) + .offlineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT) + .build(); + + // execution + dispatch.send(request, publish); + + // verifications + verify(repository).save(deliveryCaptor.capture()); + MachineDelivery saved = deliveryCaptor.getValue(); + assertThat(saved.getId()).isEqualTo("CLIENT_UNINSTALL:mach-42:mach-42"); + assertThat(saved.getStatus()).isEqualTo(DeliveryStatus.PENDING); + assertThat(saved.getAttempts()).isZero(); + assertThat(saved.getPayloadJson()).contains(ISSUED_AT); + assertThat(saved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + assertThat(saved.getDispatchedAt()).isEqualTo(saved.getLastAttemptAt()); + verify(publish).run(); + } +} From 6928756b72af08e75fe1fba0f20e84db4fece6af Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Wed, 16 Sep 2026 12:10:45 +0200 Subject: [PATCH 03/13] feat(rmm): wire success and ack into the delivery engine Answers "how do we know a delivery succeeded and stop re-sending": - ScriptExecutionAcknowledgeMessage gains kind/targetId; the listener routes any kind to DeliveryTracker.acknowledge (PENDING -> ACKED) and keeps the script path for legacy/SCRIPT_SCHEDULE acks. - InstalledAgentService completes TOOL_INSTALLATION rows on installed-agent, AgentUninstallService completes CLIENT_UNINSTALL on /api/agents/uninstall. - ToolInstallationService and ForceClientUninstallService publish through DeliveryDispatch, so the row exists before the message leaves. DeliveryDispatch and DeliveryTracker are interfaces with a recording and a pass-through/no-op implementation selected by openframe.rmm.delivery.enabled, so with the flag off every call site behaves exactly as today. Sweep and watchdog get separate try/catch in the scheduler. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../service/ForceClientUninstallService.java | 18 ++- .../ForceClientUninstallServiceTest.java | 97 +++++++++++++++ .../ScriptExecutionAcknowledgeListener.java | 22 +++- .../scheduler/DeliverySweepScheduler.java | 14 ++- .../client/service/AgentUninstallService.java | 4 + .../client/service/InstalledAgentService.java | 4 + .../rmm/delivery/DeliveryFailureRecorder.java | 2 + .../rmm/delivery/DeliverySweepService.java | 2 + .../service/rmm/delivery/DeliveryTracker.java | 61 +--------- .../rmm/delivery/DeliveryWatchdogService.java | 2 + .../rmm/delivery/MongoDeliveryTracker.java | 69 +++++++++++ .../rmm/delivery/NoopDeliveryTracker.java | 18 +++ ...criptExecutionAcknowledgeListenerTest.java | 114 ++++++++++++++++++ .../service/AgentUninstallServiceTest.java | 98 +++++++++++++++ .../service/InstalledAgentServiceTest.java | 103 ++++++++++++++++ ...est.java => MongoDeliveryTrackerTest.java} | 6 +- .../data/nats/delivery/DeliveryDispatch.java | 57 +-------- .../delivery/PublishOnlyDeliveryDispatch.java | 15 +++ .../delivery/RecordingDeliveryDispatch.java | 60 +++++++++ .../ClientUninstallNatsPublisher.java | 2 +- .../ToolInstallationNatsPublisher.java | 2 +- .../ScriptExecutionAcknowledgeMessage.java | 5 + .../PublishOnlyDeliveryDispatchTest.java | 34 ++++++ ...ava => RecordingDeliveryDispatchTest.java} | 6 +- .../data/service/ToolInstallationService.java | 14 ++- 25 files changed, 703 insertions(+), 126 deletions(-) create mode 100644 openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java create mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java create mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java rename openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/{DeliveryTrackerTest.java => MongoDeliveryTrackerTest.java} (96%) create mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java create mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java create mode 100644 openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java rename openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/{DeliveryDispatchTest.java => RecordingDeliveryDispatchTest.java} (93%) diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java index ab5bb25613..55bde6e7e0 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java @@ -5,6 +5,10 @@ import com.openframe.api.dto.force.response.ForceClientUninstallResponse; import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.nats.delivery.DeliveryDispatch; +import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.model.ClientUninstallMessage; import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; import lombok.RequiredArgsConstructor; @@ -25,6 +29,7 @@ public class ForceClientUninstallService { private final ClientUninstallNatsPublisher clientUninstallNatsPublisher; private final MachineRepository machineRepository; + private final DeliveryDispatch deliveryDispatch; public ForceClientUninstallResponse process(ForceClientUninstallRequest request) { List machineIds = request.getMachineIds(); @@ -57,7 +62,7 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { return buildResponseItem(machineId, ForceAgentStatus.FAILED); } - clientUninstallNatsPublisher.publish(machineId); + publishUninstall(machineId); markPendingDeletion(machine); @@ -68,6 +73,17 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { } } + private void publishUninstall(String machineId) { + ClientUninstallMessage message = clientUninstallNatsPublisher.buildMessage(); + DeliveryRequest request = DeliveryRequest.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .targetId(machineId) + .machineId(machineId) + .payload(message) + .build(); + deliveryDispatch.send(request, () -> clientUninstallNatsPublisher.publish(machineId, message)); + } + private void markPendingDeletion(Machine machine) { if (machine.getStatus() == PENDING_DELETION) { return; diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java new file mode 100644 index 0000000000..c4c1811f23 --- /dev/null +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java @@ -0,0 +1,97 @@ +package com.openframe.api.service; + +import com.openframe.api.dto.force.request.ForceClientUninstallRequest; +import com.openframe.api.dto.force.response.ForceAgentStatus; +import com.openframe.api.dto.force.response.ForceClientUninstallResponse; +import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.nats.delivery.DeliveryDispatch; +import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.model.ClientUninstallMessage; +import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; +import com.openframe.data.repository.device.MachineRepository; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ForceClientUninstallServiceTest { + + private static final String MACHINE_ID = "mach-42"; + + @Mock private ClientUninstallNatsPublisher clientUninstallNatsPublisher; + @Mock private MachineRepository machineRepository; + @Mock private DeliveryDispatch deliveryDispatch; + + @Captor private ArgumentCaptor requestCaptor; + @Captor private ArgumentCaptor publishCaptor; + + @InjectMocks private ForceClientUninstallService service; + + private Machine machine; + private ForceClientUninstallRequest request; + private ClientUninstallMessage message; + + @BeforeEach + void setUp() { + machine = new Machine(); + machine.setMachineId(MACHINE_ID); + machine.setStatus(DeviceStatus.ONLINE); + request = new ForceClientUninstallRequest(); + request.setMachineIds(List.of(MACHINE_ID)); + message = new ClientUninstallMessage(); + } + + @Test + void process_onlineMachine_dispatchedAndMarkedPendingDeletion() { + // setup + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + when(clientUninstallNatsPublisher.buildMessage()).thenReturn(message); + + // execution + ForceClientUninstallResponse response = service.process(request); + + // verifications + verify(deliveryDispatch).send(requestCaptor.capture(), publishCaptor.capture()); + assertThat(requestCaptor.getValue().getKind()).isEqualTo(DeliveryKind.CLIENT_UNINSTALL); + assertThat(requestCaptor.getValue().getTargetId()).isEqualTo(MACHINE_ID); + assertThat(requestCaptor.getValue().getPayload()).isSameAs(message); + publishCaptor.getValue().run(); + verify(clientUninstallNatsPublisher).publish(MACHINE_ID, message); + assertThat(machine.getStatus()).isEqualTo(DeviceStatus.PENDING_DELETION); + assertThat(response.getItems()) + .extracting(ForceClientUninstallResponseItem::getStatus) + .containsExactly(ForceAgentStatus.PROCESSED); + } + + @Test + void process_deletedMachine_failedWithoutDispatch() { + // setup + machine.setStatus(DeviceStatus.DELETED); + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + + // execution + ForceClientUninstallResponse response = service.process(request); + + // verifications + verifyNoInteractions(deliveryDispatch); + assertThat(response.getItems()) + .extracting(ForceClientUninstallResponseItem::getStatus) + .containsExactly(ForceAgentStatus.FAILED); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java index 7a22c0d443..dd77d41625 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java +++ b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.data.document.rmm.delivery.DeliveryKind; import com.openframe.data.nats.listener.AbstractJetStreamPushListener; import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; import io.nats.client.Connection; @@ -17,15 +19,18 @@ public class ScriptExecutionAcknowledgeListener extends AbstractJetStreamPushLis private final ObjectMapper objectMapper; private final ScriptExecutionAcknowledgeService acknowledgeService; + private final DeliveryTracker deliveryTracker; public ScriptExecutionAcknowledgeListener( Connection natsConnection, ObjectMapper objectMapper, - ScriptExecutionAcknowledgeService acknowledgeService + ScriptExecutionAcknowledgeService acknowledgeService, + DeliveryTracker deliveryTracker ) { super(natsConnection); this.objectMapper = objectMapper; this.acknowledgeService = acknowledgeService; + this.deliveryTracker = deliveryTracker; } @Override @@ -58,10 +63,23 @@ protected void handleMessage(Message message) { String payload = new String(message.getData(), StandardCharsets.UTF_8); try { ScriptExecutionAcknowledgeMessage ack = objectMapper.readValue(payload, ScriptExecutionAcknowledgeMessage.class); - acknowledgeService.acknowledge(ack); + if (isDeliveryAck(ack)) { + deliveryTracker.acknowledge(ack.getKind(), ack.getTargetId(), ack.getMachineId()); + } + if (isScriptAck(ack)) { + acknowledgeService.acknowledge(ack); + } message.ack(); } catch (Exception e) { log.error("Unexpected error processing execution ack: {}", payload, e); } } + + private static boolean isDeliveryAck(ScriptExecutionAcknowledgeMessage ack) { + return ack.getKind() != null && ack.getTargetId() != null; + } + + private static boolean isScriptAck(ScriptExecutionAcknowledgeMessage ack) { + return ack.getKind() == null || ack.getKind() == DeliveryKind.SCRIPT_SCHEDULE; + } } diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java b/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java index 679fabd107..ed77b28ede 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java +++ b/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java @@ -23,11 +23,23 @@ public class DeliverySweepScheduler { lockAtMostFor = "${openframe.rmm.delivery.lock-at-most-for}", lockAtLeastFor = "${openframe.rmm.delivery.lock-at-least-for}") public void tick() { + retryPending(); + reapAcked(); + } + + private void retryPending() { try { sweepService.retryPending(); + } catch (Exception e) { + log.error("Delivery sweep failed", e); + } + } + + private void reapAcked() { + try { watchdogService.reapAcked(); } catch (Exception e) { - log.error("Delivery sweep tick failed", e); + log.error("Delivery watchdog failed", e); } } } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java index d0b0231240..24cf00e28a 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java @@ -4,6 +4,8 @@ import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; import com.openframe.data.document.oauth.OAuthClient; +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.data.document.rmm.delivery.DeliveryKind; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.oauth.OAuthClientRepository; import lombok.RequiredArgsConstructor; @@ -22,6 +24,7 @@ public class AgentUninstallService { private final MachineRepository machineRepository; private final ToolConnectionService toolConnectionService; private final InstalledAgentService installedAgentService; + private final DeliveryTracker deliveryTracker; public void uninstall(String machineId, String clientSecret) { Optional client = oauthClientRepository.findByMachineId(machineId); @@ -48,6 +51,7 @@ public void uninstall(String machineId, String clientSecret) { toolConnectionService.disconnectAll(machineId); installedAgentService.disconnectAll(machineId); + deliveryTracker.complete(DeliveryKind.CLIENT_UNINSTALL, machineId, machineId); log.info("Machine {} deregistered on uninstall", machineId); } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java index 85d1511be4..8f49549a86 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java @@ -4,6 +4,8 @@ import com.openframe.client.exception.MachineNotFoundException; import com.openframe.data.document.installedagents.InstalledAgent; import com.openframe.data.document.tool.ConnectionStatus; +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.data.document.rmm.delivery.DeliveryKind; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.installedagents.InstalledAgentRepository; import lombok.RequiredArgsConstructor; @@ -21,6 +23,7 @@ public class InstalledAgentService { private final InstalledAgentRepository installedAgentRepository; private final MachineRepository machineRepository; + private final DeliveryTracker deliveryTracker; @Transactional public void addInstalledAgent(String machineId, String agentType, String version, boolean lastAttempt) { @@ -36,6 +39,7 @@ public void addInstalledAgent(String machineId, String agentType, String version installedAgent -> updateExistingInstalledAgent(installedAgent, version, machineId, agentType), () -> addNewInstalledAgent(machineId, agentType, version) ); + deliveryTracker.complete(DeliveryKind.TOOL_INSTALLATION, agentType, machineId); } @Transactional diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java index 6c388efb93..5680b28cbb 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java @@ -9,6 +9,7 @@ import com.openframe.data.repository.rmm.MachineDeliveryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.time.Instant; @@ -16,6 +17,7 @@ @Slf4j @Component @RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") public class DeliveryFailureRecorder { private final MachineDeliveryRepository repository; diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java index 918eea3cad..05f877b7b9 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java @@ -15,6 +15,7 @@ import com.openframe.data.repository.rmm.MachineDeliveryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import java.time.Instant; @@ -26,6 +27,7 @@ @Slf4j @Service @RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") public class DeliverySweepService { private final MachineDeliveryRepository repository; diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java index b045895282..be2fdc627a 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java @@ -1,65 +1,10 @@ package com.openframe.client.service.rmm.delivery; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import java.time.Instant; +public interface DeliveryTracker { -@Slf4j -@Service -@RequiredArgsConstructor -public class DeliveryTracker { + void acknowledge(DeliveryKind kind, String targetId, String machineId); - private final MachineDeliveryRepository repository; - private final DeliveryProperties properties; - - public void acknowledge(DeliveryKind kind, String targetId, String machineId) { - String id = MachineDelivery.id(kind, targetId, machineId); - repository.findById(id) - .filter(DeliveryTracker::isPending) - .ifPresent(this::markAcked); - } - - public void complete(DeliveryKind kind, String targetId, String machineId) { - String id = MachineDelivery.id(kind, targetId, machineId); - repository.findById(id) - .filter(DeliveryTracker::isOpen) - .ifPresent(this::markDone); - } - - private void markAcked(MachineDelivery delivery) { - Instant now = Instant.now(); - delivery.setStatus(DeliveryStatus.ACKED); - delivery.setAckedAt(now); - repository.save(delivery); - log.info("Delivery ACKED: kind={} targetId={} machineId={}", - delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); - } - - private void markDone(MachineDelivery delivery) { - Instant now = Instant.now(); - Policy policy = properties.resolve(delivery.getKind()); - long ttlSeconds = policy.getTtlSeconds(); - delivery.setStatus(DeliveryStatus.DONE); - delivery.setFinishedAt(now); - delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); - repository.save(delivery); - log.info("Delivery DONE: kind={} targetId={} machineId={} attempts={}", - delivery.getKind(), delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts()); - } - - private static boolean isPending(MachineDelivery delivery) { - return delivery.getStatus() == DeliveryStatus.PENDING; - } - - private static boolean isOpen(MachineDelivery delivery) { - DeliveryStatus status = delivery.getStatus(); - return status == DeliveryStatus.PENDING || status == DeliveryStatus.ACKED; - } + void complete(DeliveryKind kind, String targetId, String machineId); } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java index be65e73bc2..a3183ec52f 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java @@ -7,6 +7,7 @@ import com.openframe.data.document.rmm.delivery.MachineDelivery; import com.openframe.data.repository.rmm.MachineDeliveryRepository; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import java.time.Instant; @@ -15,6 +16,7 @@ @Service @RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") public class DeliveryWatchdogService { private final MachineDeliveryRepository repository; diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java new file mode 100644 index 0000000000..adab66afa5 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java @@ -0,0 +1,69 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +public class MongoDeliveryTracker implements DeliveryTracker { + + private final MachineDeliveryRepository repository; + private final DeliveryProperties properties; + + @Override + public void acknowledge(DeliveryKind kind, String targetId, String machineId) { + String id = MachineDelivery.id(kind, targetId, machineId); + repository.findById(id) + .filter(MongoDeliveryTracker::isPending) + .ifPresent(this::markAcked); + } + + @Override + public void complete(DeliveryKind kind, String targetId, String machineId) { + String id = MachineDelivery.id(kind, targetId, machineId); + repository.findById(id) + .filter(MongoDeliveryTracker::isOpen) + .ifPresent(this::markDone); + } + + private void markAcked(MachineDelivery delivery) { + Instant now = Instant.now(); + delivery.setStatus(DeliveryStatus.ACKED); + delivery.setAckedAt(now); + repository.save(delivery); + log.info("Delivery ACKED: kind={} targetId={} machineId={}", + delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); + } + + private void markDone(MachineDelivery delivery) { + Instant now = Instant.now(); + Policy policy = properties.resolve(delivery.getKind()); + long ttlSeconds = policy.getTtlSeconds(); + delivery.setStatus(DeliveryStatus.DONE); + delivery.setFinishedAt(now); + delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); + repository.save(delivery); + log.info("Delivery DONE: kind={} targetId={} machineId={} attempts={}", + delivery.getKind(), delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts()); + } + + private static boolean isPending(MachineDelivery delivery) { + return delivery.getStatus() == DeliveryStatus.PENDING; + } + + private static boolean isOpen(MachineDelivery delivery) { + DeliveryStatus status = delivery.getStatus(); + return status == DeliveryStatus.PENDING || status == DeliveryStatus.ACKED; + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java new file mode 100644 index 0000000000..7d5aa1fdb6 --- /dev/null +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java @@ -0,0 +1,18 @@ +package com.openframe.client.service.rmm.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "false", matchIfMissing = true) +public class NoopDeliveryTracker implements DeliveryTracker { + + @Override + public void acknowledge(DeliveryKind kind, String targetId, String machineId) { + } + + @Override + public void complete(DeliveryKind kind, String targetId, String machineId) { + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java b/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java new file mode 100644 index 0000000000..b33b00b4ff --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java @@ -0,0 +1,114 @@ +package com.openframe.client.listener.rmm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; +import io.nats.client.Connection; +import io.nats.client.Message; +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 static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +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 ScriptExecutionAcknowledgeListenerTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String EXECUTION_ID = "exec-1"; + private static final String TOOL_AGENT_ID = "tactical-agent"; + private static final String LEGACY_SCRIPT_ACK = + "{\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; + private static final String TOOL_INSTALLATION_ACK = + "{\"kind\":\"TOOL_INSTALLATION\",\"targetId\":\"tactical-agent\",\"machineId\":\"mach-42\"}"; + private static final String SCRIPT_SCHEDULE_ACK = + "{\"kind\":\"SCRIPT_SCHEDULE\",\"targetId\":\"exec-1\",\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; + private static final String MALFORMED = "not json"; + + @Mock private Connection natsConnection; + @Mock private ScriptExecutionAcknowledgeService acknowledgeService; + @Mock private DeliveryTracker deliveryTracker; + @Mock private Message message; + + @Captor private ArgumentCaptor ackCaptor; + + private ScriptExecutionAcknowledgeListener listener; + + @BeforeEach + void setUp() { + listener = new ScriptExecutionAcknowledgeListener(natsConnection, new ObjectMapper(), acknowledgeService, deliveryTracker); + } + + @Test + void handleMessage_legacyScriptAck_scriptServiceOnly() { + // setup + stubPayload(LEGACY_SCRIPT_ACK); + + // execution + listener.handleMessage(message); + + // verifications + verify(acknowledgeService).acknowledge(ackCaptor.capture()); + assertThat(ackCaptor.getValue().getExecutionId()).isEqualTo(EXECUTION_ID); + verifyNoInteractions(deliveryTracker); + verify(message).ack(); + } + + @Test + void handleMessage_toolInstallationAck_trackerOnly() { + // setup + stubPayload(TOOL_INSTALLATION_ACK); + + // execution + listener.handleMessage(message); + + // verifications + verify(deliveryTracker).acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + verifyNoInteractions(acknowledgeService); + verify(message).ack(); + } + + @Test + void handleMessage_scriptScheduleAckWithKind_trackerAndScriptService() { + // setup + stubPayload(SCRIPT_SCHEDULE_ACK); + + // execution + listener.handleMessage(message); + + // verifications + verify(deliveryTracker).acknowledge(DeliveryKind.SCRIPT_SCHEDULE, EXECUTION_ID, MACHINE_ID); + verify(acknowledgeService).acknowledge(ackCaptor.capture()); + assertThat(ackCaptor.getValue().getExecutionId()).isEqualTo(EXECUTION_ID); + verify(message).ack(); + } + + @Test + void handleMessage_malformedPayload_leftUnacked() { + // setup + stubPayload(MALFORMED); + + // execution + listener.handleMessage(message); + + // verifications + verify(message, never()).ack(); + verifyNoInteractions(acknowledgeService); + verifyNoInteractions(deliveryTracker); + } + + private void stubPayload(String json) { + when(message.getData()).thenReturn(json.getBytes(UTF_8)); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java new file mode 100644 index 0000000000..3a2474284e --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java @@ -0,0 +1,98 @@ +package com.openframe.client.service; + +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.client.service.validator.ClientSecretValidator; +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.oauth.OAuthClient; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.oauth.OAuthClientRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +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 AgentUninstallServiceTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String SECRET = "s3cret"; + + @Mock private OAuthClientRepository oauthClientRepository; + @Mock private ClientSecretValidator clientSecretValidator; + @Mock private MachineRepository machineRepository; + @Mock private ToolConnectionService toolConnectionService; + @Mock private InstalledAgentService installedAgentService; + @Mock private DeliveryTracker deliveryTracker; + + @InjectMocks private AgentUninstallService service; + + private OAuthClient client; + private Machine machine; + + @BeforeEach + void setUp() { + client = new OAuthClient(); + machine = new Machine(); + machine.setMachineId(MACHINE_ID); + machine.setStatus(DeviceStatus.PENDING_DELETION); + } + + @Test + void uninstall_pendingDeletionMachine_deletedAndDeliveryCompleted() { + // setup + when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(client)); + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + + // execution + service.uninstall(MACHINE_ID, SECRET); + + // verifications + verify(clientSecretValidator).validate(client, SECRET); + assertThat(machine.getStatus()).isEqualTo(DeviceStatus.DELETED); + verify(machineRepository).save(machine); + verify(toolConnectionService).disconnectAll(MACHINE_ID); + verify(installedAgentService).disconnectAll(MACHINE_ID); + verify(deliveryTracker).complete(DeliveryKind.CLIENT_UNINSTALL, MACHINE_ID, MACHINE_ID); + } + + @Test + void uninstall_alreadyDeleted_deliveryUntouched() { + // setup + machine.setStatus(DeviceStatus.DELETED); + when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(client)); + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + + // execution + service.uninstall(MACHINE_ID, SECRET); + + // verifications + verify(machineRepository, never()).save(machine); + verifyNoInteractions(deliveryTracker); + } + + @Test + void uninstall_unknownClient_noop() { + // setup + when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.empty()); + + // execution + service.uninstall(MACHINE_ID, SECRET); + + // verifications + verifyNoInteractions(clientSecretValidator); + verifyNoInteractions(machineRepository); + verifyNoInteractions(deliveryTracker); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java new file mode 100644 index 0000000000..ea0e2e8d61 --- /dev/null +++ b/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java @@ -0,0 +1,103 @@ +package com.openframe.client.service; + +import com.openframe.client.exception.MachineNotFoundException; +import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.data.document.device.Machine; +import com.openframe.data.document.installedagents.InstalledAgent; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.tool.ConnectionStatus; +import com.openframe.data.repository.device.MachineRepository; +import com.openframe.data.repository.installedagents.InstalledAgentRepository; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InstalledAgentServiceTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String AGENT_TYPE = "tactical-agent"; + private static final String OLD_VERSION = "1.0.0"; + private static final String VERSION = "1.2.3"; + + @Mock private InstalledAgentRepository installedAgentRepository; + @Mock private MachineRepository machineRepository; + @Mock private DeliveryTracker deliveryTracker; + + @Captor private ArgumentCaptor installedAgentCaptor; + + @InjectMocks private InstalledAgentService service; + + private Machine machine; + private InstalledAgent existing; + + @BeforeEach + void setUp() { + machine = new Machine(); + machine.setMachineId(MACHINE_ID); + existing = new InstalledAgent(); + existing.setMachineId(MACHINE_ID); + existing.setAgentType(AGENT_TYPE); + existing.setVersion(OLD_VERSION); + existing.setStatus(ConnectionStatus.DISCONNECTED); + } + + @Test + void addInstalledAgent_newAgent_savedAndDeliveryCompleted() { + // setup + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + when(installedAgentRepository.findByMachineIdAndAgentType(MACHINE_ID, AGENT_TYPE)).thenReturn(Optional.empty()); + + // execution + service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false); + + // verifications + verify(installedAgentRepository).save(installedAgentCaptor.capture()); + assertThat(installedAgentCaptor.getValue().getVersion()).isEqualTo(VERSION); + assertThat(installedAgentCaptor.getValue().getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + verify(deliveryTracker).complete(DeliveryKind.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); + } + + @Test + void addInstalledAgent_existingAgent_versionUpdatedAndDeliveryCompleted() { + // setup + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); + when(installedAgentRepository.findByMachineIdAndAgentType(MACHINE_ID, AGENT_TYPE)).thenReturn(Optional.of(existing)); + + // execution + service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false); + + // verifications + assertThat(existing.getVersion()).isEqualTo(VERSION); + assertThat(existing.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + verify(installedAgentRepository).save(existing); + verify(deliveryTracker).complete(DeliveryKind.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); + } + + @Test + void addInstalledAgent_unknownMachine_throwsAndDeliveryUntouched() { + // setup + when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.empty()); + + // execution + MachineNotFoundException ex = assertThrows(MachineNotFoundException.class, + () -> service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false)); + + // verifications + assertThat(ex.getMessage()).contains(MACHINE_ID); + verifyNoInteractions(deliveryTracker); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java similarity index 96% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java rename to openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java index 367560f9b7..73d2d19c94 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryTrackerTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java @@ -20,7 +20,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class DeliveryTrackerTest { +class MongoDeliveryTrackerTest { private static final String MACHINE_ID = "mach-42"; private static final String TOOL_AGENT_ID = "tactical-agent"; @@ -29,7 +29,7 @@ class DeliveryTrackerTest { @Mock private MachineDeliveryRepository repository; - private DeliveryTracker tracker; + private MongoDeliveryTracker tracker; private MachineDelivery delivery; @@ -53,7 +53,7 @@ void setUp() { .status(DeliveryStatus.PENDING) .build(); - tracker = new DeliveryTracker(repository, properties); + tracker = new MongoDeliveryTracker(repository, properties); } @Test diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java index 632fd5abc7..3fd25ff337 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java @@ -1,59 +1,6 @@ package com.openframe.data.nats.delivery; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; +public interface DeliveryDispatch { -import java.time.Instant; - -@Slf4j -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") -public class DeliveryDispatch { - - private final MachineDeliveryRepository repository; - private final ObjectMapper objectMapper; - - public void send(DeliveryRequest request, Runnable publish) { - MachineDelivery delivery = pendingRow(request); - repository.save(delivery); - publish.run(); - log.info("Delivery dispatched: kind={} targetId={} machineId={}", - request.getKind(), request.getTargetId(), request.getMachineId()); - } - - private MachineDelivery pendingRow(DeliveryRequest request) { - Instant now = Instant.now(); - String id = MachineDelivery.id(request.getKind(), request.getTargetId(), request.getMachineId()); - String payloadJson = toJson(request.getPayload()); - return MachineDelivery.builder() - .id(id) - .kind(request.getKind()) - .targetId(request.getTargetId()) - .machineId(request.getMachineId()) - .status(DeliveryStatus.PENDING) - .attempts(0) - .payloadJson(payloadJson) - .dispatchedAt(now) - .lastAttemptAt(now) - .offlineBehavior(request.getOfflineBehavior()) - .reconnectWindowSeconds(request.getReconnectWindowSeconds()) - .build(); - } - - private String toJson(Object payload) { - try { - return objectMapper.writeValueAsString(payload); - } catch (JsonProcessingException e) { - String payloadType = payload.getClass().getSimpleName(); - throw new IllegalArgumentException("Delivery payload is not serializable: " + payloadType, e); - } - } + void send(DeliveryRequest request, Runnable publish); } diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java new file mode 100644 index 0000000000..ed83c4a9a2 --- /dev/null +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java @@ -0,0 +1,15 @@ +package com.openframe.data.nats.delivery; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +// flag off = today's behaviour: publish without a row, so callers keep a single code path +@Component +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "false", matchIfMissing = true) +public class PublishOnlyDeliveryDispatch implements DeliveryDispatch { + + @Override + public void send(DeliveryRequest request, Runnable publish) { + publish.run(); + } +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java new file mode 100644 index 0000000000..15c7a7ae23 --- /dev/null +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java @@ -0,0 +1,60 @@ +package com.openframe.data.nats.delivery; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openframe.data.document.rmm.delivery.DeliveryStatus; +import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +public class RecordingDeliveryDispatch implements DeliveryDispatch { + + private final MachineDeliveryRepository repository; + private final ObjectMapper objectMapper; + + @Override + public void send(DeliveryRequest request, Runnable publish) { + MachineDelivery delivery = pendingRow(request); + repository.save(delivery); + publish.run(); + log.info("Delivery dispatched: kind={} targetId={} machineId={}", + request.getKind(), request.getTargetId(), request.getMachineId()); + } + + private MachineDelivery pendingRow(DeliveryRequest request) { + Instant now = Instant.now(); + String id = MachineDelivery.id(request.getKind(), request.getTargetId(), request.getMachineId()); + String payloadJson = toJson(request.getPayload()); + return MachineDelivery.builder() + .id(id) + .kind(request.getKind()) + .targetId(request.getTargetId()) + .machineId(request.getMachineId()) + .status(DeliveryStatus.PENDING) + .attempts(0) + .payloadJson(payloadJson) + .dispatchedAt(now) + .lastAttemptAt(now) + .offlineBehavior(request.getOfflineBehavior()) + .reconnectWindowSeconds(request.getReconnectWindowSeconds()) + .build(); + } + + private String toJson(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + String payloadType = payload.getClass().getSimpleName(); + throw new IllegalArgumentException("Delivery payload is not serializable: " + payloadType, e); + } + } +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java index f9a6025c2e..4ff84c3ad1 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java @@ -31,7 +31,7 @@ public void publish(String machineId, ClientUninstallMessage message) { log.info("Published client uninstall command for machine {}", machineId); } - private ClientUninstallMessage buildMessage() { + public ClientUninstallMessage buildMessage() { ClientUninstallMessage message = new ClientUninstallMessage(); message.setIssuedAt(Instant.now().toString()); return message; diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java index 4a6c6a5495..00e0f459f5 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java @@ -52,7 +52,7 @@ private ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, Inte return buildMessage(toolAgent, tool, false); } - private ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { + public ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { ToolInstallationMessage message = new ToolInstallationMessage(); message.setToolAgentId(toolAgent.getKey()); // TODO: need refactoring diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java index 9100972645..e1e90ec53f 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java @@ -1,5 +1,6 @@ package com.openframe.data.nats.rmm.model; +import com.openframe.data.document.rmm.delivery.DeliveryKind; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Builder; @@ -19,4 +20,8 @@ public class ScriptExecutionAcknowledgeMessage { private String machineId; private String scheduleId; private List scriptIds; + + // delivery kinds other than scripts ack through the same subject; null = legacy script ack + private DeliveryKind kind; + private String targetId; } diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java new file mode 100644 index 0000000000..a28d839f3e --- /dev/null +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java @@ -0,0 +1,34 @@ +package com.openframe.data.nats.delivery; + +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PublishOnlyDeliveryDispatchTest { + + private static final String MACHINE_ID = "mach-42"; + + @Mock private Runnable publish; + + @Test + void send_anyRequest_publishedWithoutRow() { + // setup + PublishOnlyDeliveryDispatch dispatch = new PublishOnlyDeliveryDispatch(); + DeliveryRequest request = DeliveryRequest.builder() + .kind(DeliveryKind.CLIENT_UNINSTALL) + .targetId(MACHINE_ID) + .machineId(MACHINE_ID) + .build(); + + // execution + dispatch.send(request, publish); + + // verifications + verify(publish).run(); + } +} diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java similarity index 93% rename from openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java rename to openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java index e1568af5c6..c3bee3a73a 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/DeliveryDispatchTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java @@ -19,7 +19,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) -class DeliveryDispatchTest { +class RecordingDeliveryDispatchTest { private static final String MACHINE_ID = "mach-42"; private static final String ISSUED_AT = "2026-09-15T10:00:00Z"; @@ -29,11 +29,11 @@ class DeliveryDispatchTest { @Captor private ArgumentCaptor deliveryCaptor; - private DeliveryDispatch dispatch; + private RecordingDeliveryDispatch dispatch; @BeforeEach void setUp() { - dispatch = new DeliveryDispatch(repository, new ObjectMapper()); + dispatch = new RecordingDeliveryDispatch(repository, new ObjectMapper()); } @Test diff --git a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java index f23f850667..9f9e677026 100644 --- a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java +++ b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java @@ -2,6 +2,10 @@ import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; +import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.nats.delivery.DeliveryDispatch; +import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.model.ToolInstallationMessage; import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +25,7 @@ public class ToolInstallationService { private final IntegratedToolService integratedToolService; private final ToolCommandParamsResolver toolCommandParamsResolver; private final ToolInstallationNatsPublisher toolInstallationNatsPublisher; + private final DeliveryDispatch deliveryDispatch; public void process(String machineId, IntegratedToolAgent toolAgent) { process(machineId, toolAgent, false); @@ -41,7 +46,14 @@ public void process(String machineId, IntegratedToolAgent toolAgent, boolean rei List runCommandArgs = toolAgent.getRunCommandArgs(); toolAgent.setRunCommandArgs(toolCommandParamsResolver.process(toolId, runCommandArgs)); - toolInstallationNatsPublisher.publish(machineId, toolAgent, tool, reinstall); + ToolInstallationMessage message = toolInstallationNatsPublisher.buildMessage(toolAgent, tool, reinstall); + DeliveryRequest request = DeliveryRequest.builder() + .kind(DeliveryKind.TOOL_INSTALLATION) + .targetId(toolAgent.getKey()) + .machineId(machineId) + .payload(message) + .build(); + deliveryDispatch.send(request, () -> toolInstallationNatsPublisher.publish(machineId, message)); log.info("Published {} agent installation message for machine {}", toolId, machineId); } catch (Exception e) { // TODO: add fallback mechanism From d3a76b2178b71be984b5afe3d7ff5522d3b4dfd8 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Wed, 16 Sep 2026 14:35:52 +0200 Subject: [PATCH 04/13] refactor(delivery): extract openframe-machine-delivery, specs own the request, kind -> type - New module openframe-machine-delivery (com.openframe.delivery): engine only, no NATS dependency. data-nats depends on it and hosts the NATS specs. - Specs build their own DeliveryRequest (type, targetId, payload, publisher); call sites shrink to spec.request(...) + dispatch.send(request), so the targetId a spec publishes with is the one its completion hook expects. - DeliveryKind -> DeliveryType everywhere (document field, ack message, YAML `types`, metric tag `type`), matching NotificationType. - MachineDelivery moves to document.delivery, repository to repository.delivery. - MachineOnlineStatus is the single place the engine reads the Machine document. - Flags: openframe.delivery.enabled for the engine, openframe.delivery.sweep.enabled for the scheduler, so publishers of deliveries never run the sweep. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../service/ForceClientUninstallService.java | 19 ++--- .../ForceClientUninstallServiceTest.java | 34 ++++---- .../ScriptExecutionAcknowledgeListener.java | 10 +-- .../client/metrics/DeliveryMetrics.java | 36 -------- .../client/service/AgentUninstallService.java | 6 +- .../client/service/InstalledAgentService.java | 6 +- .../service/rmm/delivery/DeliverySpec.java | 16 ---- .../service/rmm/delivery/DeliveryTracker.java | 10 --- .../rmm/delivery/DeliveryWatchdogService.java | 39 --------- .../rmm/delivery/NoopDeliveryTracker.java | 18 ---- .../ToolInstallationDeliverySpec.java | 38 --------- ...criptExecutionAcknowledgeListenerTest.java | 14 +-- .../service/AgentUninstallServiceTest.java | 6 +- .../service/InstalledAgentServiceTest.java | 8 +- .../{rmm => }/delivery/DeliveryFailure.java | 2 +- .../{rmm => }/delivery/DeliveryStatus.java | 2 +- .../DeliveryType.java} | 4 +- .../{rmm => }/delivery/MachineDelivery.java | 10 +-- .../delivery/MachineDeliveryRepository.java | 18 ++++ .../rmm/MachineDeliveryRepository.java | 18 ---- openframe-data-nats/pom.xml | 4 + .../delivery/ClientUninstallDeliverySpec.java | 25 ++++-- .../data/nats/delivery/DeliveryDispatch.java | 6 -- .../delivery/PublishOnlyDeliveryDispatch.java | 15 ---- .../ToolInstallationDeliverySpec.java | 53 ++++++++++++ .../ScriptExecutionAcknowledgeMessage.java | 6 +- .../ClientUninstallDeliverySpecTest.java | 29 +++++-- .../ToolInstallationDeliverySpecTest.java | 69 +++++++++++++++ openframe-machine-delivery/pom.xml | 46 ++++++++++ .../openframe/delivery/DeliveryDispatch.java | 6 ++ .../delivery/DeliveryFailureRecorder.java | 29 +++---- .../openframe/delivery/DeliveryMetrics.java | 36 ++++++++ .../delivery/DeliveryProperties.java | 24 +++--- .../openframe}/delivery/DeliveryRequest.java | 14 +-- .../com/openframe/delivery/DeliverySpec.java | 16 ++++ .../delivery/DeliverySpecRegistry.java | 28 +++--- .../delivery}/DeliverySweepScheduler.java | 13 ++- .../delivery/DeliverySweepService.java | 55 +++++------- .../openframe/delivery/DeliveryTracker.java | 10 +++ .../delivery/DeliveryWatchdogService.java | 39 +++++++++ .../delivery/MachineOnlineStatus.java | 25 ++++++ .../delivery/MongoDeliveryTracker.java | 32 +++---- .../delivery/NoopDeliveryTracker.java | 18 ++++ .../delivery/PublishOnlyDeliveryDispatch.java | 20 +++++ .../delivery/RecordingDeliveryDispatch.java | 29 ++++--- .../delivery/DeliveryFailureRecorderTest.java | 40 +++------ .../delivery/DeliveryPropertiesTest.java | 68 +++++++-------- .../delivery/DeliverySpecRegistryTest.java | 32 +++---- .../delivery/DeliverySweepServiceTest.java | 85 +++++++------------ .../delivery/DeliveryTestPolicies.java | 29 +++++++ .../delivery/DeliveryWatchdogServiceTest.java | 39 +++------ .../delivery/MachineOnlineStatusTest.java | 42 +++++++++ .../delivery/MongoDeliveryTrackerTest.java | 47 ++++------ .../PublishOnlyDeliveryDispatchTest.java | 17 ++-- .../RecordingDeliveryDispatchTest.java | 38 +++++---- .../com/openframe/delivery/TestPayload.java | 8 ++ .../data/service/ToolInstallationService.java | 19 ++--- pom.xml | 6 ++ 58 files changed, 811 insertions(+), 620 deletions(-) delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java delete mode 100644 openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java rename openframe-data-mongo-common/src/main/java/com/openframe/data/document/{rmm => }/delivery/DeliveryFailure.java (59%) rename openframe-data-mongo-common/src/main/java/com/openframe/data/document/{rmm => }/delivery/DeliveryStatus.java (60%) rename openframe-data-mongo-common/src/main/java/com/openframe/data/document/{rmm/delivery/DeliveryKind.java => delivery/DeliveryType.java} (57%) rename openframe-data-mongo-common/src/main/java/com/openframe/data/document/{rmm => }/delivery/MachineDelivery.java (82%) create mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/delivery/MachineDeliveryRepository.java delete mode 100644 openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-data-nats/src/main/java/com/openframe/data/nats}/delivery/ClientUninstallDeliverySpec.java (67%) delete mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java delete mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java create mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-data-nats/src/test/java/com/openframe/data/nats}/delivery/ClientUninstallDeliverySpecTest.java (68%) create mode 100644 openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java create mode 100644 openframe-machine-delivery/pom.xml create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/main/java/com/openframe}/delivery/DeliveryFailureRecorder.java (51%) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/main/java/com/openframe}/delivery/DeliveryProperties.java (76%) rename {openframe-data-nats/src/main/java/com/openframe/data/nats => openframe-machine-delivery/src/main/java/com/openframe}/delivery/DeliveryRequest.java (54%) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/main/java/com/openframe}/delivery/DeliverySpecRegistry.java (51%) rename {openframe-client-core/src/main/java/com/openframe/client/scheduler => openframe-machine-delivery/src/main/java/com/openframe/delivery}/DeliverySweepScheduler.java (67%) rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/main/java/com/openframe}/delivery/DeliverySweepService.java (65%) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryTracker.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryWatchdogService.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java rename {openframe-client-core/src/main/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/main/java/com/openframe}/delivery/MongoDeliveryTracker.java (62%) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryTracker.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java rename {openframe-data-nats/src/main/java/com/openframe/data/nats => openframe-machine-delivery/src/main/java/com/openframe}/delivery/RecordingDeliveryDispatch.java (66%) rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/DeliveryFailureRecorderTest.java (53%) rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/DeliveryPropertiesTest.java (55%) rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/DeliverySpecRegistryTest.java (66%) rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/DeliverySweepServiceTest.java (57%) create mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryTestPolicies.java rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/DeliveryWatchdogServiceTest.java (55%) create mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/MachineOnlineStatusTest.java rename {openframe-client-core/src/test/java/com/openframe/client/service/rmm => openframe-machine-delivery/src/test/java/com/openframe}/delivery/MongoDeliveryTrackerTest.java (62%) rename {openframe-data-nats/src/test/java/com/openframe/data/nats => openframe-machine-delivery/src/test/java/com/openframe}/delivery/PublishOnlyDeliveryDispatchTest.java (59%) rename {openframe-data-nats/src/test/java/com/openframe/data/nats => openframe-machine-delivery/src/test/java/com/openframe}/delivery/RecordingDeliveryDispatchTest.java (62%) create mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/TestPayload.java diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java index 55bde6e7e0..aed1e7a4f4 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java @@ -5,11 +5,10 @@ import com.openframe.api.dto.force.response.ForceClientUninstallResponse; import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.nats.delivery.DeliveryDispatch; -import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; +import com.openframe.delivery.DeliveryDispatch; +import com.openframe.delivery.DeliveryRequest; import com.openframe.data.repository.device.MachineRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,7 +26,7 @@ @Slf4j public class ForceClientUninstallService { - private final ClientUninstallNatsPublisher clientUninstallNatsPublisher; + private final ClientUninstallDeliverySpec clientUninstallDeliverySpec; private final MachineRepository machineRepository; private final DeliveryDispatch deliveryDispatch; @@ -74,14 +73,8 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { } private void publishUninstall(String machineId) { - ClientUninstallMessage message = clientUninstallNatsPublisher.buildMessage(); - DeliveryRequest request = DeliveryRequest.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) - .targetId(machineId) - .machineId(machineId) - .payload(message) - .build(); - deliveryDispatch.send(request, () -> clientUninstallNatsPublisher.publish(machineId, message)); + DeliveryRequest request = clientUninstallDeliverySpec.request(machineId); + deliveryDispatch.send(request); } private void markPendingDeletion(Machine machine) { diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java index c4c1811f23..850ca1f2c3 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java @@ -6,17 +6,14 @@ import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.nats.delivery.DeliveryDispatch; -import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; +import com.openframe.delivery.DeliveryDispatch; +import com.openframe.delivery.DeliveryRequest; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -34,18 +31,15 @@ class ForceClientUninstallServiceTest { private static final String MACHINE_ID = "mach-42"; - @Mock private ClientUninstallNatsPublisher clientUninstallNatsPublisher; + @Mock private ClientUninstallDeliverySpec clientUninstallDeliverySpec; @Mock private MachineRepository machineRepository; @Mock private DeliveryDispatch deliveryDispatch; - @Captor private ArgumentCaptor requestCaptor; - @Captor private ArgumentCaptor publishCaptor; - @InjectMocks private ForceClientUninstallService service; private Machine machine; private ForceClientUninstallRequest request; - private ClientUninstallMessage message; + private DeliveryRequest deliveryRequest; @BeforeEach void setUp() { @@ -54,26 +48,27 @@ void setUp() { machine.setStatus(DeviceStatus.ONLINE); request = new ForceClientUninstallRequest(); request.setMachineIds(List.of(MACHINE_ID)); - message = new ClientUninstallMessage(); + deliveryRequest = DeliveryRequest.builder() + .spec(clientUninstallDeliverySpec) + .targetId(MACHINE_ID) + .machineId(MACHINE_ID) + .payload(new ClientUninstallMessage()) + .build(); } @Test void process_onlineMachine_dispatchedAndMarkedPendingDeletion() { // setup when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - when(clientUninstallNatsPublisher.buildMessage()).thenReturn(message); + when(clientUninstallDeliverySpec.request(MACHINE_ID)).thenReturn(deliveryRequest); // execution ForceClientUninstallResponse response = service.process(request); // verifications - verify(deliveryDispatch).send(requestCaptor.capture(), publishCaptor.capture()); - assertThat(requestCaptor.getValue().getKind()).isEqualTo(DeliveryKind.CLIENT_UNINSTALL); - assertThat(requestCaptor.getValue().getTargetId()).isEqualTo(MACHINE_ID); - assertThat(requestCaptor.getValue().getPayload()).isSameAs(message); - publishCaptor.getValue().run(); - verify(clientUninstallNatsPublisher).publish(MACHINE_ID, message); + verify(deliveryDispatch).send(deliveryRequest); assertThat(machine.getStatus()).isEqualTo(DeviceStatus.PENDING_DELETION); + verify(machineRepository).save(machine); assertThat(response.getItems()) .extracting(ForceClientUninstallResponseItem::getStatus) .containsExactly(ForceAgentStatus.PROCESSED); @@ -90,6 +85,7 @@ void process_deletedMachine_failedWithoutDispatch() { // verifications verifyNoInteractions(deliveryDispatch); + verifyNoInteractions(clientUninstallDeliverySpec); assertThat(response.getItems()) .extracting(ForceClientUninstallResponseItem::getStatus) .containsExactly(ForceAgentStatus.FAILED); diff --git a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java index dd77d41625..3f895817b3 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java +++ b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.delivery.DeliveryTracker; import com.openframe.data.nats.listener.AbstractJetStreamPushListener; import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; import io.nats.client.Connection; @@ -64,7 +64,7 @@ protected void handleMessage(Message message) { try { ScriptExecutionAcknowledgeMessage ack = objectMapper.readValue(payload, ScriptExecutionAcknowledgeMessage.class); if (isDeliveryAck(ack)) { - deliveryTracker.acknowledge(ack.getKind(), ack.getTargetId(), ack.getMachineId()); + deliveryTracker.acknowledge(ack.getType(), ack.getTargetId(), ack.getMachineId()); } if (isScriptAck(ack)) { acknowledgeService.acknowledge(ack); @@ -76,10 +76,10 @@ protected void handleMessage(Message message) { } private static boolean isDeliveryAck(ScriptExecutionAcknowledgeMessage ack) { - return ack.getKind() != null && ack.getTargetId() != null; + return ack.getType() != null && ack.getTargetId() != null; } private static boolean isScriptAck(ScriptExecutionAcknowledgeMessage ack) { - return ack.getKind() == null || ack.getKind() == DeliveryKind.SCRIPT_SCHEDULE; + return ack.getType() == null || ack.getType() == DeliveryType.SCRIPT_SCHEDULE; } } diff --git a/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java b/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java deleted file mode 100644 index 59d95fa656..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/metrics/DeliveryMetrics.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.openframe.client.metrics; - -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import io.micrometer.core.instrument.MeterRegistry; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Locale; - -@Component -@RequiredArgsConstructor -public class DeliveryMetrics { - - private static final String RETRIED_COUNTER = "openframe.rmm.delivery.retried"; - private static final String FAILED_COUNTER = "openframe.rmm.delivery.failed"; - private static final String TAG_KIND = "kind"; - private static final String TAG_REASON = "reason"; - - private final MeterRegistry meterRegistry; - - public void recordRetried(DeliveryKind kind) { - String kindTag = tagValue(kind.name()); - meterRegistry.counter(RETRIED_COUNTER, TAG_KIND, kindTag).increment(); - } - - public void recordFailed(DeliveryKind kind, DeliveryFailure failure) { - String kindTag = tagValue(kind.name()); - String reasonTag = tagValue(failure.name()); - meterRegistry.counter(FAILED_COUNTER, TAG_KIND, kindTag, TAG_REASON, reasonTag).increment(); - } - - private static String tagValue(String enumName) { - return enumName.toLowerCase(Locale.ROOT); - } -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java index 24cf00e28a..4149367bff 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java @@ -4,8 +4,8 @@ import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; import com.openframe.data.document.oauth.OAuthClient; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.delivery.DeliveryTracker; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.oauth.OAuthClientRepository; import lombok.RequiredArgsConstructor; @@ -51,7 +51,7 @@ public void uninstall(String machineId, String clientSecret) { toolConnectionService.disconnectAll(machineId); installedAgentService.disconnectAll(machineId); - deliveryTracker.complete(DeliveryKind.CLIENT_UNINSTALL, machineId, machineId); + deliveryTracker.complete(DeliveryType.CLIENT_UNINSTALL, machineId, machineId); log.info("Machine {} deregistered on uninstall", machineId); } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java index 8f49549a86..fae538a0da 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java @@ -4,8 +4,8 @@ import com.openframe.client.exception.MachineNotFoundException; import com.openframe.data.document.installedagents.InstalledAgent; import com.openframe.data.document.tool.ConnectionStatus; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.delivery.DeliveryTracker; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.installedagents.InstalledAgentRepository; import lombok.RequiredArgsConstructor; @@ -39,7 +39,7 @@ public void addInstalledAgent(String machineId, String agentType, String version installedAgent -> updateExistingInstalledAgent(installedAgent, version, machineId, agentType), () -> addNewInstalledAgent(machineId, agentType, version) ); - deliveryTracker.complete(DeliveryKind.TOOL_INSTALLATION, agentType, machineId); + deliveryTracker.complete(DeliveryType.TOOL_INSTALLATION, agentType, machineId); } @Transactional diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java deleted file mode 100644 index 3c1fdf9108..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpec.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; - -public interface DeliverySpec

{ - - DeliveryKind getKind(); - - Class

getPayloadClass(); - - void publish(String machineId, P payload); - - void onFailed(MachineDelivery delivery, DeliveryFailure failure); -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java deleted file mode 100644 index be2fdc627a..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryTracker.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.data.document.rmm.delivery.DeliveryKind; - -public interface DeliveryTracker { - - void acknowledge(DeliveryKind kind, String targetId, String machineId); - - void complete(DeliveryKind kind, String targetId, String machineId); -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java deleted file mode 100644 index a3183ec52f..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.Set; - -@Service -@RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") -public class DeliveryWatchdogService { - - private final MachineDeliveryRepository repository; - private final DeliverySpecRegistry registry; - private final DeliveryProperties properties; - private final DeliveryFailureRecorder failureRecorder; - - public void reapAcked() { - Instant now = Instant.now(); - Set kinds = registry.kinds(); - kinds.forEach(kind -> reapAcked(kind, now)); - } - - private void reapAcked(DeliveryKind kind, Instant now) { - Policy policy = properties.resolve(kind); - Instant threshold = now.minusSeconds(policy.getResultTimeoutSeconds()); - List silent = repository.findByKindAndStatusAndAckedAtBefore(kind, DeliveryStatus.ACKED, threshold); - silent.forEach(delivery -> failureRecorder.fail(delivery, DeliveryFailure.TIMEOUT, now)); - } -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java deleted file mode 100644 index 7d5aa1fdb6..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/NoopDeliveryTracker.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Component -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "false", matchIfMissing = true) -public class NoopDeliveryTracker implements DeliveryTracker { - - @Override - public void acknowledge(DeliveryKind kind, String targetId, String machineId) { - } - - @Override - public void complete(DeliveryKind kind, String targetId, String machineId) { - } -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java deleted file mode 100644 index 36b7cf1eed..0000000000 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ToolInstallationDeliverySpec.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty("spring.cloud.stream.enabled") -public class ToolInstallationDeliverySpec implements DeliverySpec { - - private final ToolInstallationNatsPublisher publisher; - - @Override - public DeliveryKind getKind() { - return DeliveryKind.TOOL_INSTALLATION; - } - - @Override - public Class getPayloadClass() { - return ToolInstallationMessage.class; - } - - @Override - public void publish(String machineId, ToolInstallationMessage payload) { - publisher.publish(machineId, payload); - } - - @Override - public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { - // nothing beyond FAILED + metric: an install is safe to re-run by hand - } -} diff --git a/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java b/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java index b33b00b4ff..64c61e9e28 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java @@ -2,8 +2,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.delivery.DeliveryTracker; import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; import io.nats.client.Connection; import io.nats.client.Message; @@ -31,9 +31,9 @@ class ScriptExecutionAcknowledgeListenerTest { private static final String LEGACY_SCRIPT_ACK = "{\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; private static final String TOOL_INSTALLATION_ACK = - "{\"kind\":\"TOOL_INSTALLATION\",\"targetId\":\"tactical-agent\",\"machineId\":\"mach-42\"}"; + "{\"type\":\"TOOL_INSTALLATION\",\"targetId\":\"tactical-agent\",\"machineId\":\"mach-42\"}"; private static final String SCRIPT_SCHEDULE_ACK = - "{\"kind\":\"SCRIPT_SCHEDULE\",\"targetId\":\"exec-1\",\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; + "{\"type\":\"SCRIPT_SCHEDULE\",\"targetId\":\"exec-1\",\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; private static final String MALFORMED = "not json"; @Mock private Connection natsConnection; @@ -74,13 +74,13 @@ void handleMessage_toolInstallationAck_trackerOnly() { listener.handleMessage(message); // verifications - verify(deliveryTracker).acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + verify(deliveryTracker).acknowledge(DeliveryType.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); verifyNoInteractions(acknowledgeService); verify(message).ack(); } @Test - void handleMessage_scriptScheduleAckWithKind_trackerAndScriptService() { + void handleMessage_scriptScheduleAckWithType_trackerAndScriptService() { // setup stubPayload(SCRIPT_SCHEDULE_ACK); @@ -88,7 +88,7 @@ void handleMessage_scriptScheduleAckWithKind_trackerAndScriptService() { listener.handleMessage(message); // verifications - verify(deliveryTracker).acknowledge(DeliveryKind.SCRIPT_SCHEDULE, EXECUTION_ID, MACHINE_ID); + verify(deliveryTracker).acknowledge(DeliveryType.SCRIPT_SCHEDULE, EXECUTION_ID, MACHINE_ID); verify(acknowledgeService).acknowledge(ackCaptor.capture()); assertThat(ackCaptor.getValue().getExecutionId()).isEqualTo(EXECUTION_ID); verify(message).ack(); diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java index 3a2474284e..1c2dc4c12b 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java @@ -1,11 +1,11 @@ package com.openframe.client.service; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.delivery.DeliveryTracker; import com.openframe.client.service.validator.ClientSecretValidator; import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; import com.openframe.data.document.oauth.OAuthClient; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.oauth.OAuthClientRepository; import org.junit.jupiter.api.BeforeEach; @@ -64,7 +64,7 @@ void uninstall_pendingDeletionMachine_deletedAndDeliveryCompleted() { verify(machineRepository).save(machine); verify(toolConnectionService).disconnectAll(MACHINE_ID); verify(installedAgentService).disconnectAll(MACHINE_ID); - verify(deliveryTracker).complete(DeliveryKind.CLIENT_UNINSTALL, MACHINE_ID, MACHINE_ID); + verify(deliveryTracker).complete(DeliveryType.CLIENT_UNINSTALL, MACHINE_ID, MACHINE_ID); } @Test diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java index ea0e2e8d61..9252801f83 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java +++ b/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java @@ -1,10 +1,10 @@ package com.openframe.client.service; import com.openframe.client.exception.MachineNotFoundException; -import com.openframe.client.service.rmm.delivery.DeliveryTracker; +import com.openframe.delivery.DeliveryTracker; import com.openframe.data.document.device.Machine; import com.openframe.data.document.installedagents.InstalledAgent; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.document.tool.ConnectionStatus; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.installedagents.InstalledAgentRepository; @@ -68,7 +68,7 @@ void addInstalledAgent_newAgent_savedAndDeliveryCompleted() { verify(installedAgentRepository).save(installedAgentCaptor.capture()); assertThat(installedAgentCaptor.getValue().getVersion()).isEqualTo(VERSION); assertThat(installedAgentCaptor.getValue().getStatus()).isEqualTo(ConnectionStatus.CONNECTED); - verify(deliveryTracker).complete(DeliveryKind.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); + verify(deliveryTracker).complete(DeliveryType.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); } @Test @@ -84,7 +84,7 @@ void addInstalledAgent_existingAgent_versionUpdatedAndDeliveryCompleted() { assertThat(existing.getVersion()).isEqualTo(VERSION); assertThat(existing.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); verify(installedAgentRepository).save(existing); - verify(deliveryTracker).complete(DeliveryKind.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); + verify(deliveryTracker).complete(DeliveryType.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); } @Test diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryFailure.java similarity index 59% rename from openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java rename to openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryFailure.java index 3c9e7804a9..7095d22f8a 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryFailure.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryFailure.java @@ -1,4 +1,4 @@ -package com.openframe.data.document.rmm.delivery; +package com.openframe.data.document.delivery; public enum DeliveryFailure { EXHAUSTED, diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryStatus.java similarity index 60% rename from openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java rename to openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryStatus.java index 2a89a16f04..9c05515151 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryStatus.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryStatus.java @@ -1,4 +1,4 @@ -package com.openframe.data.document.rmm.delivery; +package com.openframe.data.document.delivery; public enum DeliveryStatus { PENDING, diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryType.java similarity index 57% rename from openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java rename to openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryType.java index 094f34f57b..3bd4c20354 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/DeliveryKind.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/DeliveryType.java @@ -1,6 +1,6 @@ -package com.openframe.data.document.rmm.delivery; +package com.openframe.data.document.delivery; -public enum DeliveryKind { +public enum DeliveryType { SCRIPT_SCHEDULE, TOOL_INSTALLATION, TOOL_UPDATE, diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java similarity index 82% rename from openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java rename to openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java index df854ea959..9508ef21f9 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/delivery/MachineDelivery.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java @@ -1,4 +1,4 @@ -package com.openframe.data.document.rmm.delivery; +package com.openframe.data.document.delivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import lombok.AllArgsConstructor; @@ -17,7 +17,7 @@ @NoArgsConstructor @AllArgsConstructor @Document(collection = "machine_delivery") -@CompoundIndex(name = "machine_delivery_sweep", def = "{'kind': 1, 'status': 1, 'lastAttemptAt': 1}") +@CompoundIndex(name = "machine_delivery_sweep", def = "{'type': 1, 'status': 1, 'lastAttemptAt': 1}") public class MachineDelivery { private static final String ID_SEPARATOR = ":"; @@ -25,7 +25,7 @@ public class MachineDelivery { @Id private String id; - private DeliveryKind kind; + private DeliveryType type; private String targetId; private String machineId; private String tenantId; @@ -48,7 +48,7 @@ public class MachineDelivery { @Indexed(name = "machine_delivery_ttl", expireAfterSeconds = 0) private Instant expiresAt; - public static String id(DeliveryKind kind, String targetId, String machineId) { - return kind.name() + ID_SEPARATOR + targetId + ID_SEPARATOR + machineId; + public static String id(DeliveryType type, String targetId, String machineId) { + return type.name() + ID_SEPARATOR + targetId + ID_SEPARATOR + machineId; } } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/delivery/MachineDeliveryRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/delivery/MachineDeliveryRepository.java new file mode 100644 index 0000000000..934d078eb3 --- /dev/null +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/delivery/MachineDeliveryRepository.java @@ -0,0 +1,18 @@ +package com.openframe.data.repository.delivery; + +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface MachineDeliveryRepository extends MongoRepository { + + List findByTypeAndStatusAndLastAttemptAtBefore(DeliveryType type, DeliveryStatus status, Instant before); + + List findByTypeAndStatusAndAckedAtBefore(DeliveryType type, DeliveryStatus status, Instant before); +} diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java deleted file mode 100644 index 53856f938e..0000000000 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/MachineDeliveryRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.openframe.data.repository.rmm; - -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.time.Instant; -import java.util.List; - -@Repository -public interface MachineDeliveryRepository extends MongoRepository { - - List findByKindAndStatusAndLastAttemptAtBefore(DeliveryKind kind, DeliveryStatus status, Instant before); - - List findByKindAndStatusAndAckedAtBefore(DeliveryKind kind, DeliveryStatus status, Instant before); -} diff --git a/openframe-data-nats/pom.xml b/openframe-data-nats/pom.xml index d7ec7edd04..219bb25d05 100644 --- a/openframe-data-nats/pom.xml +++ b/openframe-data-nats/pom.xml @@ -28,6 +28,10 @@ com.openframe.oss openframe-data-mongo-sync + + com.openframe.oss + openframe-machine-delivery + org.springframework.boot diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java similarity index 67% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java rename to openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java index 06342664a5..0e164c0c60 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java @@ -1,13 +1,15 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.data.nats.delivery; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; import com.openframe.data.nats.model.ClientUninstallMessage; import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; +import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliverySpec; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -20,9 +22,20 @@ public class ClientUninstallDeliverySpec implements DeliverySpec request(String machineId) { + ClientUninstallMessage message = publisher.buildMessage(); + return DeliveryRequest.builder() + .spec(this) + .targetId(machineId) + .machineId(machineId) + .payload(message) + .build(); + } + @Override - public DeliveryKind getKind() { - return DeliveryKind.CLIENT_UNINSTALL; + public DeliveryType getType() { + return DeliveryType.CLIENT_UNINSTALL; } @Override diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java deleted file mode 100644 index 3fd25ff337..0000000000 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryDispatch.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.openframe.data.nats.delivery; - -public interface DeliveryDispatch { - - void send(DeliveryRequest request, Runnable publish); -} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java deleted file mode 100644 index ed83c4a9a2..0000000000 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatch.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.openframe.data.nats.delivery; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -// flag off = today's behaviour: publish without a row, so callers keep a single code path -@Component -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "false", matchIfMissing = true) -public class PublishOnlyDeliveryDispatch implements DeliveryDispatch { - - @Override - public void send(DeliveryRequest request, Runnable publish) { - publish.run(); - } -} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java new file mode 100644 index 0000000000..ec06de2bc7 --- /dev/null +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java @@ -0,0 +1,53 @@ +package com.openframe.data.nats.delivery; + +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.toolagent.IntegratedToolAgent; +import com.openframe.data.nats.model.ToolInstallationMessage; +import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliverySpec; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty("spring.cloud.stream.enabled") +public class ToolInstallationDeliverySpec implements DeliverySpec { + + private final ToolInstallationNatsPublisher publisher; + + // targetId is the tool agent key: the agent reports it back as agentType in installed-agent + public DeliveryRequest request(String machineId, IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { + ToolInstallationMessage message = publisher.buildMessage(toolAgent, tool, reinstall); + return DeliveryRequest.builder() + .spec(this) + .targetId(toolAgent.getKey()) + .machineId(machineId) + .payload(message) + .build(); + } + + @Override + public DeliveryType getType() { + return DeliveryType.TOOL_INSTALLATION; + } + + @Override + public Class getPayloadClass() { + return ToolInstallationMessage.class; + } + + @Override + public void publish(String machineId, ToolInstallationMessage payload) { + publisher.publish(machineId, payload); + } + + @Override + public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { + // nothing beyond FAILED + metric: an install is safe to re-run by hand + } +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java index e1e90ec53f..a4d9549d9d 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java @@ -1,6 +1,6 @@ package com.openframe.data.nats.rmm.model; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Builder; @@ -21,7 +21,7 @@ public class ScriptExecutionAcknowledgeMessage { private String scheduleId; private List scriptIds; - // delivery kinds other than scripts ack through the same subject; null = legacy script ack - private DeliveryKind kind; + // delivery types other than scripts ack through the same subject; null = legacy script ack + private DeliveryType type; private String targetId; } diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java similarity index 68% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java rename to openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java index 40aeadf66c..7afd3ccf29 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/ClientUninstallDeliverySpecTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java @@ -1,12 +1,14 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.data.nats.delivery; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.nats.model.ClientUninstallMessage; import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; +import com.openframe.delivery.DeliveryRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -33,16 +35,33 @@ class ClientUninstallDeliverySpecTest { private MachineDelivery delivery; private Machine machine; + private ClientUninstallMessage message; @BeforeEach void setUp() { delivery = MachineDelivery.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) + .type(DeliveryType.CLIENT_UNINSTALL) .targetId(MACHINE_ID) .machineId(MACHINE_ID) .build(); machine = new Machine(); machine.setMachineId(MACHINE_ID); + message = new ClientUninstallMessage(); + } + + @Test + void request_machine_targetIsMachineAndPayloadBuiltByPublisher() { + // setup + when(publisher.buildMessage()).thenReturn(message); + + // execution + DeliveryRequest request = spec.request(MACHINE_ID); + + // verifications + assertThat(request.getType()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); + assertThat(request.getTargetId()).isEqualTo(MACHINE_ID); + assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); + assertThat(request.getPayload()).isSameAs(message); } @Test diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java new file mode 100644 index 0000000000..ea09a8bbfe --- /dev/null +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java @@ -0,0 +1,69 @@ +package com.openframe.data.nats.delivery; + +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.tool.IntegratedTool; +import com.openframe.data.document.toolagent.IntegratedToolAgent; +import com.openframe.data.nats.model.ToolInstallationMessage; +import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import com.openframe.delivery.DeliveryRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ToolInstallationDeliverySpecTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String TOOL_AGENT_KEY = "tactical-agent"; + + @Mock private ToolInstallationNatsPublisher publisher; + + @InjectMocks private ToolInstallationDeliverySpec spec; + + private IntegratedToolAgent toolAgent; + private IntegratedTool tool; + private ToolInstallationMessage message; + + @BeforeEach + void setUp() { + toolAgent = new IntegratedToolAgent(); + toolAgent.setKey(TOOL_AGENT_KEY); + tool = new IntegratedTool(); + message = new ToolInstallationMessage(); + message.setToolAgentId(TOOL_AGENT_KEY); + } + + @Test + void request_toolAgent_targetIsAgentKeyAndPayloadBuiltByPublisher() { + // setup + when(publisher.buildMessage(toolAgent, tool, true)).thenReturn(message); + + // execution + DeliveryRequest request = spec.request(MACHINE_ID, toolAgent, tool, true); + + // verifications + assertThat(request.getType()).isEqualTo(DeliveryType.TOOL_INSTALLATION); + assertThat(request.getTargetId()).isEqualTo(TOOL_AGENT_KEY); + assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); + assertThat(request.getPayload()).isSameAs(message); + assertThat(request.getSpec()).isSameAs(spec); + } + + @Test + void publish_payload_delegatedToPublisher() { + // setup + + // execution + spec.publish(MACHINE_ID, message); + + // verifications + verify(publisher).publish(MACHINE_ID, message); + } +} diff --git a/openframe-machine-delivery/pom.xml b/openframe-machine-delivery/pom.xml new file mode 100644 index 0000000000..5426ab31be --- /dev/null +++ b/openframe-machine-delivery/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + com.openframe.oss + openframe-oss-lib + ${revision} + + + openframe-machine-delivery + jar + OpenFrame Machine Delivery + Application-level delivery of commands to machines: per-type specs, delivery rows, sweep, watchdog, ack and completion tracking. Transport is supplied by the specs, the engine has no NATS dependency. + + + + com.openframe.oss + openframe-core + + + com.openframe.oss + openframe-data-mongo-sync + + + org.springframework.boot + spring-boot-starter-validation + + + net.javacrumbs.shedlock + shedlock-spring + 5.10.2 + + + io.micrometer + micrometer-core + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java new file mode 100644 index 0000000000..56024342a0 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java @@ -0,0 +1,6 @@ +package com.openframe.delivery; + +public interface DeliveryDispatch { + + void send(DeliveryRequest request); +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java similarity index 51% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java index 5680b28cbb..aef7e74682 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorder.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java @@ -1,12 +1,11 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.client.metrics.DeliveryMetrics; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; +import com.openframe.delivery.DeliveryProperties.Policy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -17,7 +16,7 @@ @Slf4j @Component @RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class DeliveryFailureRecorder { private final MachineDeliveryRepository repository; @@ -26,8 +25,8 @@ public class DeliveryFailureRecorder { private final DeliveryMetrics metrics; public void fail(MachineDelivery delivery, DeliveryFailure failure, Instant now) { - DeliveryKind kind = delivery.getKind(); - Policy policy = properties.resolve(kind); + DeliveryType type = delivery.getType(); + Policy policy = properties.resolve(type); long ttlSeconds = policy.getTtlSeconds(); delivery.setStatus(DeliveryStatus.FAILED); @@ -36,10 +35,10 @@ public void fail(MachineDelivery delivery, DeliveryFailure failure, Instant now) delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); repository.save(delivery); - metrics.recordFailed(kind, failure); - DeliverySpec spec = registry.require(kind); + metrics.recordFailed(type, failure); + DeliverySpec spec = registry.require(type); spec.onFailed(delivery, failure); - log.warn("Delivery FAILED: kind={} targetId={} machineId={} attempts={} reason={}", - kind, delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts(), failure); + log.warn("Delivery FAILED: type={} targetId={} machineId={} attempts={} reason={}", + type, delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts(), failure); } } diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java new file mode 100644 index 0000000000..a8ce1b7f72 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java @@ -0,0 +1,36 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryType; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Locale; + +@Component +@RequiredArgsConstructor +public class DeliveryMetrics { + + private static final String RETRIED_COUNTER = "openframe.delivery.retried"; + private static final String FAILED_COUNTER = "openframe.delivery.failed"; + private static final String TAG_TYPE = "type"; + private static final String TAG_REASON = "reason"; + + private final MeterRegistry meterRegistry; + + public void recordRetried(DeliveryType type) { + String typeTag = tagValue(type.name()); + meterRegistry.counter(RETRIED_COUNTER, TAG_TYPE, typeTag).increment(); + } + + public void recordFailed(DeliveryType type, DeliveryFailure failure) { + String typeTag = tagValue(type.name()); + String reasonTag = tagValue(failure.name()); + meterRegistry.counter(FAILED_COUNTER, TAG_TYPE, typeTag, TAG_REASON, reasonTag).increment(); + } + + private static String tagValue(String enumName) { + return enumName.toLowerCase(Locale.ROOT); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java similarity index 76% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java index a6b726fbe4..581be05aaf 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliveryProperties.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java @@ -1,7 +1,7 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; @@ -21,32 +21,32 @@ @Setter @Validated @Component -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") -@ConfigurationProperties(prefix = "openframe.rmm.delivery") +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") +@ConfigurationProperties(prefix = "openframe.delivery") public class DeliveryProperties { @Valid @NotNull private Policy defaults; - // deliberately not @Valid: a per-kind entry lists only the fields it overrides - private Map kinds = new EnumMap<>(DeliveryKind.class); + // deliberately not @Valid: a per-type entry lists only the fields it overrides + private Map types = new EnumMap<>(DeliveryType.class); - public Policy resolve(DeliveryKind kind) { - Policy override = kinds.get(kind); + public Policy resolve(DeliveryType type) { + Policy override = types.get(type); if (override == null) { return defaults; } return override.mergeOver(defaults); } - // row-level overrides come from ScheduleScript (per-schedule offline behaviour) and win over the kind policy + // row-level overrides come from ScheduleScript (per-schedule offline behaviour) and win over the type policy public Policy resolve(MachineDelivery delivery) { - Policy kindPolicy = resolve(delivery.getKind()); + Policy typePolicy = resolve(delivery.getType()); Policy rowOverride = new Policy(); rowOverride.setOfflineBehavior(delivery.getOfflineBehavior()); rowOverride.setReconnectWindowSeconds(delivery.getReconnectWindowSeconds()); - return rowOverride.mergeOver(kindPolicy); + return rowOverride.mergeOver(typePolicy); } @Getter diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java similarity index 54% rename from openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java index a8dcfc6728..fe8c6e99c3 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/DeliveryRequest.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java @@ -1,17 +1,21 @@ -package com.openframe.data.nats.delivery; +package com.openframe.delivery; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; import lombok.Builder; import lombok.Getter; @Getter @Builder -public class DeliveryRequest { - private final DeliveryKind kind; +public class DeliveryRequest

{ + private final DeliverySpec

spec; private final String targetId; private final String machineId; - private final Object payload; + private final P payload; private final ScheduleOfflineBehavior offlineBehavior; private final Long reconnectWindowSeconds; + + public DeliveryType getType() { + return spec.getType(); + } } diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java new file mode 100644 index 0000000000..ffa067c01c --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java @@ -0,0 +1,16 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; + +public interface DeliverySpec

{ + + DeliveryType getType(); + + Class

getPayloadClass(); + + void publish(String machineId, P payload); + + void onFailed(MachineDelivery delivery, DeliveryFailure failure); +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java similarity index 51% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java index da9423070b..6cc1527a5b 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistry.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java @@ -1,6 +1,6 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; @@ -16,27 +16,27 @@ @Component public class DeliverySpecRegistry { - private final Map> byKind; + private final Map> byType; // ObjectProvider, not List: a service with zero specs on the classpath must still boot. - // toUnmodifiableMap throws IllegalStateException on a duplicate kind — the wanted fail-fast. + // toUnmodifiableMap throws IllegalStateException on a duplicate type — the wanted fail-fast. public DeliverySpecRegistry(ObjectProvider> specs) { - this.byKind = specs.stream() - .collect(toUnmodifiableMap(DeliverySpec::getKind, identity())); - Set registered = byKind.keySet(); - Set sortedKinds = new TreeSet<>(registered); - log.info("Registered {} delivery spec(s): {}", byKind.size(), sortedKinds); + this.byType = specs.stream() + .collect(toUnmodifiableMap(DeliverySpec::getType, identity())); + Set registered = byType.keySet(); + Set sortedTypes = new TreeSet<>(registered); + log.info("Registered {} delivery spec(s): {}", byType.size(), sortedTypes); } - public DeliverySpec require(DeliveryKind kind) { - DeliverySpec spec = byKind.get(kind); + public DeliverySpec require(DeliveryType type) { + DeliverySpec spec = byType.get(type); if (spec == null) { - throw new IllegalArgumentException("No spec registered for delivery kind: " + kind.name()); + throw new IllegalArgumentException("No spec registered for delivery type: " + type.name()); } return spec; } - public Set kinds() { - return byKind.keySet(); + public Set types() { + return byType.keySet(); } } diff --git a/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java similarity index 67% rename from openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java index ed77b28ede..cb15f59e18 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/scheduler/DeliverySweepScheduler.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java @@ -1,7 +1,5 @@ -package com.openframe.client.scheduler; +package com.openframe.delivery; -import com.openframe.client.service.rmm.delivery.DeliverySweepService; -import com.openframe.client.service.rmm.delivery.DeliveryWatchdogService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; @@ -9,19 +7,20 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +// only the service that owns the sweep (client) turns this on; every publisher of deliveries gets the rest @Slf4j @Component @RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.delivery.sweep.enabled", havingValue = "true") public class DeliverySweepScheduler { private final DeliverySweepService sweepService; private final DeliveryWatchdogService watchdogService; - @Scheduled(fixedDelayString = "${openframe.rmm.delivery.sweep-interval}") + @Scheduled(fixedDelayString = "${openframe.delivery.sweep.interval}") @SchedulerLock(name = "deliverySweep", - lockAtMostFor = "${openframe.rmm.delivery.lock-at-most-for}", - lockAtLeastFor = "${openframe.rmm.delivery.lock-at-least-for}") + lockAtMostFor = "${openframe.delivery.sweep.lock-at-most-for}", + lockAtLeastFor = "${openframe.delivery.sweep.lock-at-least-for}") public void tick() { retryPending(); reapAcked(); diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java similarity index 65% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java index 05f877b7b9..6a0839bb3d 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/DeliverySweepService.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java @@ -1,18 +1,14 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.client.metrics.DeliveryMetrics; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; +import com.openframe.delivery.DeliveryProperties.Policy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -27,11 +23,11 @@ @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class DeliverySweepService { private final MachineDeliveryRepository repository; - private final MachineRepository machineRepository; + private final MachineOnlineStatus machineOnlineStatus; private final DeliverySpecRegistry registry; private final DeliveryProperties properties; private final DeliveryFailureRecorder failureRecorder; @@ -40,27 +36,22 @@ public class DeliverySweepService { public void retryPending() { Instant now = Instant.now(); - Set kinds = registry.kinds(); - kinds.forEach(kind -> retryPending(kind, now)); + Set types = registry.types(); + types.forEach(type -> retryPending(type, now)); } - private void retryPending(DeliveryKind kind, Instant now) { - Policy policy = properties.resolve(kind); + private void retryPending(DeliveryType type, Instant now) { + Policy policy = properties.resolve(type); Instant threshold = now.minusSeconds(policy.getAckThresholdSeconds()); - List unacked = repository.findByKindAndStatusAndLastAttemptAtBefore(kind, DeliveryStatus.PENDING, threshold); + List unacked = repository.findByTypeAndStatusAndLastAttemptAtBefore(type, DeliveryStatus.PENDING, threshold); if (unacked.isEmpty()) { return; } - Set offlineMachineIds = offlineMachineIds(unacked); + Set machineIds = unacked.stream().map(MachineDelivery::getMachineId).collect(toSet()); + Set offlineMachineIds = machineOnlineStatus.offline(machineIds); unacked.forEach(delivery -> retryOrFail(delivery, offlineMachineIds, now)); } - private Set offlineMachineIds(List deliveries) { - Set machineIds = deliveries.stream().map(MachineDelivery::getMachineId).collect(toSet()); - List offline = machineRepository.findByMachineIdInAndStatus(machineIds, DeviceStatus.OFFLINE); - return offline.stream().map(Machine::getMachineId).collect(toSet()); - } - private void retryOrFail(MachineDelivery delivery, Set offlineMachineIds, Instant now) { Policy policy = properties.resolve(delivery); if (isOffline(delivery, offlineMachineIds)) { @@ -83,22 +74,22 @@ private void waitOrFailOffline(MachineDelivery delivery, Policy policy, Instant failureRecorder.fail(delivery, DeliveryFailure.OFFLINE, now); return; } - log.debug("Delivery waits for machine to come online: kind={} targetId={} machineId={}", - delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); + log.debug("Delivery waits for machine to come online: type={} targetId={} machineId={}", + delivery.getType(), delivery.getTargetId(), delivery.getMachineId()); } private void republish(MachineDelivery delivery, Instant now) { - DeliveryKind kind = delivery.getKind(); - DeliverySpec spec = registry.require(kind); + DeliveryType type = delivery.getType(); + DeliverySpec spec = registry.require(type); publish(spec, delivery); int attempt = delivery.getAttempts() + 1; delivery.setAttempts(attempt); delivery.setLastAttemptAt(now); repository.save(delivery); - metrics.recordRetried(kind); - log.info("Delivery re-published: kind={} targetId={} machineId={} attempt={}", - kind, delivery.getTargetId(), delivery.getMachineId(), attempt); + metrics.recordRetried(type); + log.info("Delivery re-published: type={} targetId={} machineId={} attempt={}", + type, delivery.getTargetId(), delivery.getMachineId(), attempt); } private

void publish(DeliverySpec

spec, MachineDelivery delivery) { diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryTracker.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryTracker.java new file mode 100644 index 0000000000..46bfda1ab9 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryTracker.java @@ -0,0 +1,10 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; + +public interface DeliveryTracker { + + void acknowledge(DeliveryType type, String targetId, String machineId); + + void complete(DeliveryType type, String targetId, String machineId); +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryWatchdogService.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryWatchdogService.java new file mode 100644 index 0000000000..23b258a478 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryWatchdogService.java @@ -0,0 +1,39 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; +import com.openframe.delivery.DeliveryProperties.Policy; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") +public class DeliveryWatchdogService { + + private final MachineDeliveryRepository repository; + private final DeliverySpecRegistry registry; + private final DeliveryProperties properties; + private final DeliveryFailureRecorder failureRecorder; + + public void reapAcked() { + Instant now = Instant.now(); + Set types = registry.types(); + types.forEach(type -> reapAcked(type, now)); + } + + private void reapAcked(DeliveryType type, Instant now) { + Policy policy = properties.resolve(type); + Instant threshold = now.minusSeconds(policy.getResultTimeoutSeconds()); + List silent = repository.findByTypeAndStatusAndAckedAtBefore(type, DeliveryStatus.ACKED, threshold); + silent.forEach(delivery -> failureRecorder.fail(delivery, DeliveryFailure.TIMEOUT, now)); + } +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java new file mode 100644 index 0000000000..2a5238174c --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java @@ -0,0 +1,25 @@ +package com.openframe.delivery; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.repository.device.MachineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +import static java.util.stream.Collectors.toSet; + +// the only place the engine reads the Machine document; heartbeat-driven status today +@Component +@RequiredArgsConstructor +public class MachineOnlineStatus { + + private final MachineRepository machineRepository; + + public Set offline(Set machineIds) { + List offline = machineRepository.findByMachineIdInAndStatus(machineIds, DeviceStatus.OFFLINE); + return offline.stream().map(Machine::getMachineId).collect(toSet()); + } +} diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java similarity index 62% rename from openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java index adab66afa5..38a433f4e9 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTracker.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java @@ -1,10 +1,10 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; +import com.openframe.delivery.DeliveryProperties.Policy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,23 +15,23 @@ @Slf4j @Service @RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class MongoDeliveryTracker implements DeliveryTracker { private final MachineDeliveryRepository repository; private final DeliveryProperties properties; @Override - public void acknowledge(DeliveryKind kind, String targetId, String machineId) { - String id = MachineDelivery.id(kind, targetId, machineId); + public void acknowledge(DeliveryType type, String targetId, String machineId) { + String id = MachineDelivery.id(type, targetId, machineId); repository.findById(id) .filter(MongoDeliveryTracker::isPending) .ifPresent(this::markAcked); } @Override - public void complete(DeliveryKind kind, String targetId, String machineId) { - String id = MachineDelivery.id(kind, targetId, machineId); + public void complete(DeliveryType type, String targetId, String machineId) { + String id = MachineDelivery.id(type, targetId, machineId); repository.findById(id) .filter(MongoDeliveryTracker::isOpen) .ifPresent(this::markDone); @@ -42,20 +42,20 @@ private void markAcked(MachineDelivery delivery) { delivery.setStatus(DeliveryStatus.ACKED); delivery.setAckedAt(now); repository.save(delivery); - log.info("Delivery ACKED: kind={} targetId={} machineId={}", - delivery.getKind(), delivery.getTargetId(), delivery.getMachineId()); + log.info("Delivery ACKED: type={} targetId={} machineId={}", + delivery.getType(), delivery.getTargetId(), delivery.getMachineId()); } private void markDone(MachineDelivery delivery) { Instant now = Instant.now(); - Policy policy = properties.resolve(delivery.getKind()); + Policy policy = properties.resolve(delivery.getType()); long ttlSeconds = policy.getTtlSeconds(); delivery.setStatus(DeliveryStatus.DONE); delivery.setFinishedAt(now); delivery.setExpiresAt(now.plusSeconds(ttlSeconds)); repository.save(delivery); - log.info("Delivery DONE: kind={} targetId={} machineId={} attempts={}", - delivery.getKind(), delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts()); + log.info("Delivery DONE: type={} targetId={} machineId={} attempts={}", + delivery.getType(), delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts()); } private static boolean isPending(MachineDelivery delivery) { diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryTracker.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryTracker.java new file mode 100644 index 0000000000..7cc2b96429 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryTracker.java @@ -0,0 +1,18 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "false", matchIfMissing = true) +public class NoopDeliveryTracker implements DeliveryTracker { + + @Override + public void acknowledge(DeliveryType type, String targetId, String machineId) { + } + + @Override + public void complete(DeliveryType type, String targetId, String machineId) { + } +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java new file mode 100644 index 0000000000..af6f86a383 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java @@ -0,0 +1,20 @@ +package com.openframe.delivery; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +// flag off = today's behaviour: publish without a row, so callers keep a single code path +@Component +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "false", matchIfMissing = true) +public class PublishOnlyDeliveryDispatch implements DeliveryDispatch { + + @Override + public void send(DeliveryRequest request) { + publish(request); + } + + private

void publish(DeliveryRequest

request) { + DeliverySpec

spec = request.getSpec(); + spec.publish(request.getMachineId(), request.getPayload()); + } +} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java similarity index 66% rename from openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java index 15c7a7ae23..8d07c8bc76 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatch.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java @@ -1,10 +1,10 @@ -package com.openframe.data.nats.delivery; +package com.openframe.delivery; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,28 +15,33 @@ @Slf4j @Component @RequiredArgsConstructor -@ConditionalOnProperty(name = "openframe.rmm.delivery.enabled", havingValue = "true") +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class RecordingDeliveryDispatch implements DeliveryDispatch { private final MachineDeliveryRepository repository; private final ObjectMapper objectMapper; @Override - public void send(DeliveryRequest request, Runnable publish) { + public void send(DeliveryRequest request) { MachineDelivery delivery = pendingRow(request); repository.save(delivery); - publish.run(); - log.info("Delivery dispatched: kind={} targetId={} machineId={}", - request.getKind(), request.getTargetId(), request.getMachineId()); + publish(request); + log.info("Delivery dispatched: type={} targetId={} machineId={}", + request.getType(), request.getTargetId(), request.getMachineId()); } - private MachineDelivery pendingRow(DeliveryRequest request) { + private

void publish(DeliveryRequest

request) { + DeliverySpec

spec = request.getSpec(); + spec.publish(request.getMachineId(), request.getPayload()); + } + + private MachineDelivery pendingRow(DeliveryRequest request) { Instant now = Instant.now(); - String id = MachineDelivery.id(request.getKind(), request.getTargetId(), request.getMachineId()); + String id = MachineDelivery.id(request.getType(), request.getTargetId(), request.getMachineId()); String payloadJson = toJson(request.getPayload()); return MachineDelivery.builder() .id(id) - .kind(request.getKind()) + .type(request.getType()) .targetId(request.getTargetId()) .machineId(request.getMachineId()) .status(DeliveryStatus.PENDING) diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java similarity index 53% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java index fdab397bd3..2d223fc2e5 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryFailureRecorderTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java @@ -1,14 +1,10 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.client.metrics.DeliveryMetrics; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,20 +13,18 @@ import java.time.Instant; +import static com.openframe.delivery.DeliveryTestPolicies.TTL; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DeliveryFailureRecorderTest { - private static final long TTL = 604_800L; - @Mock private MachineDeliveryRepository repository; @Mock private DeliverySpecRegistry registry; @Mock private DeliveryMetrics metrics; - @Mock private DeliverySpec spec; + @Mock private DeliverySpec spec; private DeliveryFailureRecorder recorder; @@ -38,22 +32,12 @@ class DeliveryFailureRecorderTest { @BeforeEach void setUp() { - Policy defaults = new Policy(); - defaults.setAckThresholdSeconds(30L); - defaults.setMaxAttempts(3); - defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - defaults.setReconnectWindowSeconds(86_400L); - defaults.setResultTimeoutSeconds(600L); - defaults.setTtlSeconds(TTL); - DeliveryProperties properties = new DeliveryProperties(); - properties.setDefaults(defaults); - delivery = MachineDelivery.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) + .type(DeliveryType.CLIENT_UNINSTALL) .status(DeliveryStatus.PENDING) .attempts(5) .build(); - + DeliveryProperties properties = DeliveryTestPolicies.properties(); recorder = new DeliveryFailureRecorder(repository, registry, properties, metrics); } @@ -61,7 +45,7 @@ void setUp() { void fail_exhausted_rowFailedMetricCountedSpecNotified() { // setup Instant now = Instant.now(); - doReturn(spec).when(registry).require(DeliveryKind.CLIENT_UNINSTALL); + doReturn(spec).when(registry).require(DeliveryType.CLIENT_UNINSTALL); // execution recorder.fail(delivery, DeliveryFailure.EXHAUSTED, now); @@ -72,7 +56,7 @@ void fail_exhausted_rowFailedMetricCountedSpecNotified() { assertThat(delivery.getFinishedAt()).isEqualTo(now); assertThat(delivery.getExpiresAt()).isEqualTo(now.plusSeconds(TTL)); verify(repository).save(delivery); - verify(metrics).recordFailed(DeliveryKind.CLIENT_UNINSTALL, DeliveryFailure.EXHAUSTED); + verify(metrics).recordFailed(DeliveryType.CLIENT_UNINSTALL, DeliveryFailure.EXHAUSTED); verify(spec).onFailed(delivery, DeliveryFailure.EXHAUSTED); } } diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryPropertiesTest.java similarity index 55% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryPropertiesTest.java index 1205f08d5e..0703955da4 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryPropertiesTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryPropertiesTest.java @@ -1,23 +1,23 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.delivery.DeliveryProperties.Policy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.Map; +import static com.openframe.delivery.DeliveryTestPolicies.ACK_THRESHOLD; +import static com.openframe.delivery.DeliveryTestPolicies.MAX_ATTEMPTS; +import static com.openframe.delivery.DeliveryTestPolicies.RECONNECT_WINDOW; +import static com.openframe.delivery.DeliveryTestPolicies.RESULT_TIMEOUT; +import static com.openframe.delivery.DeliveryTestPolicies.TTL; import static org.assertj.core.api.Assertions.assertThat; class DeliveryPropertiesTest { - private static final long DEFAULT_ACK_THRESHOLD = 30L; - private static final int DEFAULT_MAX_ATTEMPTS = 3; - private static final long DEFAULT_RECONNECT_WINDOW = 86_400L; - private static final long DEFAULT_RESULT_TIMEOUT = 600L; - private static final long DEFAULT_TTL = 604_800L; private static final int UNINSTALL_MAX_ATTEMPTS = 5; private static final long ROW_RECONNECT_WINDOW = 3_600L; @@ -25,69 +25,61 @@ class DeliveryPropertiesTest { @BeforeEach void setUp() { - Policy defaults = new Policy(); - defaults.setAckThresholdSeconds(DEFAULT_ACK_THRESHOLD); - defaults.setMaxAttempts(DEFAULT_MAX_ATTEMPTS); - defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - defaults.setReconnectWindowSeconds(DEFAULT_RECONNECT_WINDOW); - defaults.setResultTimeoutSeconds(DEFAULT_RESULT_TIMEOUT); - defaults.setTtlSeconds(DEFAULT_TTL); - properties = new DeliveryProperties(); - properties.setDefaults(defaults); + properties = DeliveryTestPolicies.properties(); } @Test - void resolve_kindWithoutOverride_defaultsReturned() { + void resolve_typeWithoutOverride_defaultsReturned() { // setup Policy defaults = properties.getDefaults(); // execution - Policy resolved = properties.resolve(DeliveryKind.TOOL_INSTALLATION); + Policy resolved = properties.resolve(DeliveryType.TOOL_INSTALLATION); // verifications assertThat(resolved).isSameAs(defaults); } @Test - void resolve_kindOverridesOneField_otherFieldsFromDefaults() { + void resolve_typeOverridesOneField_otherFieldsFromDefaults() { // setup Policy uninstall = new Policy(); uninstall.setMaxAttempts(UNINSTALL_MAX_ATTEMPTS); - properties.setKinds(Map.of(DeliveryKind.CLIENT_UNINSTALL, uninstall)); + properties.setTypes(Map.of(DeliveryType.CLIENT_UNINSTALL, uninstall)); // execution - Policy resolved = properties.resolve(DeliveryKind.CLIENT_UNINSTALL); + Policy resolved = properties.resolve(DeliveryType.CLIENT_UNINSTALL); // verifications assertThat(resolved.getMaxAttempts()).isEqualTo(UNINSTALL_MAX_ATTEMPTS); - assertThat(resolved.getAckThresholdSeconds()).isEqualTo(DEFAULT_ACK_THRESHOLD); + assertThat(resolved.getAckThresholdSeconds()).isEqualTo(ACK_THRESHOLD); assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(DEFAULT_RECONNECT_WINDOW); - assertThat(resolved.getResultTimeoutSeconds()).isEqualTo(DEFAULT_RESULT_TIMEOUT); - assertThat(resolved.getTtlSeconds()).isEqualTo(DEFAULT_TTL); + assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(RECONNECT_WINDOW); + assertThat(resolved.getResultTimeoutSeconds()).isEqualTo(RESULT_TIMEOUT); + assertThat(resolved.getTtlSeconds()).isEqualTo(TTL); } @Test - void resolve_kindOverridesOfflineBehavior_overrideWins() { + void resolve_typeOverridesOfflineBehavior_overrideWins() { // setup Policy scripts = new Policy(); scripts.setOfflineBehavior(ScheduleOfflineBehavior.SKIP); - properties.setKinds(Map.of(DeliveryKind.SCRIPT_SCHEDULE, scripts)); + properties.setTypes(Map.of(DeliveryType.SCRIPT_SCHEDULE, scripts)); // execution - Policy resolved = properties.resolve(DeliveryKind.SCRIPT_SCHEDULE); + Policy resolved = properties.resolve(DeliveryType.SCRIPT_SCHEDULE); // verifications assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.SKIP); } @Test - void resolve_rowWithoutOverrides_kindPolicyReturned() { + void resolve_rowWithoutOverrides_typePolicyReturned() { // setup Policy uninstall = new Policy(); uninstall.setMaxAttempts(UNINSTALL_MAX_ATTEMPTS); - properties.setKinds(Map.of(DeliveryKind.CLIENT_UNINSTALL, uninstall)); - MachineDelivery delivery = MachineDelivery.builder().kind(DeliveryKind.CLIENT_UNINSTALL).build(); + properties.setTypes(Map.of(DeliveryType.CLIENT_UNINSTALL, uninstall)); + MachineDelivery delivery = MachineDelivery.builder().type(DeliveryType.CLIENT_UNINSTALL).build(); // execution Policy resolved = properties.resolve(delivery); @@ -95,17 +87,17 @@ void resolve_rowWithoutOverrides_kindPolicyReturned() { // verifications assertThat(resolved.getMaxAttempts()).isEqualTo(UNINSTALL_MAX_ATTEMPTS); assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(DEFAULT_RECONNECT_WINDOW); + assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(RECONNECT_WINDOW); } @Test - void resolve_rowOverridesOfflineFields_rowWinsOverKind() { + void resolve_rowOverridesOfflineFields_rowWinsOverType() { // setup Policy scripts = new Policy(); scripts.setOfflineBehavior(ScheduleOfflineBehavior.SKIP); - properties.setKinds(Map.of(DeliveryKind.SCRIPT_SCHEDULE, scripts)); + properties.setTypes(Map.of(DeliveryType.SCRIPT_SCHEDULE, scripts)); MachineDelivery delivery = MachineDelivery.builder() - .kind(DeliveryKind.SCRIPT_SCHEDULE) + .type(DeliveryType.SCRIPT_SCHEDULE) .offlineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT) .reconnectWindowSeconds(ROW_RECONNECT_WINDOW) .build(); @@ -116,6 +108,6 @@ void resolve_rowOverridesOfflineFields_rowWinsOverKind() { // verifications assertThat(resolved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); assertThat(resolved.getReconnectWindowSeconds()).isEqualTo(ROW_RECONNECT_WINDOW); - assertThat(resolved.getMaxAttempts()).isEqualTo(DEFAULT_MAX_ATTEMPTS); + assertThat(resolved.getMaxAttempts()).isEqualTo(MAX_ATTEMPTS); } } diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java similarity index 66% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java index 5e408b5d5a..3b4ad04914 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySpecRegistryTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java @@ -1,6 +1,6 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.data.document.rmm.delivery.DeliveryKind; +import com.openframe.data.document.delivery.DeliveryType; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; @@ -14,36 +14,36 @@ class DeliverySpecRegistryTest { @Test - void require_registeredKind_specReturned() { + void require_registeredType_specReturned() { // setup - DeliverySpec spec = spec(DeliveryKind.TOOL_INSTALLATION); + DeliverySpec spec = spec(DeliveryType.TOOL_INSTALLATION); ObjectProvider> specs = provider(spec); DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); // execution - DeliverySpec resolved = registry.require(DeliveryKind.TOOL_INSTALLATION); + DeliverySpec resolved = registry.require(DeliveryType.TOOL_INSTALLATION); // verifications assertThat(resolved).isSameAs(spec); } @Test - void require_unregisteredKind_throwsIllegalArgument() { + void require_unregisteredType_throwsIllegalArgument() { // setup ObjectProvider> specs = provider(); DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); // execution + verifications - assertThatThrownBy(() -> registry.require(DeliveryKind.CLIENT_UNINSTALL)) + assertThatThrownBy(() -> registry.require(DeliveryType.CLIENT_UNINSTALL)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("CLIENT_UNINSTALL"); } @Test - void constructor_duplicateKind_throwsIllegalState() { + void constructor_duplicateType_throwsIllegalState() { // setup - DeliverySpec first = spec(DeliveryKind.TOOL_INSTALLATION); - DeliverySpec second = spec(DeliveryKind.TOOL_INSTALLATION); + DeliverySpec first = spec(DeliveryType.TOOL_INSTALLATION); + DeliverySpec second = spec(DeliveryType.TOOL_INSTALLATION); ObjectProvider> duplicates = provider(first, second); // execution + verifications @@ -53,17 +53,17 @@ void constructor_duplicateKind_throwsIllegalState() { } @Test - void kinds_twoSpecs_bothKindsListed() { + void types_twoSpecs_bothTypesListed() { // setup - DeliverySpec install = spec(DeliveryKind.TOOL_INSTALLATION); - DeliverySpec uninstall = spec(DeliveryKind.CLIENT_UNINSTALL); + DeliverySpec install = spec(DeliveryType.TOOL_INSTALLATION); + DeliverySpec uninstall = spec(DeliveryType.CLIENT_UNINSTALL); ObjectProvider> specs = provider(install, uninstall); // execution DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); // verifications - assertThat(registry.kinds()).containsExactlyInAnyOrder(DeliveryKind.TOOL_INSTALLATION, DeliveryKind.CLIENT_UNINSTALL); + assertThat(registry.types()).containsExactlyInAnyOrder(DeliveryType.TOOL_INSTALLATION, DeliveryType.CLIENT_UNINSTALL); } @SuppressWarnings("unchecked") @@ -73,9 +73,9 @@ private static ObjectProvider> provider(DeliverySpec... specs return provider; } - private static DeliverySpec spec(DeliveryKind kind) { + private static DeliverySpec spec(DeliveryType type) { DeliverySpec spec = mock(DeliverySpec.class); - when(spec.getKind()).thenReturn(kind); + when(spec.getType()).thenReturn(type); return spec; } } diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java similarity index 57% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java index 56634b3ae1..451416d1c0 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliverySweepServiceTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java @@ -1,18 +1,12 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.client.metrics.DeliveryMetrics; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,6 +19,8 @@ import java.util.List; import java.util.Set; +import static com.openframe.delivery.DeliveryTestPolicies.ACK_THRESHOLD; +import static com.openframe.delivery.DeliveryTestPolicies.MAX_ATTEMPTS; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -38,47 +34,30 @@ class DeliverySweepServiceTest { private static final String MACHINE_ID = "mach-42"; - private static final String TOOL_AGENT_ID = "tactical-agent"; - private static final String PAYLOAD_JSON = "{\"toolAgentId\":\"tactical-agent\"}"; - private static final long ACK_THRESHOLD = 30L; - private static final int MAX_ATTEMPTS = 3; - private static final long RECONNECT_WINDOW = 86_400L; - private static final long RESULT_TIMEOUT = 600L; - private static final long TTL = 604_800L; + private static final String TARGET_ID = "tactical-agent"; + private static final String PAYLOAD_JSON = "{\"value\":\"tactical-agent\"}"; private static final long TWO_DAYS_SECONDS = 172_800L; @Mock private MachineDeliveryRepository repository; - @Mock private MachineRepository machineRepository; + @Mock private MachineOnlineStatus machineOnlineStatus; @Mock private DeliverySpecRegistry registry; @Mock private DeliveryFailureRecorder failureRecorder; @Mock private DeliveryMetrics metrics; - @Mock private DeliverySpec spec; + @Mock private DeliverySpec spec; - @Captor private ArgumentCaptor payloadCaptor; + @Captor private ArgumentCaptor payloadCaptor; private DeliverySweepService service; - private DeliveryProperties properties; private MachineDelivery delivery; - private Machine offlineMachine; @BeforeEach void setUp() { - Policy defaults = new Policy(); - defaults.setAckThresholdSeconds(ACK_THRESHOLD); - defaults.setMaxAttempts(MAX_ATTEMPTS); - defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - defaults.setReconnectWindowSeconds(RECONNECT_WINDOW); - defaults.setResultTimeoutSeconds(RESULT_TIMEOUT); - defaults.setTtlSeconds(TTL); - properties = new DeliveryProperties(); - properties.setDefaults(defaults); - Instant dispatchedAt = Instant.now().minusSeconds(ACK_THRESHOLD * 2); delivery = MachineDelivery.builder() - .id(MachineDelivery.id(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID)) - .kind(DeliveryKind.TOOL_INSTALLATION) - .targetId(TOOL_AGENT_ID) + .id(MachineDelivery.id(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID)) + .type(DeliveryType.TOOL_INSTALLATION) + .targetId(TARGET_ID) .machineId(MACHINE_ID) .status(DeliveryStatus.PENDING) .attempts(0) @@ -86,12 +65,8 @@ void setUp() { .dispatchedAt(dispatchedAt) .lastAttemptAt(dispatchedAt) .build(); - - offlineMachine = new Machine(); - offlineMachine.setMachineId(MACHINE_ID); - offlineMachine.setStatus(DeviceStatus.OFFLINE); - - service = new DeliverySweepService(repository, machineRepository, registry, properties, failureRecorder, metrics, new ObjectMapper()); + DeliveryProperties properties = DeliveryTestPolicies.properties(); + service = new DeliverySweepService(repository, machineOnlineStatus, registry, properties, failureRecorder, metrics, new ObjectMapper()); } @Test @@ -99,19 +74,19 @@ void retryPending_onlineWithAttemptsLeft_republishedAndAttemptCounted() { // setup stubOneStuckDelivery(); stubMachineOnline(); - doReturn(spec).when(registry).require(DeliveryKind.TOOL_INSTALLATION); - when(spec.getPayloadClass()).thenReturn(ToolInstallationMessage.class); + doReturn(spec).when(registry).require(DeliveryType.TOOL_INSTALLATION); + when(spec.getPayloadClass()).thenReturn(TestPayload.class); // execution service.retryPending(); // verifications verify(spec).publish(eq(MACHINE_ID), payloadCaptor.capture()); - assertThat(payloadCaptor.getValue().getToolAgentId()).isEqualTo(TOOL_AGENT_ID); + assertThat(payloadCaptor.getValue().getValue()).isEqualTo(TARGET_ID); assertThat(delivery.getAttempts()).isEqualTo(1); assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.PENDING); verify(repository).save(delivery); - verify(metrics).recordRetried(DeliveryKind.TOOL_INSTALLATION); + verify(metrics).recordRetried(DeliveryType.TOOL_INSTALLATION); verifyNoInteractions(failureRecorder); } @@ -177,31 +152,31 @@ void retryPending_offlineWithRowSkipOverride_failedOfflineImmediately() { } @Test - void retryPending_nothingStuck_machinesNotQueried() { + void retryPending_nothingStuck_onlineStatusNotQueried() { // setup - when(registry.kinds()).thenReturn(Set.of(DeliveryKind.TOOL_INSTALLATION)); - when(repository.findByKindAndStatusAndLastAttemptAtBefore(eq(DeliveryKind.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) + when(registry.types()).thenReturn(Set.of(DeliveryType.TOOL_INSTALLATION)); + when(repository.findByTypeAndStatusAndLastAttemptAtBefore(eq(DeliveryType.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) .thenReturn(List.of()); // execution service.retryPending(); // verifications - verifyNoInteractions(machineRepository); + verifyNoInteractions(machineOnlineStatus); verifyNoInteractions(failureRecorder); } private void stubOneStuckDelivery() { - when(registry.kinds()).thenReturn(Set.of(DeliveryKind.TOOL_INSTALLATION)); - when(repository.findByKindAndStatusAndLastAttemptAtBefore(eq(DeliveryKind.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) + when(registry.types()).thenReturn(Set.of(DeliveryType.TOOL_INSTALLATION)); + when(repository.findByTypeAndStatusAndLastAttemptAtBefore(eq(DeliveryType.TOOL_INSTALLATION), eq(DeliveryStatus.PENDING), any(Instant.class))) .thenReturn(List.of(delivery)); } private void stubMachineOnline() { - when(machineRepository.findByMachineIdInAndStatus(Set.of(MACHINE_ID), DeviceStatus.OFFLINE)).thenReturn(List.of()); + when(machineOnlineStatus.offline(Set.of(MACHINE_ID))).thenReturn(Set.of()); } private void stubMachineOffline() { - when(machineRepository.findByMachineIdInAndStatus(Set.of(MACHINE_ID), DeviceStatus.OFFLINE)).thenReturn(List.of(offlineMachine)); + when(machineOnlineStatus.offline(Set.of(MACHINE_ID))).thenReturn(Set.of(MACHINE_ID)); } } diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryTestPolicies.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryTestPolicies.java new file mode 100644 index 0000000000..b7c3a1867b --- /dev/null +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryTestPolicies.java @@ -0,0 +1,29 @@ +package com.openframe.delivery; + +import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; +import com.openframe.delivery.DeliveryProperties.Policy; + +final class DeliveryTestPolicies { + + static final long ACK_THRESHOLD = 30L; + static final int MAX_ATTEMPTS = 3; + static final long RECONNECT_WINDOW = 86_400L; + static final long RESULT_TIMEOUT = 600L; + static final long TTL = 604_800L; + + private DeliveryTestPolicies() { + } + + static DeliveryProperties properties() { + Policy defaults = new Policy(); + defaults.setAckThresholdSeconds(ACK_THRESHOLD); + defaults.setMaxAttempts(MAX_ATTEMPTS); + defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); + defaults.setReconnectWindowSeconds(RECONNECT_WINDOW); + defaults.setResultTimeoutSeconds(RESULT_TIMEOUT); + defaults.setTtlSeconds(TTL); + DeliveryProperties properties = new DeliveryProperties(); + properties.setDefaults(defaults); + return properties; + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryWatchdogServiceTest.java similarity index 55% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryWatchdogServiceTest.java index d77cdb9410..dd2ed28d96 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/DeliveryWatchdogServiceTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryWatchdogServiceTest.java @@ -1,12 +1,10 @@ -package com.openframe.client.service.rmm.delivery; +package com.openframe.delivery; -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryFailure; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.document.delivery.DeliveryFailure; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +15,7 @@ import java.util.List; import java.util.Set; +import static com.openframe.delivery.DeliveryTestPolicies.RESULT_TIMEOUT; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -26,8 +25,6 @@ @ExtendWith(MockitoExtension.class) class DeliveryWatchdogServiceTest { - private static final long RESULT_TIMEOUT = 600L; - @Mock private MachineDeliveryRepository repository; @Mock private DeliverySpecRegistry registry; @Mock private DeliveryFailureRecorder failureRecorder; @@ -38,30 +35,20 @@ class DeliveryWatchdogServiceTest { @BeforeEach void setUp() { - Policy defaults = new Policy(); - defaults.setAckThresholdSeconds(30L); - defaults.setMaxAttempts(3); - defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - defaults.setReconnectWindowSeconds(86_400L); - defaults.setResultTimeoutSeconds(RESULT_TIMEOUT); - defaults.setTtlSeconds(604_800L); - DeliveryProperties properties = new DeliveryProperties(); - properties.setDefaults(defaults); - silentDelivery = MachineDelivery.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) + .type(DeliveryType.CLIENT_UNINSTALL) .status(DeliveryStatus.ACKED) .ackedAt(Instant.now().minusSeconds(RESULT_TIMEOUT * 2)) .build(); - + DeliveryProperties properties = DeliveryTestPolicies.properties(); service = new DeliveryWatchdogService(repository, registry, properties, failureRecorder); } @Test void reapAcked_ackedOlderThanResultTimeout_failedTimeout() { // setup - when(registry.kinds()).thenReturn(Set.of(DeliveryKind.CLIENT_UNINSTALL)); - when(repository.findByKindAndStatusAndAckedAtBefore(eq(DeliveryKind.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) + when(registry.types()).thenReturn(Set.of(DeliveryType.CLIENT_UNINSTALL)); + when(repository.findByTypeAndStatusAndAckedAtBefore(eq(DeliveryType.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) .thenReturn(List.of(silentDelivery)); // execution @@ -74,8 +61,8 @@ void reapAcked_ackedOlderThanResultTimeout_failedTimeout() { @Test void reapAcked_nothingSilent_noFailure() { // setup - when(registry.kinds()).thenReturn(Set.of(DeliveryKind.CLIENT_UNINSTALL)); - when(repository.findByKindAndStatusAndAckedAtBefore(eq(DeliveryKind.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) + when(registry.types()).thenReturn(Set.of(DeliveryType.CLIENT_UNINSTALL)); + when(repository.findByTypeAndStatusAndAckedAtBefore(eq(DeliveryType.CLIENT_UNINSTALL), eq(DeliveryStatus.ACKED), any(Instant.class))) .thenReturn(List.of()); // execution diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/MachineOnlineStatusTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MachineOnlineStatusTest.java new file mode 100644 index 0000000000..c0a6e6e210 --- /dev/null +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MachineOnlineStatusTest.java @@ -0,0 +1,42 @@ +package com.openframe.delivery; + +import com.openframe.data.document.device.DeviceStatus; +import com.openframe.data.document.device.Machine; +import com.openframe.data.repository.device.MachineRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MachineOnlineStatusTest { + + private static final String ONLINE_ID = "mach-1"; + private static final String OFFLINE_ID = "mach-2"; + + @Mock private MachineRepository machineRepository; + + @InjectMocks private MachineOnlineStatus status; + + @Test + void offline_mixedMachines_onlyOfflineIdsReturned() { + // setup + Machine offline = new Machine(); + offline.setMachineId(OFFLINE_ID); + Set asked = Set.of(ONLINE_ID, OFFLINE_ID); + when(machineRepository.findByMachineIdInAndStatus(asked, DeviceStatus.OFFLINE)).thenReturn(List.of(offline)); + + // execution + Set result = status.offline(asked); + + // verifications + assertThat(result).containsExactly(OFFLINE_ID); + } +} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java similarity index 62% rename from openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java index 73d2d19c94..1091ebd60e 100644 --- a/openframe-client-core/src/test/java/com/openframe/client/service/rmm/delivery/MongoDeliveryTrackerTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java @@ -1,11 +1,9 @@ -package com.openframe.client.service.rmm.delivery; - -import com.openframe.client.service.rmm.delivery.DeliveryProperties.Policy; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; -import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,6 +12,7 @@ import java.util.Optional; +import static com.openframe.delivery.DeliveryTestPolicies.TTL; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -23,9 +22,8 @@ class MongoDeliveryTrackerTest { private static final String MACHINE_ID = "mach-42"; - private static final String TOOL_AGENT_ID = "tactical-agent"; - private static final String DELIVERY_ID = MachineDelivery.id(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); - private static final long TTL = 604_800L; + private static final String TARGET_ID = "tactical-agent"; + private static final String DELIVERY_ID = MachineDelivery.id(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); @Mock private MachineDeliveryRepository repository; @@ -35,25 +33,14 @@ class MongoDeliveryTrackerTest { @BeforeEach void setUp() { - Policy defaults = new Policy(); - defaults.setAckThresholdSeconds(30L); - defaults.setMaxAttempts(3); - defaults.setOfflineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); - defaults.setReconnectWindowSeconds(86_400L); - defaults.setResultTimeoutSeconds(600L); - defaults.setTtlSeconds(TTL); - DeliveryProperties properties = new DeliveryProperties(); - properties.setDefaults(defaults); - delivery = MachineDelivery.builder() .id(DELIVERY_ID) - .kind(DeliveryKind.TOOL_INSTALLATION) - .targetId(TOOL_AGENT_ID) + .type(DeliveryType.TOOL_INSTALLATION) + .targetId(TARGET_ID) .machineId(MACHINE_ID) .status(DeliveryStatus.PENDING) .build(); - - tracker = new MongoDeliveryTracker(repository, properties); + tracker = new MongoDeliveryTracker(repository, DeliveryTestPolicies.properties()); } @Test @@ -62,7 +49,7 @@ void acknowledge_pendingRow_ackedWithTimestamp() { when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); // execution - tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + tracker.acknowledge(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); // verifications assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.ACKED); @@ -77,7 +64,7 @@ void acknowledge_alreadyAcked_untouched() { when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); // execution - tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + tracker.acknowledge(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); // verifications verify(repository, never()).save(delivery); @@ -89,7 +76,7 @@ void acknowledge_unknownRow_noop() { when(repository.findById(DELIVERY_ID)).thenReturn(Optional.empty()); // execution - tracker.acknowledge(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + tracker.acknowledge(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); // verifications verify(repository, never()).save(delivery); @@ -102,7 +89,7 @@ void complete_ackedRow_doneWithExpiry() { when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); // execution - tracker.complete(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + tracker.complete(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); // verifications assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.DONE); @@ -118,7 +105,7 @@ void complete_failedRow_untouched() { when(repository.findById(DELIVERY_ID)).thenReturn(Optional.of(delivery)); // execution - tracker.complete(DeliveryKind.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); + tracker.complete(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); // verifications assertThat(delivery.getStatus()).isEqualTo(DeliveryStatus.FAILED); diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java similarity index 59% rename from openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java index a28d839f3e..ec85b7f869 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/PublishOnlyDeliveryDispatchTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java @@ -1,6 +1,5 @@ -package com.openframe.data.nats.delivery; +package com.openframe.delivery; -import com.openframe.data.document.rmm.delivery.DeliveryKind; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -13,22 +12,24 @@ class PublishOnlyDeliveryDispatchTest { private static final String MACHINE_ID = "mach-42"; - @Mock private Runnable publish; + @Mock private DeliverySpec spec; @Test - void send_anyRequest_publishedWithoutRow() { + void send_request_publishedThroughSpecWithoutRow() { // setup PublishOnlyDeliveryDispatch dispatch = new PublishOnlyDeliveryDispatch(); - DeliveryRequest request = DeliveryRequest.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) + TestPayload payload = new TestPayload(); + DeliveryRequest request = DeliveryRequest.builder() + .spec(spec) .targetId(MACHINE_ID) .machineId(MACHINE_ID) + .payload(payload) .build(); // execution - dispatch.send(request, publish); + dispatch.send(request); // verifications - verify(publish).run(); + verify(spec).publish(MACHINE_ID, payload); } } diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java similarity index 62% rename from openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java index c3bee3a73a..bb607ce942 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/RecordingDeliveryDispatchTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java @@ -1,12 +1,11 @@ -package com.openframe.data.nats.delivery; +package com.openframe.delivery; import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.document.rmm.delivery.DeliveryStatus; -import com.openframe.data.document.rmm.delivery.MachineDelivery; +import com.openframe.data.document.delivery.DeliveryStatus; +import com.openframe.data.document.delivery.DeliveryType; +import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.rmm.schedule.ScheduleOfflineBehavior; -import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.repository.rmm.MachineDeliveryRepository; +import com.openframe.data.repository.delivery.MachineDeliveryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,50 +16,55 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class RecordingDeliveryDispatchTest { private static final String MACHINE_ID = "mach-42"; - private static final String ISSUED_AT = "2026-09-15T10:00:00Z"; + private static final String VALUE = "issued"; @Mock private MachineDeliveryRepository repository; - @Mock private Runnable publish; + @Mock private DeliverySpec spec; @Captor private ArgumentCaptor deliveryCaptor; private RecordingDeliveryDispatch dispatch; + private TestPayload payload; + @BeforeEach void setUp() { + payload = new TestPayload(); + payload.setValue(VALUE); dispatch = new RecordingDeliveryDispatch(repository, new ObjectMapper()); } @Test - void send_uninstallRequest_pendingRowSavedThenPublished() { + void send_request_pendingRowSavedThenPublishedThroughSpec() { // setup - ClientUninstallMessage message = new ClientUninstallMessage(); - message.setIssuedAt(ISSUED_AT); - DeliveryRequest request = DeliveryRequest.builder() - .kind(DeliveryKind.CLIENT_UNINSTALL) + when(spec.getType()).thenReturn(DeliveryType.CLIENT_UNINSTALL); + DeliveryRequest request = DeliveryRequest.builder() + .spec(spec) .targetId(MACHINE_ID) .machineId(MACHINE_ID) - .payload(message) + .payload(payload) .offlineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT) .build(); // execution - dispatch.send(request, publish); + dispatch.send(request); // verifications verify(repository).save(deliveryCaptor.capture()); MachineDelivery saved = deliveryCaptor.getValue(); assertThat(saved.getId()).isEqualTo("CLIENT_UNINSTALL:mach-42:mach-42"); + assertThat(saved.getType()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); assertThat(saved.getStatus()).isEqualTo(DeliveryStatus.PENDING); assertThat(saved.getAttempts()).isZero(); - assertThat(saved.getPayloadJson()).contains(ISSUED_AT); + assertThat(saved.getPayloadJson()).contains(VALUE); assertThat(saved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); assertThat(saved.getDispatchedAt()).isEqualTo(saved.getLastAttemptAt()); - verify(publish).run(); + verify(spec).publish(MACHINE_ID, payload); } } diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestPayload.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestPayload.java new file mode 100644 index 0000000000..f2b4e46f28 --- /dev/null +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestPayload.java @@ -0,0 +1,8 @@ +package com.openframe.delivery; + +import lombok.Data; + +@Data +class TestPayload { + private String value; +} diff --git a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java index 9f9e677026..7bce8a21d3 100644 --- a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java +++ b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java @@ -2,11 +2,10 @@ import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; -import com.openframe.data.document.rmm.delivery.DeliveryKind; -import com.openframe.data.nats.delivery.DeliveryDispatch; -import com.openframe.data.nats.delivery.DeliveryRequest; +import com.openframe.data.nats.delivery.ToolInstallationDeliverySpec; import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import com.openframe.delivery.DeliveryDispatch; +import com.openframe.delivery.DeliveryRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -24,7 +23,7 @@ public class ToolInstallationService { private final IntegratedToolService integratedToolService; private final ToolCommandParamsResolver toolCommandParamsResolver; - private final ToolInstallationNatsPublisher toolInstallationNatsPublisher; + private final ToolInstallationDeliverySpec toolInstallationDeliverySpec; private final DeliveryDispatch deliveryDispatch; public void process(String machineId, IntegratedToolAgent toolAgent) { @@ -46,14 +45,8 @@ public void process(String machineId, IntegratedToolAgent toolAgent, boolean rei List runCommandArgs = toolAgent.getRunCommandArgs(); toolAgent.setRunCommandArgs(toolCommandParamsResolver.process(toolId, runCommandArgs)); - ToolInstallationMessage message = toolInstallationNatsPublisher.buildMessage(toolAgent, tool, reinstall); - DeliveryRequest request = DeliveryRequest.builder() - .kind(DeliveryKind.TOOL_INSTALLATION) - .targetId(toolAgent.getKey()) - .machineId(machineId) - .payload(message) - .build(); - deliveryDispatch.send(request, () -> toolInstallationNatsPublisher.publish(machineId, message)); + DeliveryRequest request = toolInstallationDeliverySpec.request(machineId, toolAgent, tool, reinstall); + deliveryDispatch.send(request); log.info("Published {} agent installation message for machine {}", toolId, machineId); } catch (Exception e) { // TODO: add fallback mechanism diff --git a/pom.xml b/pom.xml index 8226a9d8f1..3edacc7464 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,7 @@ openframe-core-crypto openframe-data-mongo-common openframe-data-mongo-sync + openframe-machine-delivery openframe-data-mongo-reactive openframe-data-redis openframe-presence @@ -142,6 +143,11 @@ openframe-notification-core ${revision} + + com.openframe.oss + openframe-machine-delivery + ${revision} + com.openframe.oss openframe-data-kafka From 71b89e54b6eb8b89e20568254de89e57b0b256ad Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Wed, 16 Sep 2026 14:46:04 +0200 Subject: [PATCH 05/13] refactor(delivery): DeliveryDispatcher with per-type seeds, recorder strategy behind the flag Mirrors NotificationEmitter: callers hand a Seed to DeliveryDispatcher.dispatch, the registry resolves the spec by the seed's type, the spec builds the DeliveryRequest and publishes. DeliveryRecorder (Mongo when enabled, noop otherwise) is the only flag-dependent piece; publishing is identical either way. Call sites no longer inject concrete specs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../service/ForceClientUninstallService.java | 11 +-- .../ForceClientUninstallServiceTest.java | 27 +++---- .../delivery/ClientUninstallDeliverySpec.java | 41 +++++++--- .../ToolInstallationDeliverySpec.java | 47 +++++++++--- .../ClientUninstallDeliverySpecTest.java | 3 +- .../ToolInstallationDeliverySpecTest.java | 4 +- .../openframe/delivery/DeliveryDispatch.java | 6 -- .../delivery/DeliveryDispatcher.java | 29 ++++++++ .../delivery/DeliveryFailureRecorder.java | 2 +- .../openframe/delivery/DeliveryRecorder.java | 6 ++ .../openframe/delivery/DeliveryRequest.java | 6 +- .../com/openframe/delivery/DeliverySeed.java | 8 ++ .../com/openframe/delivery/DeliverySpec.java | 6 +- .../delivery/DeliverySpecRegistry.java | 8 +- .../delivery/DeliverySweepService.java | 4 +- ...spatch.java => MongoDeliveryRecorder.java} | 12 +-- .../delivery/NoopDeliveryRecorder.java | 14 ++++ .../delivery/PublishOnlyDeliveryDispatch.java | 20 ----- .../delivery/DeliveryDispatcherTest.java | 74 +++++++++++++++++++ .../delivery/DeliveryFailureRecorderTest.java | 2 +- .../delivery/DeliverySpecRegistryTest.java | 28 +++---- .../delivery/DeliverySweepServiceTest.java | 2 +- ...st.java => MongoDeliveryRecorderTest.java} | 30 ++++---- .../PublishOnlyDeliveryDispatchTest.java | 35 --------- .../java/com/openframe/delivery/TestSeed.java | 16 ++++ .../data/service/ToolInstallationService.java | 11 +-- 26 files changed, 283 insertions(+), 169 deletions(-) delete mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatcher.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRecorder.java create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySeed.java rename openframe-machine-delivery/src/main/java/com/openframe/delivery/{RecordingDeliveryDispatch.java => MongoDeliveryRecorder.java} (84%) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java delete mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java create mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryDispatcherTest.java rename openframe-machine-delivery/src/test/java/com/openframe/delivery/{RecordingDeliveryDispatchTest.java => MongoDeliveryRecorderTest.java} (75%) delete mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java create mode 100644 openframe-machine-delivery/src/test/java/com/openframe/delivery/TestSeed.java diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java index aed1e7a4f4..9f0bb0b9cc 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java @@ -6,9 +6,7 @@ import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; import com.openframe.data.document.device.Machine; import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; -import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.delivery.DeliveryDispatch; -import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliveryDispatcher; import com.openframe.data.repository.device.MachineRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,9 +24,8 @@ @Slf4j public class ForceClientUninstallService { - private final ClientUninstallDeliverySpec clientUninstallDeliverySpec; private final MachineRepository machineRepository; - private final DeliveryDispatch deliveryDispatch; + private final DeliveryDispatcher deliveryDispatcher; public ForceClientUninstallResponse process(ForceClientUninstallRequest request) { List machineIds = request.getMachineIds(); @@ -73,8 +70,8 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { } private void publishUninstall(String machineId) { - DeliveryRequest request = clientUninstallDeliverySpec.request(machineId); - deliveryDispatch.send(request); + ClientUninstallDeliverySpec.Seed seed = new ClientUninstallDeliverySpec.Seed(machineId); + deliveryDispatcher.dispatch(seed); } private void markPendingDeletion(Machine machine) { diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java index 850ca1f2c3..2dd74bb008 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java @@ -4,16 +4,17 @@ import com.openframe.api.dto.force.response.ForceAgentStatus; import com.openframe.api.dto.force.response.ForceClientUninstallResponse; import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; +import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; -import com.openframe.data.nats.model.ClientUninstallMessage; import com.openframe.data.repository.device.MachineRepository; -import com.openframe.delivery.DeliveryDispatch; -import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliveryDispatcher; 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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -31,15 +32,15 @@ class ForceClientUninstallServiceTest { private static final String MACHINE_ID = "mach-42"; - @Mock private ClientUninstallDeliverySpec clientUninstallDeliverySpec; @Mock private MachineRepository machineRepository; - @Mock private DeliveryDispatch deliveryDispatch; + @Mock private DeliveryDispatcher deliveryDispatcher; + + @Captor private ArgumentCaptor seedCaptor; @InjectMocks private ForceClientUninstallService service; private Machine machine; private ForceClientUninstallRequest request; - private DeliveryRequest deliveryRequest; @BeforeEach void setUp() { @@ -48,25 +49,20 @@ void setUp() { machine.setStatus(DeviceStatus.ONLINE); request = new ForceClientUninstallRequest(); request.setMachineIds(List.of(MACHINE_ID)); - deliveryRequest = DeliveryRequest.builder() - .spec(clientUninstallDeliverySpec) - .targetId(MACHINE_ID) - .machineId(MACHINE_ID) - .payload(new ClientUninstallMessage()) - .build(); } @Test void process_onlineMachine_dispatchedAndMarkedPendingDeletion() { // setup when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - when(clientUninstallDeliverySpec.request(MACHINE_ID)).thenReturn(deliveryRequest); // execution ForceClientUninstallResponse response = service.process(request); // verifications - verify(deliveryDispatch).send(deliveryRequest); + verify(deliveryDispatcher).dispatch(seedCaptor.capture()); + assertThat(seedCaptor.getValue().getMachineId()).isEqualTo(MACHINE_ID); + assertThat(seedCaptor.getValue().type()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); assertThat(machine.getStatus()).isEqualTo(DeviceStatus.PENDING_DELETION); verify(machineRepository).save(machine); assertThat(response.getItems()) @@ -84,8 +80,7 @@ void process_deletedMachine_failedWithoutDispatch() { ForceClientUninstallResponse response = service.process(request); // verifications - verifyNoInteractions(deliveryDispatch); - verifyNoInteractions(clientUninstallDeliverySpec); + verifyNoInteractions(deliveryDispatcher); assertThat(response.getItems()) .extracting(ForceClientUninstallResponseItem::getStatus) .containsExactly(ForceAgentStatus.FAILED); diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java index 0e164c0c60..2818461ba1 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java @@ -9,7 +9,10 @@ import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliverySeed; import com.openframe.delivery.DeliverySpec; +import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -17,20 +20,20 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty("spring.cloud.stream.enabled") -public class ClientUninstallDeliverySpec implements DeliverySpec { +public class ClientUninstallDeliverySpec implements DeliverySpec { private final ClientUninstallNatsPublisher publisher; private final MachineRepository machineRepository; - // targetId is the machine itself: the agent confirms over HTTP /api/agents/uninstall with X-Machine-Id - public DeliveryRequest request(String machineId) { - ClientUninstallMessage message = publisher.buildMessage(); - return DeliveryRequest.builder() - .spec(this) - .targetId(machineId) - .machineId(machineId) - .payload(message) - .build(); + @Getter + @AllArgsConstructor + public static class Seed implements DeliverySeed { + private final String machineId; + + @Override + public DeliveryType type() { + return DeliveryType.CLIENT_UNINSTALL; + } } @Override @@ -38,11 +41,29 @@ public DeliveryType getType() { return DeliveryType.CLIENT_UNINSTALL; } + @Override + public Class getSeedClass() { + return Seed.class; + } + @Override public Class getPayloadClass() { return ClientUninstallMessage.class; } + // targetId is the machine itself: the agent confirms over HTTP /api/agents/uninstall with X-Machine-Id + @Override + public DeliveryRequest request(Seed seed) { + ClientUninstallMessage message = publisher.buildMessage(); + String machineId = seed.getMachineId(); + return DeliveryRequest.builder() + .type(DeliveryType.CLIENT_UNINSTALL) + .targetId(machineId) + .machineId(machineId) + .payload(message) + .build(); + } + @Override public void publish(String machineId, ClientUninstallMessage payload) { publisher.publish(machineId, payload); diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java index ec06de2bc7..eb55beb626 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java @@ -8,7 +8,10 @@ import com.openframe.data.nats.model.ToolInstallationMessage; import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliverySeed; import com.openframe.delivery.DeliverySpec; +import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -16,19 +19,22 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty("spring.cloud.stream.enabled") -public class ToolInstallationDeliverySpec implements DeliverySpec { +public class ToolInstallationDeliverySpec implements DeliverySpec { private final ToolInstallationNatsPublisher publisher; - // targetId is the tool agent key: the agent reports it back as agentType in installed-agent - public DeliveryRequest request(String machineId, IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { - ToolInstallationMessage message = publisher.buildMessage(toolAgent, tool, reinstall); - return DeliveryRequest.builder() - .spec(this) - .targetId(toolAgent.getKey()) - .machineId(machineId) - .payload(message) - .build(); + @Getter + @AllArgsConstructor + public static class Seed implements DeliverySeed { + private final String machineId; + private final IntegratedToolAgent toolAgent; + private final IntegratedTool tool; + private final boolean reinstall; + + @Override + public DeliveryType type() { + return DeliveryType.TOOL_INSTALLATION; + } } @Override @@ -36,11 +42,32 @@ public DeliveryType getType() { return DeliveryType.TOOL_INSTALLATION; } + @Override + public Class getSeedClass() { + return Seed.class; + } + @Override public Class getPayloadClass() { return ToolInstallationMessage.class; } + // targetId is the tool agent key: the agent reports it back as agentType in installed-agent + @Override + public DeliveryRequest request(Seed seed) { + IntegratedToolAgent toolAgent = seed.getToolAgent(); + IntegratedTool tool = seed.getTool(); + boolean reinstall = seed.isReinstall(); + ToolInstallationMessage message = publisher.buildMessage(toolAgent, tool, reinstall); + String targetId = toolAgent.getKey(); + return DeliveryRequest.builder() + .type(DeliveryType.TOOL_INSTALLATION) + .targetId(targetId) + .machineId(seed.getMachineId()) + .payload(message) + .build(); + } + @Override public void publish(String machineId, ToolInstallationMessage payload) { publisher.publish(machineId, payload); diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java index 7afd3ccf29..5e55928bb2 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java @@ -55,7 +55,8 @@ void request_machine_targetIsMachineAndPayloadBuiltByPublisher() { when(publisher.buildMessage()).thenReturn(message); // execution - DeliveryRequest request = spec.request(MACHINE_ID); + ClientUninstallDeliverySpec.Seed seed = new ClientUninstallDeliverySpec.Seed(MACHINE_ID); + DeliveryRequest request = spec.request(seed); // verifications assertThat(request.getType()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java index ea09a8bbfe..d164477244 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java @@ -46,14 +46,14 @@ void request_toolAgent_targetIsAgentKeyAndPayloadBuiltByPublisher() { when(publisher.buildMessage(toolAgent, tool, true)).thenReturn(message); // execution - DeliveryRequest request = spec.request(MACHINE_ID, toolAgent, tool, true); + ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(MACHINE_ID, toolAgent, tool, true); + DeliveryRequest request = spec.request(seed); // verifications assertThat(request.getType()).isEqualTo(DeliveryType.TOOL_INSTALLATION); assertThat(request.getTargetId()).isEqualTo(TOOL_AGENT_KEY); assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); assertThat(request.getPayload()).isSameAs(message); - assertThat(request.getSpec()).isSameAs(spec); } @Test diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java deleted file mode 100644 index 56024342a0..0000000000 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatch.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.openframe.delivery; - -public interface DeliveryDispatch { - - void send(DeliveryRequest request); -} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatcher.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatcher.java new file mode 100644 index 0000000000..43fd0210ef --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryDispatcher.java @@ -0,0 +1,29 @@ +package com.openframe.delivery; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DeliveryDispatcher { + + private final DeliverySpecRegistry registry; + private final DeliveryRecorder recorder; + + public void dispatch(DeliverySeed seed) { + DeliverySpec spec = registry.require(seed.type()); + dispatch(spec, seed); + } + + private void dispatch(DeliverySpec spec, DeliverySeed seed) { + Class seedClass = spec.getSeedClass(); + S typedSeed = seedClass.cast(seed); + DeliveryRequest

request = spec.request(typedSeed); + recorder.record(request); + spec.publish(request.getMachineId(), request.getPayload()); + log.info("Delivery dispatched: type={} targetId={} machineId={}", + request.getType(), request.getTargetId(), request.getMachineId()); + } +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java index aef7e74682..50abe845bc 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryFailureRecorder.java @@ -36,7 +36,7 @@ public void fail(MachineDelivery delivery, DeliveryFailure failure, Instant now) repository.save(delivery); metrics.recordFailed(type, failure); - DeliverySpec spec = registry.require(type); + DeliverySpec spec = registry.require(type); spec.onFailed(delivery, failure); log.warn("Delivery FAILED: type={} targetId={} machineId={} attempts={} reason={}", type, delivery.getTargetId(), delivery.getMachineId(), delivery.getAttempts(), failure); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRecorder.java new file mode 100644 index 0000000000..1012a20274 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRecorder.java @@ -0,0 +1,6 @@ +package com.openframe.delivery; + +public interface DeliveryRecorder { + + void record(DeliveryRequest request); +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java index fe8c6e99c3..5857761286 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryRequest.java @@ -8,14 +8,10 @@ @Getter @Builder public class DeliveryRequest

{ - private final DeliverySpec

spec; + private final DeliveryType type; private final String targetId; private final String machineId; private final P payload; private final ScheduleOfflineBehavior offlineBehavior; private final Long reconnectWindowSeconds; - - public DeliveryType getType() { - return spec.getType(); - } } diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySeed.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySeed.java new file mode 100644 index 0000000000..e0f41cfc08 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySeed.java @@ -0,0 +1,8 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; + +public interface DeliverySeed { + + DeliveryType type(); +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java index ffa067c01c..0048147262 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpec.java @@ -4,12 +4,16 @@ import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.document.delivery.MachineDelivery; -public interface DeliverySpec

{ +public interface DeliverySpec { DeliveryType getType(); + Class getSeedClass(); + Class

getPayloadClass(); + DeliveryRequest

request(S seed); + void publish(String machineId, P payload); void onFailed(MachineDelivery delivery, DeliveryFailure failure); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java index 6cc1527a5b..a95ee96c7c 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySpecRegistry.java @@ -16,11 +16,11 @@ @Component public class DeliverySpecRegistry { - private final Map> byType; + private final Map> byType; // ObjectProvider, not List: a service with zero specs on the classpath must still boot. // toUnmodifiableMap throws IllegalStateException on a duplicate type — the wanted fail-fast. - public DeliverySpecRegistry(ObjectProvider> specs) { + public DeliverySpecRegistry(ObjectProvider> specs) { this.byType = specs.stream() .collect(toUnmodifiableMap(DeliverySpec::getType, identity())); Set registered = byType.keySet(); @@ -28,8 +28,8 @@ public DeliverySpecRegistry(ObjectProvider> specs) { log.info("Registered {} delivery spec(s): {}", byType.size(), sortedTypes); } - public DeliverySpec require(DeliveryType type) { - DeliverySpec spec = byType.get(type); + public DeliverySpec require(DeliveryType type) { + DeliverySpec spec = byType.get(type); if (spec == null) { throw new IllegalArgumentException("No spec registered for delivery type: " + type.name()); } diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java index 6a0839bb3d..e29309b110 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepService.java @@ -80,7 +80,7 @@ private void waitOrFailOffline(MachineDelivery delivery, Policy policy, Instant private void republish(MachineDelivery delivery, Instant now) { DeliveryType type = delivery.getType(); - DeliverySpec spec = registry.require(type); + DeliverySpec spec = registry.require(type); publish(spec, delivery); int attempt = delivery.getAttempts() + 1; @@ -92,7 +92,7 @@ private void republish(MachineDelivery delivery, Instant now) { type, delivery.getTargetId(), delivery.getMachineId(), attempt); } - private

void publish(DeliverySpec

spec, MachineDelivery delivery) { + private void publish(DeliverySpec spec, MachineDelivery delivery) { Class

payloadClass = spec.getPayloadClass(); P payload = readPayload(delivery, payloadClass); String machineId = delivery.getMachineId(); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java similarity index 84% rename from openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java rename to openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java index 8d07c8bc76..b882a00f98 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/RecordingDeliveryDispatch.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java @@ -16,25 +16,19 @@ @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") -public class RecordingDeliveryDispatch implements DeliveryDispatch { +public class MongoDeliveryRecorder implements DeliveryRecorder { private final MachineDeliveryRepository repository; private final ObjectMapper objectMapper; @Override - public void send(DeliveryRequest request) { + public void record(DeliveryRequest request) { MachineDelivery delivery = pendingRow(request); repository.save(delivery); - publish(request); - log.info("Delivery dispatched: type={} targetId={} machineId={}", + log.info("Delivery recorded: type={} targetId={} machineId={}", request.getType(), request.getTargetId(), request.getMachineId()); } - private

void publish(DeliveryRequest

request) { - DeliverySpec

spec = request.getSpec(); - spec.publish(request.getMachineId(), request.getPayload()); - } - private MachineDelivery pendingRow(DeliveryRequest request) { Instant now = Instant.now(); String id = MachineDelivery.id(request.getType(), request.getTargetId(), request.getMachineId()); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java new file mode 100644 index 0000000000..c6a9b33eca --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java @@ -0,0 +1,14 @@ +package com.openframe.delivery; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +// flag off = today's behaviour: the command is published, no row is kept +@Component +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "false", matchIfMissing = true) +public class NoopDeliveryRecorder implements DeliveryRecorder { + + @Override + public void record(DeliveryRequest request) { + } +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java deleted file mode 100644 index af6f86a383..0000000000 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/PublishOnlyDeliveryDispatch.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.openframe.delivery; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -// flag off = today's behaviour: publish without a row, so callers keep a single code path -@Component -@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "false", matchIfMissing = true) -public class PublishOnlyDeliveryDispatch implements DeliveryDispatch { - - @Override - public void send(DeliveryRequest request) { - publish(request); - } - - private

void publish(DeliveryRequest

request) { - DeliverySpec

spec = request.getSpec(); - spec.publish(request.getMachineId(), request.getPayload()); - } -} diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryDispatcherTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryDispatcherTest.java new file mode 100644 index 0000000000..977dbc349d --- /dev/null +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryDispatcherTest.java @@ -0,0 +1,74 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryDispatcherTest { + + private static final String MACHINE_ID = "mach-42"; + private static final String TARGET_ID = "tactical-agent"; + + @Mock private DeliverySpecRegistry registry; + @Mock private DeliveryRecorder recorder; + @Mock private DeliverySpec spec; + + @InjectMocks private DeliveryDispatcher dispatcher; + + private TestSeed seed; + private TestPayload payload; + private DeliveryRequest request; + + @BeforeEach + void setUp() { + seed = new TestSeed(MACHINE_ID); + payload = new TestPayload(); + request = DeliveryRequest.builder() + .type(DeliveryType.TOOL_INSTALLATION) + .targetId(TARGET_ID) + .machineId(MACHINE_ID) + .payload(payload) + .build(); + } + + @Test + void dispatch_seed_requestRecordedThenPublishedThroughSpec() { + // setup + doReturn(spec).when(registry).require(DeliveryType.TOOL_INSTALLATION); + when(spec.getSeedClass()).thenReturn(TestSeed.class); + when(spec.request(seed)).thenReturn(request); + + // execution + dispatcher.dispatch(seed); + + // verifications + verify(recorder).record(request); + verify(spec).publish(MACHINE_ID, payload); + } + + @Test + void dispatch_unregisteredType_throwsWithoutRecording() { + // setup + when(registry.require(DeliveryType.TOOL_INSTALLATION)) + .thenThrow(new IllegalArgumentException("No spec registered for delivery type: TOOL_INSTALLATION")); + + // execution + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> dispatcher.dispatch(seed)); + + // verifications + assertThat(ex.getMessage()).contains("TOOL_INSTALLATION"); + verifyNoInteractions(recorder); + } +} diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java index 2d223fc2e5..d7f2a706a7 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliveryFailureRecorderTest.java @@ -24,7 +24,7 @@ class DeliveryFailureRecorderTest { @Mock private MachineDeliveryRepository repository; @Mock private DeliverySpecRegistry registry; @Mock private DeliveryMetrics metrics; - @Mock private DeliverySpec spec; + @Mock private DeliverySpec spec; private DeliveryFailureRecorder recorder; diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java index 3b4ad04914..727ced7c99 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySpecRegistryTest.java @@ -16,12 +16,12 @@ class DeliverySpecRegistryTest { @Test void require_registeredType_specReturned() { // setup - DeliverySpec spec = spec(DeliveryType.TOOL_INSTALLATION); - ObjectProvider> specs = provider(spec); + DeliverySpec spec = spec(DeliveryType.TOOL_INSTALLATION); + ObjectProvider> specs = provider(spec); DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); // execution - DeliverySpec resolved = registry.require(DeliveryType.TOOL_INSTALLATION); + DeliverySpec resolved = registry.require(DeliveryType.TOOL_INSTALLATION); // verifications assertThat(resolved).isSameAs(spec); @@ -30,7 +30,7 @@ void require_registeredType_specReturned() { @Test void require_unregisteredType_throwsIllegalArgument() { // setup - ObjectProvider> specs = provider(); + ObjectProvider> specs = provider(); DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); // execution + verifications @@ -42,9 +42,9 @@ void require_unregisteredType_throwsIllegalArgument() { @Test void constructor_duplicateType_throwsIllegalState() { // setup - DeliverySpec first = spec(DeliveryType.TOOL_INSTALLATION); - DeliverySpec second = spec(DeliveryType.TOOL_INSTALLATION); - ObjectProvider> duplicates = provider(first, second); + DeliverySpec first = spec(DeliveryType.TOOL_INSTALLATION); + DeliverySpec second = spec(DeliveryType.TOOL_INSTALLATION); + ObjectProvider> duplicates = provider(first, second); // execution + verifications assertThatThrownBy(() -> new DeliverySpecRegistry(duplicates)) @@ -55,9 +55,9 @@ void constructor_duplicateType_throwsIllegalState() { @Test void types_twoSpecs_bothTypesListed() { // setup - DeliverySpec install = spec(DeliveryType.TOOL_INSTALLATION); - DeliverySpec uninstall = spec(DeliveryType.CLIENT_UNINSTALL); - ObjectProvider> specs = provider(install, uninstall); + DeliverySpec install = spec(DeliveryType.TOOL_INSTALLATION); + DeliverySpec uninstall = spec(DeliveryType.CLIENT_UNINSTALL); + ObjectProvider> specs = provider(install, uninstall); // execution DeliverySpecRegistry registry = new DeliverySpecRegistry(specs); @@ -67,14 +67,14 @@ void types_twoSpecs_bothTypesListed() { } @SuppressWarnings("unchecked") - private static ObjectProvider> provider(DeliverySpec... specs) { - ObjectProvider> provider = mock(ObjectProvider.class); + private static ObjectProvider> provider(DeliverySpec... specs) { + ObjectProvider> provider = mock(ObjectProvider.class); when(provider.stream()).thenReturn(Stream.of(specs)); return provider; } - private static DeliverySpec spec(DeliveryType type) { - DeliverySpec spec = mock(DeliverySpec.class); + private static DeliverySpec spec(DeliveryType type) { + DeliverySpec spec = mock(DeliverySpec.class); when(spec.getType()).thenReturn(type); return spec; } diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java index 451416d1c0..acfbf60b7e 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java @@ -43,7 +43,7 @@ class DeliverySweepServiceTest { @Mock private DeliverySpecRegistry registry; @Mock private DeliveryFailureRecorder failureRecorder; @Mock private DeliveryMetrics metrics; - @Mock private DeliverySpec spec; + @Mock private DeliverySpec spec; @Captor private ArgumentCaptor payloadCaptor; diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryRecorderTest.java similarity index 75% rename from openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java rename to openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryRecorderTest.java index bb607ce942..2eb870b77c 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/RecordingDeliveryDispatchTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryRecorderTest.java @@ -16,44 +16,41 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class RecordingDeliveryDispatchTest { +class MongoDeliveryRecorderTest { private static final String MACHINE_ID = "mach-42"; private static final String VALUE = "issued"; @Mock private MachineDeliveryRepository repository; - @Mock private DeliverySpec spec; @Captor private ArgumentCaptor deliveryCaptor; - private RecordingDeliveryDispatch dispatch; + private MongoDeliveryRecorder recorder; - private TestPayload payload; + private DeliveryRequest request; @BeforeEach void setUp() { - payload = new TestPayload(); + TestPayload payload = new TestPayload(); payload.setValue(VALUE); - dispatch = new RecordingDeliveryDispatch(repository, new ObjectMapper()); - } - - @Test - void send_request_pendingRowSavedThenPublishedThroughSpec() { - // setup - when(spec.getType()).thenReturn(DeliveryType.CLIENT_UNINSTALL); - DeliveryRequest request = DeliveryRequest.builder() - .spec(spec) + request = DeliveryRequest.builder() + .type(DeliveryType.CLIENT_UNINSTALL) .targetId(MACHINE_ID) .machineId(MACHINE_ID) .payload(payload) .offlineBehavior(ScheduleOfflineBehavior.RETRY_ON_RECONNECT) .build(); + recorder = new MongoDeliveryRecorder(repository, new ObjectMapper()); + } + + @Test + void record_request_pendingRowSaved() { + // setup // execution - dispatch.send(request); + recorder.record(request); // verifications verify(repository).save(deliveryCaptor.capture()); @@ -65,6 +62,5 @@ void send_request_pendingRowSavedThenPublishedThroughSpec() { assertThat(saved.getPayloadJson()).contains(VALUE); assertThat(saved.getOfflineBehavior()).isEqualTo(ScheduleOfflineBehavior.RETRY_ON_RECONNECT); assertThat(saved.getDispatchedAt()).isEqualTo(saved.getLastAttemptAt()); - verify(spec).publish(MACHINE_ID, payload); } } diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java deleted file mode 100644 index ec85b7f869..0000000000 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/PublishOnlyDeliveryDispatchTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.openframe.delivery; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class PublishOnlyDeliveryDispatchTest { - - private static final String MACHINE_ID = "mach-42"; - - @Mock private DeliverySpec spec; - - @Test - void send_request_publishedThroughSpecWithoutRow() { - // setup - PublishOnlyDeliveryDispatch dispatch = new PublishOnlyDeliveryDispatch(); - TestPayload payload = new TestPayload(); - DeliveryRequest request = DeliveryRequest.builder() - .spec(spec) - .targetId(MACHINE_ID) - .machineId(MACHINE_ID) - .payload(payload) - .build(); - - // execution - dispatch.send(request); - - // verifications - verify(spec).publish(MACHINE_ID, payload); - } -} diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestSeed.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestSeed.java new file mode 100644 index 0000000000..d5b16ddd58 --- /dev/null +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/TestSeed.java @@ -0,0 +1,16 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +class TestSeed implements DeliverySeed { + private final String machineId; + + @Override + public DeliveryType type() { + return DeliveryType.TOOL_INSTALLATION; + } +} diff --git a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java index 7bce8a21d3..d4295b0c6b 100644 --- a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java +++ b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java @@ -3,9 +3,7 @@ import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; import com.openframe.data.nats.delivery.ToolInstallationDeliverySpec; -import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.delivery.DeliveryDispatch; -import com.openframe.delivery.DeliveryRequest; +import com.openframe.delivery.DeliveryDispatcher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -23,8 +21,7 @@ public class ToolInstallationService { private final IntegratedToolService integratedToolService; private final ToolCommandParamsResolver toolCommandParamsResolver; - private final ToolInstallationDeliverySpec toolInstallationDeliverySpec; - private final DeliveryDispatch deliveryDispatch; + private final DeliveryDispatcher deliveryDispatcher; public void process(String machineId, IntegratedToolAgent toolAgent) { process(machineId, toolAgent, false); @@ -45,8 +42,8 @@ public void process(String machineId, IntegratedToolAgent toolAgent, boolean rei List runCommandArgs = toolAgent.getRunCommandArgs(); toolAgent.setRunCommandArgs(toolCommandParamsResolver.process(toolId, runCommandArgs)); - DeliveryRequest request = toolInstallationDeliverySpec.request(machineId, toolAgent, tool, reinstall); - deliveryDispatch.send(request); + ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(machineId, toolAgent, tool, reinstall); + deliveryDispatcher.dispatch(seed); log.info("Published {} agent installation message for machine {}", toolId, machineId); } catch (Exception e) { // TODO: add fallback mechanism From ded6401e614beeca809eb601ea3f5690fc540cd8 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Wed, 16 Sep 2026 14:46:04 +0200 Subject: [PATCH 06/13] chore(delivery): keep this PR to the module and specs, usage moves to a follow-up Reverts the call-site wiring (ToolInstallationService, ForceClientUninstallService), the ack routing (ScriptExecutionAcknowledgeMessage type/targetId, listener) and the complete() hooks (InstalledAgentService, AgentUninstallService) with their tests. They come back as the next PR on top of this one. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../service/ForceClientUninstallService.java | 12 +- .../ForceClientUninstallServiceTest.java | 88 -------------- .../ScriptExecutionAcknowledgeListener.java | 22 +--- .../client/service/AgentUninstallService.java | 4 - .../client/service/InstalledAgentService.java | 4 - ...criptExecutionAcknowledgeListenerTest.java | 114 ------------------ .../service/AgentUninstallServiceTest.java | 98 --------------- .../service/InstalledAgentServiceTest.java | 103 ---------------- .../ScriptExecutionAcknowledgeMessage.java | 5 - .../data/service/ToolInstallationService.java | 8 +- 10 files changed, 8 insertions(+), 450 deletions(-) delete mode 100644 openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java delete mode 100644 openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java delete mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java delete mode 100644 openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java index 9f0bb0b9cc..ab5bb25613 100644 --- a/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java +++ b/openframe-api-service-core/src/main/java/com/openframe/api/service/ForceClientUninstallService.java @@ -5,8 +5,7 @@ import com.openframe.api.dto.force.response.ForceClientUninstallResponse; import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; import com.openframe.data.document.device.Machine; -import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; -import com.openframe.delivery.DeliveryDispatcher; +import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; import com.openframe.data.repository.device.MachineRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,8 +23,8 @@ @Slf4j public class ForceClientUninstallService { + private final ClientUninstallNatsPublisher clientUninstallNatsPublisher; private final MachineRepository machineRepository; - private final DeliveryDispatcher deliveryDispatcher; public ForceClientUninstallResponse process(ForceClientUninstallRequest request) { List machineIds = request.getMachineIds(); @@ -58,7 +57,7 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { return buildResponseItem(machineId, ForceAgentStatus.FAILED); } - publishUninstall(machineId); + clientUninstallNatsPublisher.publish(machineId); markPendingDeletion(machine); @@ -69,11 +68,6 @@ private ForceClientUninstallResponseItem processMachine(String machineId) { } } - private void publishUninstall(String machineId) { - ClientUninstallDeliverySpec.Seed seed = new ClientUninstallDeliverySpec.Seed(machineId); - deliveryDispatcher.dispatch(seed); - } - private void markPendingDeletion(Machine machine) { if (machine.getStatus() == PENDING_DELETION) { return; diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java deleted file mode 100644 index 2dd74bb008..0000000000 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/ForceClientUninstallServiceTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.openframe.api.service; - -import com.openframe.api.dto.force.request.ForceClientUninstallRequest; -import com.openframe.api.dto.force.response.ForceAgentStatus; -import com.openframe.api.dto.force.response.ForceClientUninstallResponse; -import com.openframe.api.dto.force.response.ForceClientUninstallResponseItem; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.nats.delivery.ClientUninstallDeliverySpec; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.delivery.DeliveryDispatcher; -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.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class ForceClientUninstallServiceTest { - - private static final String MACHINE_ID = "mach-42"; - - @Mock private MachineRepository machineRepository; - @Mock private DeliveryDispatcher deliveryDispatcher; - - @Captor private ArgumentCaptor seedCaptor; - - @InjectMocks private ForceClientUninstallService service; - - private Machine machine; - private ForceClientUninstallRequest request; - - @BeforeEach - void setUp() { - machine = new Machine(); - machine.setMachineId(MACHINE_ID); - machine.setStatus(DeviceStatus.ONLINE); - request = new ForceClientUninstallRequest(); - request.setMachineIds(List.of(MACHINE_ID)); - } - - @Test - void process_onlineMachine_dispatchedAndMarkedPendingDeletion() { - // setup - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - ForceClientUninstallResponse response = service.process(request); - - // verifications - verify(deliveryDispatcher).dispatch(seedCaptor.capture()); - assertThat(seedCaptor.getValue().getMachineId()).isEqualTo(MACHINE_ID); - assertThat(seedCaptor.getValue().type()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); - assertThat(machine.getStatus()).isEqualTo(DeviceStatus.PENDING_DELETION); - verify(machineRepository).save(machine); - assertThat(response.getItems()) - .extracting(ForceClientUninstallResponseItem::getStatus) - .containsExactly(ForceAgentStatus.PROCESSED); - } - - @Test - void process_deletedMachine_failedWithoutDispatch() { - // setup - machine.setStatus(DeviceStatus.DELETED); - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - ForceClientUninstallResponse response = service.process(request); - - // verifications - verifyNoInteractions(deliveryDispatcher); - assertThat(response.getItems()) - .extracting(ForceClientUninstallResponseItem::getStatus) - .containsExactly(ForceAgentStatus.FAILED); - } -} diff --git a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java index 3f895817b3..7a22c0d443 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java +++ b/openframe-client-core/src/main/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListener.java @@ -2,8 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.delivery.DeliveryTracker; import com.openframe.data.nats.listener.AbstractJetStreamPushListener; import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; import io.nats.client.Connection; @@ -19,18 +17,15 @@ public class ScriptExecutionAcknowledgeListener extends AbstractJetStreamPushLis private final ObjectMapper objectMapper; private final ScriptExecutionAcknowledgeService acknowledgeService; - private final DeliveryTracker deliveryTracker; public ScriptExecutionAcknowledgeListener( Connection natsConnection, ObjectMapper objectMapper, - ScriptExecutionAcknowledgeService acknowledgeService, - DeliveryTracker deliveryTracker + ScriptExecutionAcknowledgeService acknowledgeService ) { super(natsConnection); this.objectMapper = objectMapper; this.acknowledgeService = acknowledgeService; - this.deliveryTracker = deliveryTracker; } @Override @@ -63,23 +58,10 @@ protected void handleMessage(Message message) { String payload = new String(message.getData(), StandardCharsets.UTF_8); try { ScriptExecutionAcknowledgeMessage ack = objectMapper.readValue(payload, ScriptExecutionAcknowledgeMessage.class); - if (isDeliveryAck(ack)) { - deliveryTracker.acknowledge(ack.getType(), ack.getTargetId(), ack.getMachineId()); - } - if (isScriptAck(ack)) { - acknowledgeService.acknowledge(ack); - } + acknowledgeService.acknowledge(ack); message.ack(); } catch (Exception e) { log.error("Unexpected error processing execution ack: {}", payload, e); } } - - private static boolean isDeliveryAck(ScriptExecutionAcknowledgeMessage ack) { - return ack.getType() != null && ack.getTargetId() != null; - } - - private static boolean isScriptAck(ScriptExecutionAcknowledgeMessage ack) { - return ack.getType() == null || ack.getType() == DeliveryType.SCRIPT_SCHEDULE; - } } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java index 4149367bff..d0b0231240 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/AgentUninstallService.java @@ -4,8 +4,6 @@ import com.openframe.data.document.device.DeviceStatus; import com.openframe.data.document.device.Machine; import com.openframe.data.document.oauth.OAuthClient; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.delivery.DeliveryTracker; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.oauth.OAuthClientRepository; import lombok.RequiredArgsConstructor; @@ -24,7 +22,6 @@ public class AgentUninstallService { private final MachineRepository machineRepository; private final ToolConnectionService toolConnectionService; private final InstalledAgentService installedAgentService; - private final DeliveryTracker deliveryTracker; public void uninstall(String machineId, String clientSecret) { Optional client = oauthClientRepository.findByMachineId(machineId); @@ -51,7 +48,6 @@ public void uninstall(String machineId, String clientSecret) { toolConnectionService.disconnectAll(machineId); installedAgentService.disconnectAll(machineId); - deliveryTracker.complete(DeliveryType.CLIENT_UNINSTALL, machineId, machineId); log.info("Machine {} deregistered on uninstall", machineId); } diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java index fae538a0da..85d1511be4 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/InstalledAgentService.java @@ -4,8 +4,6 @@ import com.openframe.client.exception.MachineNotFoundException; import com.openframe.data.document.installedagents.InstalledAgent; import com.openframe.data.document.tool.ConnectionStatus; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.delivery.DeliveryTracker; import com.openframe.data.repository.device.MachineRepository; import com.openframe.data.repository.installedagents.InstalledAgentRepository; import lombok.RequiredArgsConstructor; @@ -23,7 +21,6 @@ public class InstalledAgentService { private final InstalledAgentRepository installedAgentRepository; private final MachineRepository machineRepository; - private final DeliveryTracker deliveryTracker; @Transactional public void addInstalledAgent(String machineId, String agentType, String version, boolean lastAttempt) { @@ -39,7 +36,6 @@ public void addInstalledAgent(String machineId, String agentType, String version installedAgent -> updateExistingInstalledAgent(installedAgent, version, machineId, agentType), () -> addNewInstalledAgent(machineId, agentType, version) ); - deliveryTracker.complete(DeliveryType.TOOL_INSTALLATION, agentType, machineId); } @Transactional diff --git a/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java b/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java deleted file mode 100644 index 64c61e9e28..0000000000 --- a/openframe-client-core/src/test/java/com/openframe/client/listener/rmm/ScriptExecutionAcknowledgeListenerTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.openframe.client.listener.rmm; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.openframe.client.service.rmm.ScriptExecutionAcknowledgeService; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.delivery.DeliveryTracker; -import com.openframe.data.nats.rmm.model.ScriptExecutionAcknowledgeMessage; -import io.nats.client.Connection; -import io.nats.client.Message; -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 static java.nio.charset.StandardCharsets.UTF_8; -import static org.assertj.core.api.Assertions.assertThat; -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 ScriptExecutionAcknowledgeListenerTest { - - private static final String MACHINE_ID = "mach-42"; - private static final String EXECUTION_ID = "exec-1"; - private static final String TOOL_AGENT_ID = "tactical-agent"; - private static final String LEGACY_SCRIPT_ACK = - "{\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; - private static final String TOOL_INSTALLATION_ACK = - "{\"type\":\"TOOL_INSTALLATION\",\"targetId\":\"tactical-agent\",\"machineId\":\"mach-42\"}"; - private static final String SCRIPT_SCHEDULE_ACK = - "{\"type\":\"SCRIPT_SCHEDULE\",\"targetId\":\"exec-1\",\"executionId\":\"exec-1\",\"machineId\":\"mach-42\",\"scriptIds\":[\"s1\"]}"; - private static final String MALFORMED = "not json"; - - @Mock private Connection natsConnection; - @Mock private ScriptExecutionAcknowledgeService acknowledgeService; - @Mock private DeliveryTracker deliveryTracker; - @Mock private Message message; - - @Captor private ArgumentCaptor ackCaptor; - - private ScriptExecutionAcknowledgeListener listener; - - @BeforeEach - void setUp() { - listener = new ScriptExecutionAcknowledgeListener(natsConnection, new ObjectMapper(), acknowledgeService, deliveryTracker); - } - - @Test - void handleMessage_legacyScriptAck_scriptServiceOnly() { - // setup - stubPayload(LEGACY_SCRIPT_ACK); - - // execution - listener.handleMessage(message); - - // verifications - verify(acknowledgeService).acknowledge(ackCaptor.capture()); - assertThat(ackCaptor.getValue().getExecutionId()).isEqualTo(EXECUTION_ID); - verifyNoInteractions(deliveryTracker); - verify(message).ack(); - } - - @Test - void handleMessage_toolInstallationAck_trackerOnly() { - // setup - stubPayload(TOOL_INSTALLATION_ACK); - - // execution - listener.handleMessage(message); - - // verifications - verify(deliveryTracker).acknowledge(DeliveryType.TOOL_INSTALLATION, TOOL_AGENT_ID, MACHINE_ID); - verifyNoInteractions(acknowledgeService); - verify(message).ack(); - } - - @Test - void handleMessage_scriptScheduleAckWithType_trackerAndScriptService() { - // setup - stubPayload(SCRIPT_SCHEDULE_ACK); - - // execution - listener.handleMessage(message); - - // verifications - verify(deliveryTracker).acknowledge(DeliveryType.SCRIPT_SCHEDULE, EXECUTION_ID, MACHINE_ID); - verify(acknowledgeService).acknowledge(ackCaptor.capture()); - assertThat(ackCaptor.getValue().getExecutionId()).isEqualTo(EXECUTION_ID); - verify(message).ack(); - } - - @Test - void handleMessage_malformedPayload_leftUnacked() { - // setup - stubPayload(MALFORMED); - - // execution - listener.handleMessage(message); - - // verifications - verify(message, never()).ack(); - verifyNoInteractions(acknowledgeService); - verifyNoInteractions(deliveryTracker); - } - - private void stubPayload(String json) { - when(message.getData()).thenReturn(json.getBytes(UTF_8)); - } -} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java deleted file mode 100644 index 1c2dc4c12b..0000000000 --- a/openframe-client-core/src/test/java/com/openframe/client/service/AgentUninstallServiceTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.openframe.client.service; - -import com.openframe.delivery.DeliveryTracker; -import com.openframe.client.service.validator.ClientSecretValidator; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.document.oauth.OAuthClient; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.data.repository.oauth.OAuthClientRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -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 AgentUninstallServiceTest { - - private static final String MACHINE_ID = "mach-42"; - private static final String SECRET = "s3cret"; - - @Mock private OAuthClientRepository oauthClientRepository; - @Mock private ClientSecretValidator clientSecretValidator; - @Mock private MachineRepository machineRepository; - @Mock private ToolConnectionService toolConnectionService; - @Mock private InstalledAgentService installedAgentService; - @Mock private DeliveryTracker deliveryTracker; - - @InjectMocks private AgentUninstallService service; - - private OAuthClient client; - private Machine machine; - - @BeforeEach - void setUp() { - client = new OAuthClient(); - machine = new Machine(); - machine.setMachineId(MACHINE_ID); - machine.setStatus(DeviceStatus.PENDING_DELETION); - } - - @Test - void uninstall_pendingDeletionMachine_deletedAndDeliveryCompleted() { - // setup - when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(client)); - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - service.uninstall(MACHINE_ID, SECRET); - - // verifications - verify(clientSecretValidator).validate(client, SECRET); - assertThat(machine.getStatus()).isEqualTo(DeviceStatus.DELETED); - verify(machineRepository).save(machine); - verify(toolConnectionService).disconnectAll(MACHINE_ID); - verify(installedAgentService).disconnectAll(MACHINE_ID); - verify(deliveryTracker).complete(DeliveryType.CLIENT_UNINSTALL, MACHINE_ID, MACHINE_ID); - } - - @Test - void uninstall_alreadyDeleted_deliveryUntouched() { - // setup - machine.setStatus(DeviceStatus.DELETED); - when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(client)); - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - service.uninstall(MACHINE_ID, SECRET); - - // verifications - verify(machineRepository, never()).save(machine); - verifyNoInteractions(deliveryTracker); - } - - @Test - void uninstall_unknownClient_noop() { - // setup - when(oauthClientRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.empty()); - - // execution - service.uninstall(MACHINE_ID, SECRET); - - // verifications - verifyNoInteractions(clientSecretValidator); - verifyNoInteractions(machineRepository); - verifyNoInteractions(deliveryTracker); - } -} diff --git a/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java b/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java deleted file mode 100644 index 9252801f83..0000000000 --- a/openframe-client-core/src/test/java/com/openframe/client/service/InstalledAgentServiceTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.openframe.client.service; - -import com.openframe.client.exception.MachineNotFoundException; -import com.openframe.delivery.DeliveryTracker; -import com.openframe.data.document.device.Machine; -import com.openframe.data.document.installedagents.InstalledAgent; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.data.document.tool.ConnectionStatus; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.data.repository.installedagents.InstalledAgentRepository; -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.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class InstalledAgentServiceTest { - - private static final String MACHINE_ID = "mach-42"; - private static final String AGENT_TYPE = "tactical-agent"; - private static final String OLD_VERSION = "1.0.0"; - private static final String VERSION = "1.2.3"; - - @Mock private InstalledAgentRepository installedAgentRepository; - @Mock private MachineRepository machineRepository; - @Mock private DeliveryTracker deliveryTracker; - - @Captor private ArgumentCaptor installedAgentCaptor; - - @InjectMocks private InstalledAgentService service; - - private Machine machine; - private InstalledAgent existing; - - @BeforeEach - void setUp() { - machine = new Machine(); - machine.setMachineId(MACHINE_ID); - existing = new InstalledAgent(); - existing.setMachineId(MACHINE_ID); - existing.setAgentType(AGENT_TYPE); - existing.setVersion(OLD_VERSION); - existing.setStatus(ConnectionStatus.DISCONNECTED); - } - - @Test - void addInstalledAgent_newAgent_savedAndDeliveryCompleted() { - // setup - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - when(installedAgentRepository.findByMachineIdAndAgentType(MACHINE_ID, AGENT_TYPE)).thenReturn(Optional.empty()); - - // execution - service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false); - - // verifications - verify(installedAgentRepository).save(installedAgentCaptor.capture()); - assertThat(installedAgentCaptor.getValue().getVersion()).isEqualTo(VERSION); - assertThat(installedAgentCaptor.getValue().getStatus()).isEqualTo(ConnectionStatus.CONNECTED); - verify(deliveryTracker).complete(DeliveryType.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); - } - - @Test - void addInstalledAgent_existingAgent_versionUpdatedAndDeliveryCompleted() { - // setup - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - when(installedAgentRepository.findByMachineIdAndAgentType(MACHINE_ID, AGENT_TYPE)).thenReturn(Optional.of(existing)); - - // execution - service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false); - - // verifications - assertThat(existing.getVersion()).isEqualTo(VERSION); - assertThat(existing.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); - verify(installedAgentRepository).save(existing); - verify(deliveryTracker).complete(DeliveryType.TOOL_INSTALLATION, AGENT_TYPE, MACHINE_ID); - } - - @Test - void addInstalledAgent_unknownMachine_throwsAndDeliveryUntouched() { - // setup - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.empty()); - - // execution - MachineNotFoundException ex = assertThrows(MachineNotFoundException.class, - () -> service.addInstalledAgent(MACHINE_ID, AGENT_TYPE, VERSION, false)); - - // verifications - assertThat(ex.getMessage()).contains(MACHINE_ID); - verifyNoInteractions(deliveryTracker); - } -} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java index a4d9549d9d..9100972645 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/rmm/model/ScriptExecutionAcknowledgeMessage.java @@ -1,6 +1,5 @@ package com.openframe.data.nats.rmm.model; -import com.openframe.data.document.delivery.DeliveryType; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.AllArgsConstructor; import lombok.Builder; @@ -20,8 +19,4 @@ public class ScriptExecutionAcknowledgeMessage { private String machineId; private String scheduleId; private List scriptIds; - - // delivery types other than scripts ack through the same subject; null = legacy script ack - private DeliveryType type; - private String targetId; } diff --git a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java index d4295b0c6b..f23f850667 100644 --- a/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java +++ b/openframe-tool-agent-nats-installation/src/main/java/com/openframe/data/service/ToolInstallationService.java @@ -2,8 +2,7 @@ import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; -import com.openframe.data.nats.delivery.ToolInstallationDeliverySpec; -import com.openframe.delivery.DeliveryDispatcher; +import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -21,7 +20,7 @@ public class ToolInstallationService { private final IntegratedToolService integratedToolService; private final ToolCommandParamsResolver toolCommandParamsResolver; - private final DeliveryDispatcher deliveryDispatcher; + private final ToolInstallationNatsPublisher toolInstallationNatsPublisher; public void process(String machineId, IntegratedToolAgent toolAgent) { process(machineId, toolAgent, false); @@ -42,8 +41,7 @@ public void process(String machineId, IntegratedToolAgent toolAgent, boolean rei List runCommandArgs = toolAgent.getRunCommandArgs(); toolAgent.setRunCommandArgs(toolCommandParamsResolver.process(toolId, runCommandArgs)); - ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(machineId, toolAgent, tool, reinstall); - deliveryDispatcher.dispatch(seed); + toolInstallationNatsPublisher.publish(machineId, toolAgent, tool, reinstall); log.info("Published {} agent installation message for machine {}", toolId, machineId); } catch (Exception e) { // TODO: add fallback mechanism From bc6cfd0a25cc7d242c7d78562c0eaff733e9650a Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Wed, 16 Sep 2026 14:56:47 +0200 Subject: [PATCH 07/13] refactor(delivery): composite id scheme moves from the document into DeliveryId MachineDelivery is a plain @Data document like its neighbours; the id layout is engine knowledge used only by the recorder and the tracker. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../data/document/delivery/MachineDelivery.java | 6 ------ .../java/com/openframe/delivery/DeliveryId.java | 16 ++++++++++++++++ .../delivery/MongoDeliveryRecorder.java | 2 +- .../openframe/delivery/MongoDeliveryTracker.java | 4 ++-- .../delivery/DeliverySweepServiceTest.java | 2 +- .../delivery/MongoDeliveryTrackerTest.java | 2 +- 6 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java index 9508ef21f9..8f2b487cc8 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/delivery/MachineDelivery.java @@ -20,8 +20,6 @@ @CompoundIndex(name = "machine_delivery_sweep", def = "{'type': 1, 'status': 1, 'lastAttemptAt': 1}") public class MachineDelivery { - private static final String ID_SEPARATOR = ":"; - @Id private String id; @@ -47,8 +45,4 @@ public class MachineDelivery { @Indexed(name = "machine_delivery_ttl", expireAfterSeconds = 0) private Instant expiresAt; - - public static String id(DeliveryType type, String targetId, String machineId) { - return type.name() + ID_SEPARATOR + targetId + ID_SEPARATOR + machineId; - } } diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java new file mode 100644 index 0000000000..9543625800 --- /dev/null +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java @@ -0,0 +1,16 @@ +package com.openframe.delivery; + +import com.openframe.data.document.delivery.DeliveryType; + +// composite _id: re-dispatching the same command overwrites its row, and the tracker finds it without an index +final class DeliveryId { + + private static final String SEPARATOR = ":"; + + private DeliveryId() { + } + + static String of(DeliveryType type, String targetId, String machineId) { + return type.name() + SEPARATOR + targetId + SEPARATOR + machineId; + } +} diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java index b882a00f98..a937f2ddfa 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryRecorder.java @@ -31,7 +31,7 @@ public void record(DeliveryRequest request) { private MachineDelivery pendingRow(DeliveryRequest request) { Instant now = Instant.now(); - String id = MachineDelivery.id(request.getType(), request.getTargetId(), request.getMachineId()); + String id = DeliveryId.of(request.getType(), request.getTargetId(), request.getMachineId()); String payloadJson = toJson(request.getPayload()); return MachineDelivery.builder() .id(id) diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java index 38a433f4e9..2da4561bc5 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MongoDeliveryTracker.java @@ -23,7 +23,7 @@ public class MongoDeliveryTracker implements DeliveryTracker { @Override public void acknowledge(DeliveryType type, String targetId, String machineId) { - String id = MachineDelivery.id(type, targetId, machineId); + String id = DeliveryId.of(type, targetId, machineId); repository.findById(id) .filter(MongoDeliveryTracker::isPending) .ifPresent(this::markAcked); @@ -31,7 +31,7 @@ public void acknowledge(DeliveryType type, String targetId, String machineId) { @Override public void complete(DeliveryType type, String targetId, String machineId) { - String id = MachineDelivery.id(type, targetId, machineId); + String id = DeliveryId.of(type, targetId, machineId); repository.findById(id) .filter(MongoDeliveryTracker::isOpen) .ifPresent(this::markDone); diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java index acfbf60b7e..bffd6c591e 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/DeliverySweepServiceTest.java @@ -55,7 +55,7 @@ class DeliverySweepServiceTest { void setUp() { Instant dispatchedAt = Instant.now().minusSeconds(ACK_THRESHOLD * 2); delivery = MachineDelivery.builder() - .id(MachineDelivery.id(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID)) + .id(DeliveryId.of(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID)) .type(DeliveryType.TOOL_INSTALLATION) .targetId(TARGET_ID) .machineId(MACHINE_ID) diff --git a/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java index 1091ebd60e..fe9a8c66b1 100644 --- a/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java +++ b/openframe-machine-delivery/src/test/java/com/openframe/delivery/MongoDeliveryTrackerTest.java @@ -23,7 +23,7 @@ class MongoDeliveryTrackerTest { private static final String MACHINE_ID = "mach-42"; private static final String TARGET_ID = "tactical-agent"; - private static final String DELIVERY_ID = MachineDelivery.id(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); + private static final String DELIVERY_ID = DeliveryId.of(DeliveryType.TOOL_INSTALLATION, TARGET_ID, MACHINE_ID); @Mock private MachineDeliveryRepository repository; From e1950537919786e1c8b24f93ff5ef61b4ea053aa Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 11:17:43 +0200 Subject: [PATCH 08/13] refactor(delivery): client uninstall targets the openframe-client agent type, not the machine id Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../data/nats/delivery/ClientUninstallDeliverySpec.java | 9 +++++---- .../nats/delivery/ClientUninstallDeliverySpecTest.java | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java index 2818461ba1..f2b036a079 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java @@ -22,6 +22,9 @@ @ConditionalOnProperty("spring.cloud.stream.enabled") public class ClientUninstallDeliverySpec implements DeliverySpec { + // the target is the OpenFrame client itself, under the agentType it reports in installed-agent + public static final String CLIENT_AGENT_TYPE = "openframe-client"; + private final ClientUninstallNatsPublisher publisher; private final MachineRepository machineRepository; @@ -51,15 +54,13 @@ public Class getPayloadClass() { return ClientUninstallMessage.class; } - // targetId is the machine itself: the agent confirms over HTTP /api/agents/uninstall with X-Machine-Id @Override public DeliveryRequest request(Seed seed) { ClientUninstallMessage message = publisher.buildMessage(); - String machineId = seed.getMachineId(); return DeliveryRequest.builder() .type(DeliveryType.CLIENT_UNINSTALL) - .targetId(machineId) - .machineId(machineId) + .targetId(CLIENT_AGENT_TYPE) + .machineId(seed.getMachineId()) .payload(message) .build(); } diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java index 5e55928bb2..a7e61e5a9d 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java @@ -41,7 +41,7 @@ class ClientUninstallDeliverySpecTest { void setUp() { delivery = MachineDelivery.builder() .type(DeliveryType.CLIENT_UNINSTALL) - .targetId(MACHINE_ID) + .targetId(ClientUninstallDeliverySpec.CLIENT_AGENT_TYPE) .machineId(MACHINE_ID) .build(); machine = new Machine(); @@ -50,7 +50,7 @@ void setUp() { } @Test - void request_machine_targetIsMachineAndPayloadBuiltByPublisher() { + void request_machine_targetIsClientAgentAndPayloadBuiltByPublisher() { // setup when(publisher.buildMessage()).thenReturn(message); @@ -60,7 +60,7 @@ void request_machine_targetIsMachineAndPayloadBuiltByPublisher() { // verifications assertThat(request.getType()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); - assertThat(request.getTargetId()).isEqualTo(MACHINE_ID); + assertThat(request.getTargetId()).isEqualTo(ClientUninstallDeliverySpec.CLIENT_AGENT_TYPE); assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); assertThat(request.getPayload()).isSameAs(message); } From cad1d5442ce8eddb4e454da5e5614160d8418788 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 11:20:41 +0200 Subject: [PATCH 09/13] chore(delivery): pilot with one spec, tool installation; client uninstall follows separately Rolling the types out one at a time: the module lands with a single worked example and ClientUninstallDeliverySpec (plus its publisher overload) comes back in its own PR once TOOL_INSTALLATION has been through dev and stage. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../delivery/ClientUninstallDeliverySpec.java | 90 ------------------ .../ClientUninstallNatsPublisher.java | 9 +- .../ClientUninstallDeliverySpecTest.java | 95 ------------------- 3 files changed, 3 insertions(+), 191 deletions(-) delete mode 100644 openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java delete mode 100644 openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java deleted file mode 100644 index f2b036a079..0000000000 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpec.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.openframe.data.nats.delivery; - -import com.openframe.data.document.delivery.DeliveryFailure; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.data.document.delivery.MachineDelivery; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.delivery.DeliveryRequest; -import com.openframe.delivery.DeliverySeed; -import com.openframe.delivery.DeliverySpec; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty("spring.cloud.stream.enabled") -public class ClientUninstallDeliverySpec implements DeliverySpec { - - // the target is the OpenFrame client itself, under the agentType it reports in installed-agent - public static final String CLIENT_AGENT_TYPE = "openframe-client"; - - private final ClientUninstallNatsPublisher publisher; - private final MachineRepository machineRepository; - - @Getter - @AllArgsConstructor - public static class Seed implements DeliverySeed { - private final String machineId; - - @Override - public DeliveryType type() { - return DeliveryType.CLIENT_UNINSTALL; - } - } - - @Override - public DeliveryType getType() { - return DeliveryType.CLIENT_UNINSTALL; - } - - @Override - public Class getSeedClass() { - return Seed.class; - } - - @Override - public Class getPayloadClass() { - return ClientUninstallMessage.class; - } - - @Override - public DeliveryRequest request(Seed seed) { - ClientUninstallMessage message = publisher.buildMessage(); - return DeliveryRequest.builder() - .type(DeliveryType.CLIENT_UNINSTALL) - .targetId(CLIENT_AGENT_TYPE) - .machineId(seed.getMachineId()) - .payload(message) - .build(); - } - - @Override - public void publish(String machineId, ClientUninstallMessage payload) { - publisher.publish(machineId, payload); - } - - // the agent never ran the uninstall: keep the machine visible instead of parking it in PENDING_DELETION forever - @Override - public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { - String machineId = delivery.getMachineId(); - machineRepository.findByMachineId(machineId) - .filter(ClientUninstallDeliverySpec::isPendingDeletion) - .ifPresent(this::restoreOffline); - } - - private void restoreOffline(Machine machine) { - machine.setStatus(DeviceStatus.OFFLINE); - machineRepository.save(machine); - } - - private static boolean isPendingDeletion(Machine machine) { - return machine.getStatus() == DeviceStatus.PENDING_DELETION; - } -} diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java index 4ff84c3ad1..064a827672 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ClientUninstallNatsPublisher.java @@ -21,17 +21,14 @@ public class ClientUninstallNatsPublisher { private final NatsMessagePublisher natsMessagePublisher; public void publish(String machineId) { - ClientUninstallMessage message = buildMessage(); - publish(machineId, message); - } - - public void publish(String machineId, ClientUninstallMessage message) { String topicName = format(TOPIC_NAME_TEMPLATE, machineId); + ClientUninstallMessage message = buildMessage(); natsMessagePublisher.publishPersistent(topicName, message); + log.info("Published client uninstall command for machine {}", machineId); } - public ClientUninstallMessage buildMessage() { + private ClientUninstallMessage buildMessage() { ClientUninstallMessage message = new ClientUninstallMessage(); message.setIssuedAt(Instant.now().toString()); return message; diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java deleted file mode 100644 index a7e61e5a9d..0000000000 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ClientUninstallDeliverySpecTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.openframe.data.nats.delivery; - -import com.openframe.data.document.delivery.DeliveryFailure; -import com.openframe.data.document.delivery.DeliveryType; -import com.openframe.data.document.delivery.MachineDelivery; -import com.openframe.data.document.device.DeviceStatus; -import com.openframe.data.document.device.Machine; -import com.openframe.data.nats.model.ClientUninstallMessage; -import com.openframe.data.nats.publisher.ClientUninstallNatsPublisher; -import com.openframe.data.repository.device.MachineRepository; -import com.openframe.delivery.DeliveryRequest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class ClientUninstallDeliverySpecTest { - - private static final String MACHINE_ID = "mach-42"; - - @Mock private ClientUninstallNatsPublisher publisher; - @Mock private MachineRepository machineRepository; - - @InjectMocks private ClientUninstallDeliverySpec spec; - - private MachineDelivery delivery; - private Machine machine; - private ClientUninstallMessage message; - - @BeforeEach - void setUp() { - delivery = MachineDelivery.builder() - .type(DeliveryType.CLIENT_UNINSTALL) - .targetId(ClientUninstallDeliverySpec.CLIENT_AGENT_TYPE) - .machineId(MACHINE_ID) - .build(); - machine = new Machine(); - machine.setMachineId(MACHINE_ID); - message = new ClientUninstallMessage(); - } - - @Test - void request_machine_targetIsClientAgentAndPayloadBuiltByPublisher() { - // setup - when(publisher.buildMessage()).thenReturn(message); - - // execution - ClientUninstallDeliverySpec.Seed seed = new ClientUninstallDeliverySpec.Seed(MACHINE_ID); - DeliveryRequest request = spec.request(seed); - - // verifications - assertThat(request.getType()).isEqualTo(DeliveryType.CLIENT_UNINSTALL); - assertThat(request.getTargetId()).isEqualTo(ClientUninstallDeliverySpec.CLIENT_AGENT_TYPE); - assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); - assertThat(request.getPayload()).isSameAs(message); - } - - @Test - void onFailed_machinePendingDeletion_restoredToOffline() { - // setup - machine.setStatus(DeviceStatus.PENDING_DELETION); - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - spec.onFailed(delivery, DeliveryFailure.EXHAUSTED); - - // verifications - assertThat(machine.getStatus()).isEqualTo(DeviceStatus.OFFLINE); - verify(machineRepository).save(machine); - } - - @Test - void onFailed_machineAlreadyDeleted_untouched() { - // setup - machine.setStatus(DeviceStatus.DELETED); - when(machineRepository.findByMachineId(MACHINE_ID)).thenReturn(Optional.of(machine)); - - // execution - spec.onFailed(delivery, DeliveryFailure.OFFLINE); - - // verifications - assertThat(machine.getStatus()).isEqualTo(DeviceStatus.DELETED); - verify(machineRepository, never()).save(machine); - } -} From 92d7d0108ae426c86db2674701c78d120bb3167e Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 11:26:17 +0200 Subject: [PATCH 10/13] chore(delivery): metrics and online-status beans exist only when openframe.delivery.enabled=true Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../src/main/java/com/openframe/delivery/DeliveryMetrics.java | 2 ++ .../main/java/com/openframe/delivery/MachineOnlineStatus.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java index a8ce1b7f72..75a7750e3d 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryMetrics.java @@ -4,12 +4,14 @@ import com.openframe.data.document.delivery.DeliveryType; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.util.Locale; @Component @RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class DeliveryMetrics { private static final String RETRIED_COUNTER = "openframe.delivery.retried"; diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java index 2a5238174c..2763d75558 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java @@ -4,6 +4,7 @@ import com.openframe.data.document.device.Machine; import com.openframe.data.repository.device.MachineRepository; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.util.List; @@ -14,6 +15,7 @@ // the only place the engine reads the Machine document; heartbeat-driven status today @Component @RequiredArgsConstructor +@ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") public class MachineOnlineStatus { private final MachineRepository machineRepository; From d73b4ca0f4a61802ef5ee17736587c330cc2800b Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 12:09:59 +0200 Subject: [PATCH 11/13] chore(delivery): onFailed comment says why it is empty, not what the recorder does Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../data/nats/delivery/ToolInstallationDeliverySpec.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java index eb55beb626..ede89c62ea 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java @@ -75,6 +75,6 @@ public void publish(String machineId, ToolInstallationMessage payload) { @Override public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { - // nothing beyond FAILED + metric: an install is safe to re-run by hand + // intentionally empty: a failed install leaves nothing to compensate } } From aec02461ce2a99fde9a6cdae87cd7f05589827b1 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 12:11:19 +0200 Subject: [PATCH 12/13] chore(delivery): keep only the comments that warn about a trap Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../data/nats/delivery/ToolInstallationDeliverySpec.java | 2 +- .../src/main/java/com/openframe/delivery/DeliveryId.java | 1 - .../main/java/com/openframe/delivery/DeliveryProperties.java | 1 - .../java/com/openframe/delivery/DeliverySweepScheduler.java | 1 - .../main/java/com/openframe/delivery/MachineOnlineStatus.java | 1 - .../main/java/com/openframe/delivery/NoopDeliveryRecorder.java | 1 - 6 files changed, 1 insertion(+), 6 deletions(-) diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java index ede89c62ea..ba52cd86ff 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java @@ -52,7 +52,7 @@ public Class getPayloadClass() { return ToolInstallationMessage.class; } - // targetId is the tool agent key: the agent reports it back as agentType in installed-agent + // targetId must equal the agentType the agent sends in installed-agent, or complete() never finds the row @Override public DeliveryRequest request(Seed seed) { IntegratedToolAgent toolAgent = seed.getToolAgent(); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java index 9543625800..0e1b50c43c 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryId.java @@ -2,7 +2,6 @@ import com.openframe.data.document.delivery.DeliveryType; -// composite _id: re-dispatching the same command overwrites its row, and the tracker finds it without an index final class DeliveryId { private static final String SEPARATOR = ":"; diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java index 581be05aaf..1bfb89ec26 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliveryProperties.java @@ -40,7 +40,6 @@ public Policy resolve(DeliveryType type) { return override.mergeOver(defaults); } - // row-level overrides come from ScheduleScript (per-schedule offline behaviour) and win over the type policy public Policy resolve(MachineDelivery delivery) { Policy typePolicy = resolve(delivery.getType()); Policy rowOverride = new Policy(); diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java index cb15f59e18..801ac34a95 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/DeliverySweepScheduler.java @@ -7,7 +7,6 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -// only the service that owns the sweep (client) turns this on; every publisher of deliveries gets the rest @Slf4j @Component @RequiredArgsConstructor diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java index 2763d75558..b3ba543c38 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/MachineOnlineStatus.java @@ -12,7 +12,6 @@ import static java.util.stream.Collectors.toSet; -// the only place the engine reads the Machine document; heartbeat-driven status today @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "true") diff --git a/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java index c6a9b33eca..1f26787029 100644 --- a/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java +++ b/openframe-machine-delivery/src/main/java/com/openframe/delivery/NoopDeliveryRecorder.java @@ -3,7 +3,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -// flag off = today's behaviour: the command is published, no row is kept @Component @ConditionalOnProperty(name = "openframe.delivery.enabled", havingValue = "false", matchIfMissing = true) public class NoopDeliveryRecorder implements DeliveryRecorder { From 47a0a8abaa7eea3d3def0effd5a10581651d78a9 Mon Sep 17 00:00:00 2001 From: semen-flamingo Date: Thu, 17 Sep 2026 12:13:46 +0200 Subject: [PATCH 13/13] refactor(delivery): the spec builds and publishes the tool-installation message itself No wrapper around ToolInstallationNatsPublisher: the spec owns the payload mapping and the subject, so the publisher stays untouched here and is deleted in the usage PR once ToolInstallationService dispatches through it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BnYUpYgKvuMw6hB5VZSimY --- .../ToolInstallationDeliverySpec.java | 75 +++++++++++++++++-- .../ToolInstallationNatsPublisher.java | 8 +- .../ToolInstallationDeliverySpecTest.java | 56 +++++++++++--- 3 files changed, 116 insertions(+), 23 deletions(-) diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java index ba52cd86ff..aaccc9c2d8 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpec.java @@ -5,8 +5,12 @@ import com.openframe.data.document.delivery.MachineDelivery; import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; +import com.openframe.data.document.toolagent.ToolAgentAsset; +import com.openframe.data.document.toolagent.ToolAgentAssetSource; +import com.openframe.data.nats.mapper.DownloadConfigurationMapper; +import com.openframe.data.nats.mapper.LocalFilenameConfigurationMapper; import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import com.openframe.data.nats.publisher.NatsMessagePublisher; import com.openframe.delivery.DeliveryRequest; import com.openframe.delivery.DeliverySeed; import com.openframe.delivery.DeliverySpec; @@ -16,12 +20,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import java.util.List; + +import static java.lang.String.format; +import static java.util.Objects.requireNonNullElse; + @Component @RequiredArgsConstructor @ConditionalOnProperty("spring.cloud.stream.enabled") public class ToolInstallationDeliverySpec implements DeliverySpec { - private final ToolInstallationNatsPublisher publisher; + private static final String SUBJECT_TEMPLATE = "machine.%s.tool-installation"; + + private final NatsMessagePublisher natsMessagePublisher; + private final DownloadConfigurationMapper downloadConfigurationMapper; + private final LocalFilenameConfigurationMapper localFilenameConfigurationMapper; @Getter @AllArgsConstructor @@ -56,9 +69,7 @@ public Class getPayloadClass() { @Override public DeliveryRequest request(Seed seed) { IntegratedToolAgent toolAgent = seed.getToolAgent(); - IntegratedTool tool = seed.getTool(); - boolean reinstall = seed.isReinstall(); - ToolInstallationMessage message = publisher.buildMessage(toolAgent, tool, reinstall); + ToolInstallationMessage message = buildMessage(toolAgent, seed.getTool(), seed.isReinstall()); String targetId = toolAgent.getKey(); return DeliveryRequest.builder() .type(DeliveryType.TOOL_INSTALLATION) @@ -70,11 +81,63 @@ public DeliveryRequest request(Seed seed) { @Override public void publish(String machineId, ToolInstallationMessage payload) { - publisher.publish(machineId, payload); + String subject = format(SUBJECT_TEMPLATE, machineId); + natsMessagePublisher.publishPersistent(subject, payload); } @Override public void onFailed(MachineDelivery delivery, DeliveryFailure failure) { // intentionally empty: a failed install leaves nothing to compensate } + + private ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { + String version = toolAgent.getVersion(); + ToolInstallationMessage message = new ToolInstallationMessage(); + message.setToolAgentId(toolAgent.getKey()); + message.setToolId(requireNonNullElse(toolAgent.getToolId(), "")); + message.setToolType(requireNonNullElse(tool.getToolType(), "")); + message.setVersion(version); + message.setSessionType(toolAgent.getSessionType()); + message.setDownloadConfigurations(downloadConfigurationMapper.map(toolAgent.getDownloadConfigurations(), version)); + message.setAssets(mapAssets(toolAgent.getAssets())); + message.setInstallationCommandArgs(toolAgent.getInstallationCommandArgs()); + message.setUninstallationCommandArgs(toolAgent.getUninstallationCommandArgs()); + message.setRunCommandArgs(toolAgent.getRunCommandArgs()); + message.setToolAgentIdCommandArgs(toolAgent.getAgentToolIdCommandArgs()); + message.setReinstall(reinstall); + return message; + } + + private List mapAssets(List assets) { + if (assets == null) { + return null; + } + return assets.stream() + .map(this::mapAsset) + .toList(); + } + + private ToolInstallationMessage.Asset mapAsset(ToolAgentAsset asset) { + String version = asset.getVersion(); + ToolInstallationMessage.Asset messageAsset = new ToolInstallationMessage.Asset(); + messageAsset.setId(asset.getId()); + messageAsset.setVersion(version); + messageAsset.setLocalFilenameConfiguration(localFilenameConfigurationMapper.map(asset.getLocalFilenameConfiguration())); + messageAsset.setDownloadConfigurations(downloadConfigurationMapper.map(asset.getDownloadConfigurations(), version)); + messageAsset.setSource(mapAssetSource(asset.getSource())); + messageAsset.setPath(asset.getPath()); + messageAsset.setExecutable(asset.isExecutable()); + return messageAsset; + } + + private static ToolInstallationMessage.AssetSource mapAssetSource(ToolAgentAssetSource source) { + if (source == null) { + return null; + } + return switch (source) { + case ARTIFACTORY -> ToolInstallationMessage.AssetSource.ARTIFACTORY; + case TOOL_API -> ToolInstallationMessage.AssetSource.TOOL_API; + case GITHUB -> ToolInstallationMessage.AssetSource.GITHUB; + }; + } } diff --git a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java index 00e0f459f5..f0c47b9ded 100644 --- a/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java +++ b/openframe-data-nats/src/main/java/com/openframe/data/nats/publisher/ToolInstallationNatsPublisher.java @@ -35,12 +35,8 @@ public void publish(String machineId, IntegratedToolAgent toolAgent, IntegratedT } public void publish(String machineId, IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { - ToolInstallationMessage message = buildMessage(toolAgent, tool, reinstall); - publish(machineId, message); - } - - public void publish(String machineId, ToolInstallationMessage message) { String topicName = buildTopicName(machineId); + ToolInstallationMessage message = buildMessage(toolAgent, tool, reinstall); natsMessagePublisher.publishPersistent(topicName, message); } @@ -52,7 +48,7 @@ private ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, Inte return buildMessage(toolAgent, tool, false); } - public ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { + private ToolInstallationMessage buildMessage(IntegratedToolAgent toolAgent, IntegratedTool tool, boolean reinstall) { ToolInstallationMessage message = new ToolInstallationMessage(); message.setToolAgentId(toolAgent.getKey()); // TODO: need refactoring diff --git a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java index d164477244..1a4c3511f3 100644 --- a/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java +++ b/openframe-data-nats/src/test/java/com/openframe/data/nats/delivery/ToolInstallationDeliverySpecTest.java @@ -3,8 +3,10 @@ import com.openframe.data.document.delivery.DeliveryType; import com.openframe.data.document.tool.IntegratedTool; import com.openframe.data.document.toolagent.IntegratedToolAgent; +import com.openframe.data.nats.mapper.DownloadConfigurationMapper; +import com.openframe.data.nats.mapper.LocalFilenameConfigurationMapper; import com.openframe.data.nats.model.ToolInstallationMessage; -import com.openframe.data.nats.publisher.ToolInstallationNatsPublisher; +import com.openframe.data.nats.publisher.NatsMessagePublisher; import com.openframe.delivery.DeliveryRequest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,6 +15,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,48 +26,78 @@ class ToolInstallationDeliverySpecTest { private static final String MACHINE_ID = "mach-42"; private static final String TOOL_AGENT_KEY = "tactical-agent"; + private static final String TOOL_ID = "tactical"; + private static final String TOOL_TYPE = "TACTICAL"; + private static final String VERSION = "1.2.3"; + private static final List INSTALL_ARGS = List.of("--silent"); - @Mock private ToolInstallationNatsPublisher publisher; + @Mock private NatsMessagePublisher natsMessagePublisher; + @Mock private DownloadConfigurationMapper downloadConfigurationMapper; + @Mock private LocalFilenameConfigurationMapper localFilenameConfigurationMapper; @InjectMocks private ToolInstallationDeliverySpec spec; private IntegratedToolAgent toolAgent; private IntegratedTool tool; - private ToolInstallationMessage message; @BeforeEach void setUp() { toolAgent = new IntegratedToolAgent(); toolAgent.setKey(TOOL_AGENT_KEY); + toolAgent.setToolId(TOOL_ID); + toolAgent.setVersion(VERSION); + toolAgent.setInstallationCommandArgs(INSTALL_ARGS); tool = new IntegratedTool(); - message = new ToolInstallationMessage(); - message.setToolAgentId(TOOL_AGENT_KEY); + tool.setToolType(TOOL_TYPE); } @Test - void request_toolAgent_targetIsAgentKeyAndPayloadBuiltByPublisher() { + void request_toolAgent_messageBuiltAndTargetIsAgentKey() { // setup - when(publisher.buildMessage(toolAgent, tool, true)).thenReturn(message); + when(downloadConfigurationMapper.map(null, VERSION)).thenReturn(List.of()); + ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(MACHINE_ID, toolAgent, tool, true); // execution - ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(MACHINE_ID, toolAgent, tool, true); DeliveryRequest request = spec.request(seed); // verifications assertThat(request.getType()).isEqualTo(DeliveryType.TOOL_INSTALLATION); assertThat(request.getTargetId()).isEqualTo(TOOL_AGENT_KEY); assertThat(request.getMachineId()).isEqualTo(MACHINE_ID); - assertThat(request.getPayload()).isSameAs(message); + ToolInstallationMessage message = request.getPayload(); + assertThat(message.getToolAgentId()).isEqualTo(TOOL_AGENT_KEY); + assertThat(message.getToolId()).isEqualTo(TOOL_ID); + assertThat(message.getToolType()).isEqualTo(TOOL_TYPE); + assertThat(message.getVersion()).isEqualTo(VERSION); + assertThat(message.getInstallationCommandArgs()).isEqualTo(INSTALL_ARGS); + assertThat(message.isReinstall()).isTrue(); + } + + @Test + void request_toolWithoutIdAndType_emptyStringsNotNulls() { + // setup + toolAgent.setToolId(null); + tool.setToolType(null); + when(downloadConfigurationMapper.map(null, VERSION)).thenReturn(List.of()); + ToolInstallationDeliverySpec.Seed seed = new ToolInstallationDeliverySpec.Seed(MACHINE_ID, toolAgent, tool, false); + + // execution + DeliveryRequest request = spec.request(seed); + + // verifications + assertThat(request.getPayload().getToolId()).isEmpty(); + assertThat(request.getPayload().getToolType()).isEmpty(); } @Test - void publish_payload_delegatedToPublisher() { + void publish_payload_sentToMachineSubject() { // setup + ToolInstallationMessage message = new ToolInstallationMessage(); // execution spec.publish(MACHINE_ID, message); // verifications - verify(publisher).publish(MACHINE_ID, message); + verify(natsMessagePublisher).publishPersistent("machine.mach-42.tool-installation", message); } }