Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class ScriptExecutionResponse {
private String id;
private String executionId;
private String scriptId;
/** Schedule this execution came from; null for ad-hoc runs. */
private String scheduleId;
private String machineId;
private PrivilegeLevel privilegeLevel;
private String initiatedBy;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.openframe.api.dto.rmm.schedule;

import com.openframe.data.document.rmm.ScriptPlatform;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

import java.time.Instant;
import java.util.List;

/**
Expand All @@ -22,4 +24,14 @@ public class CreateScriptScheduleInput {
private List<ScriptPlatform> supportedPlatforms;

private List<String> scriptIds;

/**
* First scheduled run as an absolute UTC instant (the dashboard converts the
* chosen Date + Time to UTC). Optional — a schedule with no startAt is never
* picked up by the runner until one is set.
*/
private Instant startAt;

@Min(value = 1800, message = "repeat must be at least 1800 seconds (30 minutes)")
private Long repeat;
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class ScriptScheduleResponse {

private List<String> scriptIds;

private Instant startAt;
private Long repeat;
private Instant nextRunAt;
private Instant lastRunAt;

private String createdBy;

private String status;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.openframe.api.dto.rmm.schedule;

import com.openframe.data.document.rmm.ScriptPlatform;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

import java.time.Instant;
import java.util.List;

/**
Expand All @@ -25,4 +27,16 @@ public class UpdateScriptScheduleInput {
private List<ScriptPlatform> supportedPlatforms;

private List<String> scriptIds;

/**
* First scheduled run as an absolute UTC instant. PUT semantics: null clears
* the timing (the schedule stops being picked up). Changing this value
* reschedules the next run (see {@code ScriptScheduleService.update}).
*/
private Instant startAt;

/** Recurrence interval in seconds; null clears recurrence (one-shot). Must be a whole number
* of 30-minute slots (1800, 3600, 5400, …) — the runner ticks on that grid. */
@Min(value = 1800, message = "repeat must be at least 1800 seconds (30 minutes)")
private Long repeat;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public ScriptExecutionResponse toResponse(ScriptExecution entity) {
.id(entity.getId())
.executionId(entity.getExecutionId())
.scriptId(entity.getScriptId())
.scheduleId(entity.getScheduleId())
.machineId(entity.getMachineId())
.privilegeLevel(entity.getPrivilegeLevel())
.initiatedBy(entity.getInitiatedBy())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public ScriptSchedule toEntity(String tenantId, CreateScriptScheduleInput input)
.description(input.getDescription())
.supportedPlatforms(input.getSupportedPlatforms())
.scriptIds(input.getScriptIds())
.startAt(input.getStartAt())
.repeat(input.getRepeat())
.build();
}

Expand All @@ -33,6 +35,8 @@ public void updateEntity(ScriptSchedule existing, UpdateScriptScheduleInput inpu
existing.setDescription(input.getDescription());
existing.setSupportedPlatforms(input.getSupportedPlatforms());
existing.setScriptIds(input.getScriptIds());
existing.setStartAt(input.getStartAt());
existing.setRepeat(input.getRepeat());
}

public ScriptScheduleResponse toResponse(ScriptSchedule entity) {
Expand All @@ -42,6 +46,10 @@ public ScriptScheduleResponse toResponse(ScriptSchedule entity) {
.description(entity.getDescription())
.supportedPlatforms(mapPlatformsToResponse(entity.getSupportedPlatforms()))
.scriptIds(entity.getScriptIds())
.startAt(entity.getStartAt())
.repeat(entity.getRepeat())
.nextRunAt(entity.getNextRunAt())
.lastRunAt(entity.getLastRunAt())
.createdBy(entity.getCreatedBy())
.status(entity.getStatus() != null ? entity.getStatus().name() : ScriptStatus.ACTIVE.name())
.statusChangedAt(entity.getStatusChangedAt())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
package com.openframe.api.service.rmm;

import com.openframe.api.dto.rmm.DispatchResponse;
import com.openframe.api.dto.rmm.schedule.ScriptScheduleResponse;
import com.openframe.api.dto.rmm.script.BatchRunScriptInput;
import com.openframe.api.dto.rmm.script.RunScriptInput;
import com.openframe.api.dto.rmm.script.ScriptEnvVarInput;
import com.openframe.api.dto.rmm.script.ScriptResponse;
import com.openframe.api.exception.DeviceNotFoundException;
import com.openframe.api.service.DeviceService;
import com.openframe.core.exception.BadRequestException;
import com.openframe.data.document.rmm.PrivilegeLevel;
import com.openframe.data.document.rmm.ScriptEnvVar;
import com.openframe.data.document.rmm.ScriptShell;
import com.openframe.data.document.rmm.ScriptStatus;
import com.openframe.data.nats.rmm.model.ScriptMessage;
import com.openframe.data.nats.rmm.publisher.ScriptNatsPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* Dispatches a <b>saved</b> script from the dashboard to the target agent over <b>core NATS</b>.
Expand All @@ -41,6 +48,8 @@ public class ScriptDispatchService {
private final ScriptNatsPublisher scriptNatsPublisher;
private final DeviceService deviceService;
private final ScriptExecutionService scriptExecutionService;
private final ScriptScheduleService scriptScheduleService;
private final ScriptScheduleDeviceService scriptScheduleDeviceService;

public DispatchResponse runScript(RunScriptInput input, String initiatedBy) {
deviceService.findByMachineId(input.getMachineId())
Expand All @@ -58,6 +67,7 @@ public DispatchResponse runScript(RunScriptInput input, String initiatedBy) {

ScriptMessage message = ScriptMessage.builder()
.executionId(executionId)
.scriptId(script.getId())
.machineId(input.getMachineId())
.code(script.getScriptBody())
.shell(ScriptShell.valueOf(script.getShell()))
Expand All @@ -81,44 +91,124 @@ public DispatchResponse batchRunScript(BatchRunScriptInput input, String initiat

// Verify every target up front — reject the whole batch if any is unknown,
// so we never half-dispatch.
machineIds.forEach(machineId -> deviceService.findByMachineId(machineId)
.orElseThrow(() -> new DeviceNotFoundException("Machine not found: " + machineId)));
verifyMachines(machineIds);

// Resolve the saved script once; every machine shares it.
ScriptResponse script = scriptService.get(input.getScriptId());
String executionId = UUID.randomUUID().toString();

Integer timeoutSeconds = effectiveTimeout(input.getTimeoutSeconds(), script.getDefaultTimeoutSeconds());
return dispatchBatch(executionId, script, machineIds, input.getPrivilegeLevel(),
input.getArgs(), input.getTimeoutSeconds(), input.getEnvVars(), initiatedBy, null);
}

/**
* Ad-hoc run of a saved schedule: fan every script the schedule references out to
* every device currently assigned to it, over core NATS. The <b>whole run shares a
* single {@code executionId}</b> across all scripts and machines; each row and wire
* payload is disambiguated by {@code scriptId} (and every payload carries the
* originating {@code scheduleId}).
*
* <p>Mirrors the scheduled runner: only ACTIVE scripts are dispatched; scripts the
* schedule has outlived (deleted/archived) are skipped, not fatal. Returns the one
* {@link DispatchResponse} carrying that shared executionId. Throws
* {@link BadRequestException} only when there is genuinely nothing to run — the
* schedule has no scripts, no assigned devices, or none of its scripts are runnable —
* so we never mint a hollow executionId that correlates to no execution rows.
*/
public DispatchResponse runSchedule(String scheduleId, String initiatedBy) {
// Tenant-scoped lookup; throws if the schedule is missing or soft-deleted.
ScriptScheduleResponse schedule = scriptScheduleService.get(scheduleId);

List<String> scriptIds = schedule.getScriptIds();
if (scriptIds == null || scriptIds.isEmpty()) {
throw new BadRequestException("Schedule has no scripts to run: " + scheduleId);
}

List<String> machineIds = scriptScheduleDeviceService.getMachineIds(scheduleId);
if (machineIds.isEmpty()) {
throw new BadRequestException("Schedule has no assigned devices: " + scheduleId);
}

// Verify every target up front — reject the whole run if any is unknown,
// so we never half-dispatch across the schedule's scripts.
verifyMachines(machineIds);

// Resolve every referenced script in ONE query (no N+1). Mirror the scheduled
// runner: only ACTIVE scripts are dispatched; a schedule can outlive some of its
// scripts (deleted/archived), and those are skipped rather than failing the run.
Map<String, ScriptResponse> scriptsById = scriptService.getScriptsByIds(scriptIds).stream()
.filter(script -> ScriptStatus.ACTIVE.name().equals(script.getStatus()))
.collect(Collectors.toMap(ScriptResponse::getId, Function.identity(), (a, b) -> a));

// Preserve run order; dedup (a shared executionId can't carry the same
// scriptId twice on one machine — the unique key would collide).
List<String> runnableIds = scriptIds.stream().distinct().filter(scriptsById::containsKey).toList();
List<String> skippedIds = scriptIds.stream().distinct().filter(id -> !scriptsById.containsKey(id)).toList();
if (!skippedIds.isEmpty()) {
log.warn("Schedule run scheduleId={} skipping non-runnable scripts (missing/deleted/archived): {}",
scheduleId, skippedIds);
}
if (runnableIds.isEmpty()) {
throw new BadRequestException("Schedule has no runnable scripts: " + scheduleId);
}

String executionId = UUID.randomUUID().toString();
for (String scriptId : runnableIds) {
ScriptResponse script = scriptsById.get(scriptId);
dispatchBatch(executionId, script, machineIds, script.getPrivilegeLevel(),
null, null, null, initiatedBy, scheduleId);
}

// "Run now" is an extra, out-of-band execution: record it, but leave the cadence
// untouched — the schedule still fires at whatever slot it was already heading for.
scriptScheduleService.recordManualRun(scheduleId, Instant.now());

log.info("Dispatched schedule run scheduleId={} executionId={} scripts={} machines={}",
scheduleId, executionId, runnableIds.size(), machineIds.size());
return DispatchResponse.builder().executionId(executionId).build();
}

private DispatchResponse dispatchBatch(String executionId, ScriptResponse script, List<String> machineIds,
PrivilegeLevel privilegeLevel, List<String> argsOverride,
Integer timeoutOverride, List<ScriptEnvVarInput> envVarsOverride,
String initiatedBy, String scheduleId) {
Integer timeoutSeconds = effectiveTimeout(timeoutOverride, script.getDefaultTimeoutSeconds());

// Persist the effective timeout per row so the watchdog can derive a
// per-execution stuck-threshold from it.
scriptExecutionService.createBatch(executionId, script.getId(),
machineIds, input.getPrivilegeLevel(), timeoutSeconds, initiatedBy);
scriptExecutionService.createBatch(executionId, script.getId(), scheduleId, machineIds, privilegeLevel, timeoutSeconds, initiatedBy);

ScriptShell shell = ScriptShell.valueOf(script.getShell());
List<String> args = input.getArgs() != null ? input.getArgs() : script.getDefaultArgs();
List<ScriptEnvVar> envVars = mergeEnvVars(script.getEnvVars(), input.getEnvVars());
List<String> args = argsOverride != null ? argsOverride : script.getDefaultArgs();
List<ScriptEnvVar> envVars = mergeEnvVars(script.getEnvVars(), envVarsOverride);

// Fan out the same script (one executionId) to every machine.
// Fan out the same script (shared executionId) to every machine.
machineIds.forEach(machineId -> scriptNatsPublisher.publishScript(machineId,
ScriptMessage.builder()
.executionId(executionId)
.scheduleId(scheduleId)
.scriptId(script.getId())
.machineId(machineId)
.code(script.getScriptBody())
.shell(shell)
.privilegeLevel(input.getPrivilegeLevel())
.privilegeLevel(privilegeLevel)
.args(args)
.timeoutSeconds(timeoutSeconds)
.envVars(envVars)
.build()));

log.info("Dispatched batch script executionId={} scriptId={} machines={} shell={} privilegeLevel={}",
executionId, input.getScriptId(), machineIds.size(), script.getShell(), input.getPrivilegeLevel());
log.info("Dispatched batch script executionId={} scriptId={} machines={} shell={} privilegeLevel={} scheduleId={}",
executionId, script.getId(), machineIds.size(), script.getShell(), privilegeLevel, scheduleId);
return DispatchResponse.builder()
.executionId(executionId)
.build();
}

private void verifyMachines(List<String> machineIds) {
machineIds.forEach(machineId -> deviceService.findByMachineId(machineId)
.orElseThrow(() -> new DeviceNotFoundException("Machine not found: " + machineId)));
}

private static Integer effectiveTimeout(Integer override, Integer scriptDefault) {
return override != null ? override : scriptDefault;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,41 +60,44 @@ public Optional<ScriptExecutionResponse> findById(String id) {
* later rename of the source {@code Script} is reflected in History without
* duplicating the name onto every row.
*/
public ScriptExecution create(String executionId,
String scriptId,
String machineId,
PrivilegeLevel privilegeLevel,
Integer timeoutSeconds,
String initiatedBy) {
public ScriptExecutionResponse create(String executionId,
String scriptId,
String machineId,
PrivilegeLevel privilegeLevel,
Integer timeoutSeconds,
String initiatedBy) {
Instant now = Instant.now();
ScriptExecution scriptExecution = buildRunningRow(executionId, scriptId, machineId, privilegeLevel, timeoutSeconds, initiatedBy, now);
// Single ad-hoc run (runScript) never originates from a schedule → scheduleId null.
ScriptExecution scriptExecution = buildRunningRow(executionId, scriptId, null, machineId, privilegeLevel, timeoutSeconds, initiatedBy, now);
ScriptExecution saved = scriptExecutionRepository.save(scriptExecution);
log.info("Persisted execution row: executionId={} scriptId={} machineId={} initiatedBy={} status=RUNNING",
executionId, scriptId, machineId, initiatedBy);
return saved;
return scriptExecutionMapper.toResponse(saved);
}

/**
* Bulk-persist one {@link ExecutionStatus#RUNNING} row per target machine
* under a shared {@code executionId} — backs batch dispatch. Unique
* constraint is {@code (tenantId, executionId, machineId)}, so the same
* {@code executionId} repeats across rows while each {@code machineId}
* stays distinct.
* constraint is {@code (tenantId, executionId, machineId, scriptId)}: the same
* {@code executionId} repeats across rows (a schedule run shares it across all
* its scripts too), with {@code machineId}/{@code scriptId} keeping each row
* distinct.
*/
public List<ScriptExecution> createBatch(String executionId,
String scriptId,
List<String> machineIds,
PrivilegeLevel privilegeLevel,
Integer timeoutSeconds,
String initiatedBy) {
public List<ScriptExecutionResponse> createBatch(String executionId,
String scriptId,
String scheduleId,
List<String> machineIds,
PrivilegeLevel privilegeLevel,
Integer timeoutSeconds,
String initiatedBy) {
Instant now = Instant.now();
List<ScriptExecution> rows = machineIds.stream()
.map(machineId -> buildRunningRow(executionId, scriptId, machineId, privilegeLevel, timeoutSeconds, initiatedBy, now))
.map(machineId -> buildRunningRow(executionId, scriptId, scheduleId, machineId, privilegeLevel, timeoutSeconds, initiatedBy, now))
.toList();
List<ScriptExecution> saved = scriptExecutionRepository.saveAll(rows);
log.info("Persisted batch execution rows: executionId={} scriptId={} machineCount={} initiatedBy={} status=RUNNING",
executionId, scriptId, machineIds.size(), initiatedBy);
return saved;
log.info("Persisted batch execution rows: executionId={} scriptId={} scheduleId={} machineCount={} initiatedBy={} status=RUNNING",
executionId, scriptId, scheduleId, machineIds.size(), initiatedBy);
return saved.stream().map(scriptExecutionMapper::toResponse).toList();
}

/**
Expand All @@ -111,6 +114,7 @@ public List<ScriptExecution> getBatchResults(String executionId, List<String> ma

private ScriptExecution buildRunningRow(String executionId,
String scriptId,
String scheduleId,
String machineId,
PrivilegeLevel privilegeLevel,
Integer timeoutSeconds,
Expand All @@ -120,6 +124,7 @@ private ScriptExecution buildRunningRow(String executionId,
.tenantId(tenantIdProvider.getTenantId())
.executionId(executionId)
.scriptId(scriptId)
.scheduleId(scheduleId)
.machineId(machineId)
.privilegeLevel(privilegeLevel)
.timeoutSeconds(timeoutSeconds)
Expand Down
Loading
Loading