diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/schedule/ScriptScheduleResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/schedule/ScriptScheduleResponse.java index 505262ada5..eec2413cfd 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/schedule/ScriptScheduleResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/schedule/ScriptScheduleResponse.java @@ -54,4 +54,5 @@ public class ScriptScheduleResponse { private Instant statusChangedAt; private Instant createdAt; private Instant updatedAt; + private boolean testScript; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptResponse.java b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptResponse.java index 452db6ff0f..b8bb38e390 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptResponse.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/dto/rmm/script/ScriptResponse.java @@ -36,4 +36,5 @@ public class ScriptResponse { private Instant statusChangedAt; private Instant createdAt; private Instant updatedAt; + private boolean testScript; } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptMapper.java index 5fdb592bd2..53f918c39f 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptMapper.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptMapper.java @@ -8,21 +8,17 @@ import com.openframe.data.document.rmm.script.Script; import com.openframe.data.document.rmm.script.ScriptEnvVar; import com.openframe.data.document.rmm.script.ScriptStatus; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.List; -/** - * Pure entity ↔ DTO mapping for scripts. Lives in {@code openframe-api-lib} - * so it can be reused by any service that talks to the script repository, - * regardless of transport (GraphQL / REST / messaging). - * - *

GraphQL-specific concerns (cursor pagination, Relay Connection / Edge - * envelope) live in {@code GraphQLScriptMapper} alongside the DGS resolver. - */ @Component public class ScriptMapper { + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + public Script toEntity(String tenantId, CreateScriptInput input) { return Script.builder() .tenantId(tenantId) @@ -35,6 +31,7 @@ public Script toEntity(String tenantId, CreateScriptInput input) { .defaultTimeoutSeconds(input.getDefaultTimeoutSeconds()) .defaultArgs(input.getDefaultArgs()) .envVars(ScriptEnvVarMapper.toEntity(input.getEnvVars())) + .testScript(testModeEnabled) .build(); } @@ -67,6 +64,7 @@ public ScriptResponse toResponse(Script entity) { .statusChangedAt(entity.getStatusChangedAt()) .createdAt(entity.getCreatedAt()) .updatedAt(entity.getUpdatedAt()) + .testScript(entity.isTestScript()) .build(); } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptScheduleMapper.java b/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptScheduleMapper.java index b768ad0468..1b85883309 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptScheduleMapper.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/mapper/ScriptScheduleMapper.java @@ -11,6 +11,7 @@ import com.openframe.data.document.rmm.schedule.ScheduleScriptTrigger; import com.openframe.data.document.rmm.schedule.ScheduleTimeReference; import com.openframe.data.document.rmm.script.ScriptStatus; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.List; @@ -23,6 +24,9 @@ @Component public class ScriptScheduleMapper { + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + public ScheduleScript toEntity(String tenantId, CreateScriptScheduleInput input) { return ScheduleScript.builder() .tenantId(tenantId) @@ -37,6 +41,7 @@ public ScheduleScript toEntity(String tenantId, CreateScriptScheduleInput input) .reconnectWindowSeconds(input.getReconnectWindowSeconds()) .startAt(input.getStartAt()) .repeat(input.getRepeat()) + .testScript(testModeEnabled) .build(); } @@ -94,6 +99,7 @@ public ScriptScheduleResponse toResponse(ScheduleScript entity) { .statusChangedAt(entity.getStatusChangedAt()) .createdAt(entity.getCreatedAt()) .updatedAt(entity.getUpdatedAt()) + .testScript(entity.isTestScript()) .build(); } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandDispatchService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandDispatchService.java index d9bf16edcc..b2b9e69e65 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandDispatchService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandDispatchService.java @@ -10,6 +10,7 @@ import com.openframe.data.nats.rmm.publisher.CommandNatsPublisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @@ -37,6 +38,9 @@ public class CommandDispatchService { private final DeviceService deviceService; private final CommandExecutionService commandExecutionService; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + public DispatchResponse runCommand(RunCommandInput input) { deviceService.verifyDispatchable(input.getMachineId()); @@ -76,7 +80,8 @@ public DispatchResponse batchRunCommand(BatchRunCommandInput input, String initi // Persist one RUNNING row per machine (tenant-scoped, via the service) before // anything hits the wire — the agent's result transitions each row later. commandExecutionService.createBatch(executionId, input.getCommand(), input.getShell(), - machineIds, input.getPrivilegeLevel(), input.getTimeoutSeconds(), initiatedBy); + machineIds, input.getPrivilegeLevel(), input.getTimeoutSeconds(), initiatedBy, + testModeEnabled); // Fan out the same payload (one executionId) to every machine. CommandMessage message = CommandMessage.builder() diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandExecutionService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandExecutionService.java index fc1f89a8b7..9d24fc6937 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandExecutionService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/command/CommandExecutionService.java @@ -38,11 +38,12 @@ public List createBatch(String executionId, List machineIds, PrivilegeLevel privilegeLevel, Integer timeoutSeconds, - String initiatedBy) { + String initiatedBy, + boolean testCommand) { Instant now = Instant.now(); List rows = machineIds.stream() .map(machineId -> buildRunningRow(executionId, command, shell, machineId, - privilegeLevel, timeoutSeconds, initiatedBy, now)) + privilegeLevel, timeoutSeconds, initiatedBy, now, testCommand)) .toList(); List saved = commandExecutionRepository.saveAll(rows); log.info("Persisted batch command execution rows: executionId={} machineCount={} initiatedBy={} status=RUNNING", @@ -72,7 +73,8 @@ private CommandExecution buildRunningRow(String executionId, PrivilegeLevel privilegeLevel, Integer timeoutSeconds, String initiatedBy, - Instant now) { + Instant now, + boolean testCommand) { return CommandExecution.builder() .tenantId(tenantIdProvider.getTenantId()) .executionId(executionId) @@ -85,6 +87,7 @@ private CommandExecution buildRunningRow(String executionId, .status(ExecutionStatus.RUNNING) .dispatchedAt(now) .statusChangedAt(now) + .testCommand(testCommand) .build(); } } diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptDispatchService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptDispatchService.java index 696b26d88b..ba63a4308d 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptDispatchService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptDispatchService.java @@ -27,6 +27,7 @@ import com.openframe.data.service.TenantIdProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @@ -64,6 +65,8 @@ public class ScriptDispatchService { private final ScheduleScriptExecutionRepository scheduleScriptExecutionRepository; private final TenantIdProvider tenantIdProvider; private final ScriptTimeoutValidator timeoutValidator; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; public DispatchResponse runScript(RunScriptInput input, String initiatedBy, ExecutionSource source) { timeoutValidator.validate(input.getTimeoutSeconds()); @@ -77,7 +80,8 @@ public DispatchResponse runScript(RunScriptInput input, String initiatedBy, Exec // Persist the effective timeout on the row so the watchdog can derive a // per-execution stuck-threshold from it. scriptExecutionService.create(executionId, script.getId(), - input.getMachineId(), input.getPrivilegeLevel(), timeoutSeconds, initiatedBy, source); + input.getMachineId(), input.getPrivilegeLevel(), timeoutSeconds, initiatedBy, source, + testModeEnabled); ScriptMessage message = ScriptMessage.builder() .executionId(executionId) @@ -185,6 +189,7 @@ public DispatchResponse runSchedule(String scheduleId, String initiatedBy) { .status(ExecutionStatus.RUNNING) .totalMachineCount(machineIds.size()) .dispatchedAt(now) + .testScript(testModeEnabled) .build()); // 2. Leaves: N × M ScriptExecution rows (persist per-script batch), so the watchdog @@ -196,7 +201,7 @@ public DispatchResponse runSchedule(String scheduleId, String initiatedBy) { scriptExecutionService.createBatch(executionId, script.getId(), scheduleId, machineIds, script.getPrivilegeLevel(), effectiveTimeout(null, script.getDefaultTimeoutSeconds()), - initiatedBy, ExecutionSource.SCHEDULED); + initiatedBy, ExecutionSource.SCHEDULED, testModeEnabled); } // 3. Build the batched agent payload once — shared across every target machine. Per-script @@ -245,7 +250,8 @@ private DispatchResponse dispatchBatch(String executionId, ScriptResponse script // Persist the effective timeout per row so the watchdog can derive a // per-execution stuck-threshold from it. - scriptExecutionService.createBatch(executionId, script.getId(), null, machineIds, privilegeLevel, timeoutSeconds, initiatedBy, source); + scriptExecutionService.createBatch(executionId, script.getId(), null, machineIds, privilegeLevel, + timeoutSeconds, initiatedBy, source, testModeEnabled); List args = ScriptArgsTokenizer.tokenize(argsOverride != null ? argsOverride : script.getDefaultArgs()); List envVars = mergeEnvVars(script.getEnvVars(), envVarsOverride); diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptExecutionService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptExecutionService.java index 8a7f9106ca..4907f79e8a 100644 --- a/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptExecutionService.java +++ b/openframe-api-lib/src/main/java/com/openframe/api/service/rmm/script/ScriptExecutionService.java @@ -69,7 +69,8 @@ public ScriptExecutionResponse create(String executionId, PrivilegeLevel privilegeLevel, Integer timeoutSeconds, String initiatedBy, - ExecutionSource source) { + ExecutionSource source, + boolean testScript) { // Single ad-hoc run (runScript) never originates from a schedule → scheduleId null. List saved = scriptExecutionRepository.saveRunning(RunningExecutionRows.builder() .tenantId(tenantIdProvider.getTenantId()) @@ -80,6 +81,7 @@ public ScriptExecutionResponse create(String executionId, .timeoutSeconds(timeoutSeconds) .initiatedBy(initiatedBy) .source(source) + .testScript(testScript) .build()); log.info("Persisted execution row: executionId={} scriptId={} machineId={} initiatedBy={} source={} status=RUNNING", executionId, scriptId, machineId, initiatedBy, source); @@ -101,7 +103,8 @@ public List createBatch(String executionId, PrivilegeLevel privilegeLevel, Integer timeoutSeconds, String initiatedBy, - ExecutionSource source) { + ExecutionSource source, + boolean testScript) { List saved = scriptExecutionRepository.saveRunning(RunningExecutionRows.builder() .tenantId(tenantIdProvider.getTenantId()) .executionId(executionId) @@ -112,6 +115,7 @@ public List createBatch(String executionId, .timeoutSeconds(timeoutSeconds) .initiatedBy(initiatedBy) .source(source) + .testScript(testScript) .build()); log.info("Persisted batch execution rows: executionId={} scriptId={} scheduleId={} machineCount={} initiatedBy={} source={} status=RUNNING", executionId, scriptId, scheduleId, machineIds.size(), initiatedBy, source); @@ -152,6 +156,7 @@ public List createSoftwareBatch(String executionId, .packageManager(packageManager) .packageName(packageName) .softwareAction(softwareAction) + .testScript(false) .build()); log.info("Persisted software batch rows: executionId={} scriptId={} packageManager={} packageName={} action={} machineCount={} initiatedBy={} source={} status=RUNNING", executionId, scriptId, packageManager, packageName, softwareAction, machineIds.size(), initiatedBy, source); diff --git a/openframe-api-service-core/src/main/resources/schema/script-schedule.graphqls b/openframe-api-service-core/src/main/resources/schema/script-schedule.graphqls index 9ba57c48ed..5e0b12a7c0 100644 --- a/openframe-api-service-core/src/main/resources/schema/script-schedule.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/script-schedule.graphqls @@ -152,6 +152,9 @@ type ScriptSchedule implements Node { updatedAt: Instant """The creating user (resolved from the internal createdBy id via the user DataLoader).""" author: User + """When true, this is a monitoring / QA fixture. Dispatch still runs, user-facing reads hide it. + Immutable at create-time.""" + testScript: Boolean! } enum ScriptScheduleTrigger { diff --git a/openframe-api-service-core/src/main/resources/schema/script.graphqls b/openframe-api-service-core/src/main/resources/schema/script.graphqls index c3f4417158..d1a3e77045 100644 --- a/openframe-api-service-core/src/main/resources/schema/script.graphqls +++ b/openframe-api-service-core/src/main/resources/schema/script.graphqls @@ -110,6 +110,10 @@ type Script implements Node { updatedAt: Instant """The creating user (resolved from the internal createdBy id via the user DataLoader). The raw createdBy id is not exposed — filter by author via authorIds.""" author: User + """When true, this is a monitoring / QA fixture created by the monitoring pipeline. Immutable at + create-time. Set by the pipeline via createScript; hidden from user-facing list/facets/getById + reads (query-builder shield) — visible only on direct id lookup performed by the pipeline itself.""" + testScript: Boolean! } type ScriptEnvVar { diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandDispatchServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandDispatchServiceTest.java index 4f38dcad59..ad3833b39a 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandDispatchServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandDispatchServiceTest.java @@ -216,7 +216,7 @@ void batchRunCommand_persistsPendingThenFansOut() { // carrying the shared executionId + command/shell/privilege/timeout + initiatedBy. verify(commandExecutionService).createBatch( eq(response.getExecutionId()), eq("uptime"), eq(ScriptShell.BASH), - eq(machines), eq(PrivilegeLevel.ADMIN), eq(30), eq(INITIATED_BY)); + eq(machines), eq(PrivilegeLevel.ADMIN), eq(30), eq(INITIATED_BY), eq(false)); // One publish per machine — every published payload must carry the FULL wire contract // (executionId, code, shell, privilegeLevel, timeout), not just executionId+code, so a @@ -242,7 +242,7 @@ void batchRunCommand_savesBeforePublishing() { InOrder order = inOrder(commandExecutionService, commandNatsPublisher); order.verify(commandExecutionService).createBatch(any(), any(), any(), - org.mockito.ArgumentMatchers.anyList(), any(), any(), any()); + org.mockito.ArgumentMatchers.anyList(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean()); order.verify(commandNatsPublisher).publishCommand(eq("machine-1"), any(CommandMessage.class)); } @@ -253,7 +253,7 @@ void batchRunCommand_dedupsMachineIds() { commandDispatchService.batchRunCommand(batchInput(List.of("machine-1", "machine-1")), INITIATED_BY); verify(commandExecutionService).createBatch(any(), any(), any(), - eq(List.of("machine-1")), any(), any(), any()); + eq(List.of("machine-1")), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean()); verify(commandNatsPublisher).publishCommand(eq("machine-1"), any(CommandMessage.class)); } diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandExecutionServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandExecutionServiceTest.java index a320df0e75..7cb9d15745 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandExecutionServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/CommandExecutionServiceTest.java @@ -48,7 +48,7 @@ void createBatch_persistsOneRunningRowPerMachine() { when(commandExecutionRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); service.createBatch(EXECUTION_ID, "uptime", ScriptShell.BASH, List.of("m-1", "m-2"), - PrivilegeLevel.ADMIN, 30, "alice"); + PrivilegeLevel.ADMIN, 30, "alice", false); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptDispatchServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptDispatchServiceTest.java index b536af3706..3c49ac43f4 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptDispatchServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptDispatchServiceTest.java @@ -147,7 +147,8 @@ void runScript_persistsExecutionRowBeforeNatsPublish() { eq(PrivilegeLevel.ADMIN), eq(60), // effective timeout (script default, no override) — persisted for the watchdog eq(USER_ID), - eq(ExecutionSource.MANUAL)); + eq(ExecutionSource.MANUAL), + eq(false)); inOrder.verify(scriptNatsPublisher).publishScript(eq(MACHINE_ID), any(ScriptMessage.class)); } @@ -163,7 +164,8 @@ void runScript_nullInitiatedBy_persistedAsNull() { eq(PrivilegeLevel.ADMIN), eq(60), eq((String) null), - eq(ExecutionSource.MANUAL)); + eq(ExecutionSource.MANUAL), + eq(false)); } @Test @@ -232,7 +234,7 @@ void runScript_persistsEffectiveTimeoutOnRow() { scriptDispatchService.runScript(input, USER_ID, ExecutionSource.MANUAL); verify(scriptExecutionService).create( - any(String.class), eq(SCRIPT_ID), eq(MACHINE_ID), eq(PrivilegeLevel.ADMIN), eq(90), eq(USER_ID), eq(ExecutionSource.MANUAL)); + any(String.class), eq(SCRIPT_ID), eq(MACHINE_ID), eq(PrivilegeLevel.ADMIN), eq(90), eq(USER_ID), eq(ExecutionSource.MANUAL), eq(false)); assertThat(capturePublished().getTimeoutSeconds()).isEqualTo(90); } @@ -344,7 +346,8 @@ void batchRunScript_fansOutWithSharedExecutionId() { eq(PrivilegeLevel.ADMIN), eq(60), eq(USER_ID), - eq(ExecutionSource.MANUAL)); + eq(ExecutionSource.MANUAL), + eq(false)); ArgumentCaptor captor = ArgumentCaptor.forClass(ScriptMessage.class); for (String id : machines) { @@ -398,7 +401,7 @@ void batchRunScript_dedupsMachineIds() { scriptDispatchService.batchRunScript(batchInput(List.of("machine-1", "machine-1")), USER_ID, ExecutionSource.MANUAL); verify(scriptExecutionService).createBatch( - any(), eq(SCRIPT_ID), eq((String) null), eq(List.of("machine-1")), eq(PrivilegeLevel.ADMIN), eq(60), eq(USER_ID), eq(ExecutionSource.MANUAL)); + any(), eq(SCRIPT_ID), eq((String) null), eq(List.of("machine-1")), eq(PrivilegeLevel.ADMIN), eq(60), eq(USER_ID), eq(ExecutionSource.MANUAL), eq(false)); verify(scriptNatsPublisher, times(1)).publishScript(eq("machine-1"), any(ScriptMessage.class)); } diff --git a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptExecutionServiceTest.java b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptExecutionServiceTest.java index e167431814..4d849f2f96 100644 --- a/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptExecutionServiceTest.java +++ b/openframe-api-service-core/src/test/java/com/openframe/api/service/rmm/ScriptExecutionServiceTest.java @@ -96,7 +96,7 @@ void get_throwsWhenMissing() { @Test @DisplayName("create: hands the repository a RunningExecutionRows with tenant scope + scriptId + one machine (no schedule origin), and maps the saved row to a DTO") void create_forwardsRunningExecutionRows() { - ScriptExecutionResponse result = service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL); + ScriptExecutionResponse result = service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL, false); ArgumentCaptor captor = ArgumentCaptor.forClass(RunningExecutionRows.class); verify(scriptExecutionRepository).saveRunning(captor.capture()); @@ -122,7 +122,7 @@ void create_forwardsRunningExecutionRows() { @Test @DisplayName("create: tenantId is taken from TenantIdProvider, NOT from any caller-supplied input — locks in the pod-scoped tenant contract") void create_alwaysUsesTenantIdProvider() { - service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.USER, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL); + service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.USER, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL, false); verify(tenantIdProvider).getTenantId(); ArgumentCaptor captor = ArgumentCaptor.forClass(RunningExecutionRows.class); @@ -133,7 +133,7 @@ void create_alwaysUsesTenantIdProvider() { @Test @DisplayName("create: a null initiatedBy is forwarded as null — defensive fallback so an authenticated request without a fully-formed principal still produces a History row instead of NPE-ing the whole dispatch") void create_acceptsNullInitiatedBy() { - service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, null, ExecutionSource.MANUAL); + service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, null, ExecutionSource.MANUAL, false); ArgumentCaptor captor = ArgumentCaptor.forClass(RunningExecutionRows.class); verify(scriptExecutionRepository).saveRunning(captor.capture()); @@ -143,7 +143,7 @@ void create_acceptsNullInitiatedBy() { @Test @DisplayName("create: privilegeLevel is forwarded verbatim — USER vs ADMIN reaches the request exactly as the dispatch carried it") void create_forwardsPrivilegeLevelVerbatim() { - service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.USER, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL); + service.create(EXECUTION_ID, SCRIPT_ID, MACHINE_ID, PrivilegeLevel.USER, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.MANUAL, false); ArgumentCaptor captor = ArgumentCaptor.forClass(RunningExecutionRows.class); verify(scriptExecutionRepository).saveRunning(captor.capture()); @@ -156,7 +156,7 @@ void createBatch_forwardsAllMachinesUnderSharedExecution() { List machines = List.of("m-1", "m-2", "m-3"); List results = service.createBatch(EXECUTION_ID, SCRIPT_ID, "sched-1", machines, - PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.SCHEDULED); + PrivilegeLevel.ADMIN, TIMEOUT_SECONDS, INITIATED_BY, ExecutionSource.SCHEDULED, false); ArgumentCaptor captor = ArgumentCaptor.forClass(RunningExecutionRows.class); verify(scriptExecutionRepository).saveRunning(captor.capture()); diff --git a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/ScheduleFireDispatcher.java b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/ScheduleFireDispatcher.java index 698d97493c..ff50d8154c 100644 --- a/openframe-client-core/src/main/java/com/openframe/client/service/rmm/ScheduleFireDispatcher.java +++ b/openframe-client-core/src/main/java/com/openframe/client/service/rmm/ScheduleFireDispatcher.java @@ -28,6 +28,7 @@ import com.openframe.data.service.rmm.ScheduleDeviceTargetResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Component; @@ -55,6 +56,8 @@ public class ScheduleFireDispatcher { private final MachineRepository machineRepository; private final DeviceOnlineDispatchRepository dispatchRepository; private final ScriptDeliveryRetryStore retryStore; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; public void dispatch(ScheduleScript schedule, Instant now) { List targets = targetResolver.resolveTargetMachineIds(schedule); @@ -185,6 +188,7 @@ private void saveHeader(Fire fire) { .status(ExecutionStatus.RUNNING) .totalMachineCount(fire.machineIds().size()) .dispatchedAt(fire.now()) + .testScript(testModeEnabled) .build()); } @@ -204,6 +208,7 @@ private void saveLeafRows(Fire fire) { .status(ExecutionStatus.QUEUED) .dispatchedAt(fire.now()) .statusChangedAt(fire.now()) + .testScript(testModeEnabled) .build())) .toList(); scriptExecutionRepository.saveAll(rows); diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/command/CommandExecution.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/command/CommandExecution.java index 1c1278d1e0..87ad130b5c 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/command/CommandExecution.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/command/CommandExecution.java @@ -89,4 +89,5 @@ public class CommandExecution implements TenantScoped { private String stderr; private Boolean stderrTruncated; private String error; + private boolean testCommand; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScript.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScript.java index f74c1d48d2..7c01fd911a 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScript.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScript.java @@ -83,4 +83,5 @@ public class ScheduleScript implements TenantScoped { private ScriptStatus status = ScriptStatus.ACTIVE; private Instant statusChangedAt; + private boolean testScript; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScriptExecution.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScriptExecution.java index c8a76f4cdd..05ef4f3a8a 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScriptExecution.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/schedule/ScheduleScriptExecution.java @@ -56,4 +56,5 @@ public class ScheduleScriptExecution implements TenantScoped { @CreatedDate private Instant createdAt; + private boolean testScript; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/RunningExecutionRows.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/RunningExecutionRows.java index 7892d076e8..166338d96a 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/RunningExecutionRows.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/RunningExecutionRows.java @@ -24,4 +24,5 @@ public class RunningExecutionRows { PackageManagerType packageManager; String packageName; SoftwareAction softwareAction; + boolean testScript; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/Script.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/Script.java index ddd1e4a110..e9b6932adb 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/Script.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/Script.java @@ -111,4 +111,5 @@ public class Script implements TenantScoped { */ private Instant statusChangedAt; private String contentHash; + private boolean testScript; } diff --git a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/ScriptExecution.java b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/ScriptExecution.java index 3df9e21c46..3652c138e0 100644 --- a/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/ScriptExecution.java +++ b/openframe-data-mongo-common/src/main/java/com/openframe/data/document/rmm/script/ScriptExecution.java @@ -93,4 +93,5 @@ public class ScriptExecution implements TenantScoped { private String stderr; private Boolean stderrTruncated; private String error; + private boolean testScript; } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScheduleScriptExecutionRepositoryImpl.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScheduleScriptExecutionRepositoryImpl.java index 9a1079f4bb..ca0d766230 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScheduleScriptExecutionRepositoryImpl.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScheduleScriptExecutionRepositoryImpl.java @@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j; import org.bson.Document; import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; @@ -54,11 +55,15 @@ public class CustomScheduleScriptExecutionRepositoryImpl implements CustomSchedu private static final String FIELD_DISPATCHED_AT = "dispatchedAt"; private static final String FIELD_COUNT = "count"; private static final String CURSOR_SEPARATOR = "|"; + private static final String FIELD_TEST_SCRIPT = "testScript"; private static final Set SORTABLE_FIELDS = Set.of(FIELD_ID, FIELD_DISPATCHED_AT); private final MongoTemplate mongoTemplate; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + @Override public List findPageForSchedule(String tenantId, String scriptScheduleId, @@ -204,9 +209,12 @@ private static Criteria facetCriteria(String tenantId, String scriptScheduleId, return criteria; } - private static Criteria baseCriteria(String tenantId, String scriptScheduleId, ScheduleRunQueryFilter filter) { + private Criteria baseCriteria(String tenantId, String scriptScheduleId, ScheduleRunQueryFilter filter) { Criteria criteria = Criteria.where(FIELD_TENANT_ID).is(tenantId) .and(FIELD_SCRIPT_SCHEDULE_ID).is(scriptScheduleId); + if (testModeEnabled) { + criteria.and(FIELD_TEST_SCRIPT).ne(true); + } if (filter == null) { return criteria; } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptExecutionRepositoryImpl.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptExecutionRepositoryImpl.java index a372835bb7..5020a2d032 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptExecutionRepositoryImpl.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptExecutionRepositoryImpl.java @@ -11,6 +11,7 @@ import lombok.extern.slf4j.Slf4j; import org.bson.Document; import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; @@ -54,6 +55,7 @@ public class CustomScriptExecutionRepositoryImpl implements CustomScriptExecutio private static final String FIELD_FINISHED_AT = "finishedAt"; private static final String FIELD_STATUS_CHANGED_AT = "statusChangedAt"; private static final String FIELD_COUNT = "count"; + private static final String FIELD_TEST_SCRIPT = "testScript"; /** Sort-field allowlist. Anything not in here falls back to {@link #getDefaultSortField()}. */ private static final Set SORTABLE_FIELDS = Set.of( @@ -72,6 +74,9 @@ public class CustomScriptExecutionRepositoryImpl implements CustomScriptExecutio private final MongoTemplate mongoTemplate; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + @Override public List saveRunning(RunningExecutionRows request) { return save(request, ExecutionStatus.RUNNING); @@ -98,6 +103,7 @@ private List save(RunningExecutionRows request, ExecutionStatus .packageManager(request.getPackageManager()) .packageName(request.getPackageName()) .softwareAction(request.getSoftwareAction()) + .testScript(request.isTestScript()) .status(initialStatus) .dispatchedAt(now) .statusChangedAt(now) @@ -204,10 +210,13 @@ private static void applyOwner(Criteria criteria, ExecutionOwnerScope owner) { } } - private static Criteria baseCriteria(String tenantId, ExecutionOwnerScope owner, - ScriptExecutionQueryFilter filter, String excludedField) { + private Criteria baseCriteria(String tenantId, ExecutionOwnerScope owner, + ScriptExecutionQueryFilter filter, String excludedField) { Criteria criteria = Criteria.where(FIELD_TENANT_ID).is(tenantId); applyOwner(criteria, owner); + if (testModeEnabled) { + criteria.and(FIELD_TEST_SCRIPT).ne(true); + } if (filter == null) { return criteria; } diff --git a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptRepositoryImpl.java b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptRepositoryImpl.java index 9a010c80f9..3be7979b42 100644 --- a/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptRepositoryImpl.java +++ b/openframe-data-mongo-sync/src/main/java/com/openframe/data/repository/rmm/CustomScriptRepositoryImpl.java @@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.bson.Document; import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; @@ -59,6 +60,7 @@ public class CustomScriptRepositoryImpl implements CustomScriptRepository { private static final String FIELD_UPDATED_AT = "updatedAt"; private static final String FIELD_CREATED_BY = "createdBy"; private static final String FIELD_TYPE = "type"; + private static final String FIELD_TEST_SCRIPT = "testScript"; // tag_assignments fields used to resolve the tagIds filter into script ids. private static final String FIELD_TA_TAG_ID = "tagId"; @@ -71,6 +73,9 @@ public class CustomScriptRepositoryImpl implements CustomScriptRepository { private final MongoTemplate mongoTemplate; + @Value("${openframe.rmm.test-mode.enabled}") + private boolean testModeEnabled; + @Override public List