diff --git a/build.gradle.kts b/build.gradle.kts index f3e8d83..365e287 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -79,7 +79,7 @@ kover { excludes { classes( // Kotlin serialization generated classes (JVM binary name patterns) - "**\$\$serializer", + "**$\$serializer", "**\$serializer", "**Companion\$serializer", @@ -91,7 +91,7 @@ kover { "**\$DefaultImpls", "**\$WhenMappings", "**\$inlined*", - "**\$sam\$*", + "**\$sam$*", // data class copy$default bridge methods (JVM only) "**\$copy\$default*", diff --git a/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/application/environment/REnvironmentDefinition.kt b/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/application/environment/REnvironmentDefinition.kt new file mode 100644 index 0000000..234848c --- /dev/null +++ b/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/application/environment/REnvironmentDefinition.kt @@ -0,0 +1,61 @@ +package carp.dsp.core.application.environment + +import dk.cachet.carp.analytics.domain.environment.EnvironmentDefinition +import dk.cachet.carp.common.application.UUID +import kotlinx.serialization.Serializable + +/** + * Declarative author-time definition of an R execution environment. + * + * Contains only specification data (R version, packages, lock files). + * Does not represent a materialized/runtime environment. + */ +@Serializable +data class REnvironmentDefinition( + override val id: UUID, + override val name: String, + val rVersion: String, + val rPackages: List = emptyList(), + val renvLockFile: String? = null, + val installationPath: String? = null, + override val dependencies: List = emptyList(), + override val environmentVariables: Map = emptyMap() +) : EnvironmentDefinition { + + private companion object { + private const val MIN_VERSION_SEGMENTS = 2 + private const val MAX_VERSION_SEGMENTS = 3 + } + + /** + * Validate R environment specification. + */ + fun validate(): List { + val errors = mutableListOf() + + if (rVersion.isBlank()) { + errors.add("R version cannot be blank") + } + + if (renvLockFile == null && rPackages.isEmpty()) { + errors.add("Either renvLockFile or rPackages must be specified") + } + + // Validate version format + if (!isValidRVersion(rVersion)) { + errors.add("Invalid R version format: $rVersion") + } + + return errors + } + + /** + * Check if version string is valid. + * Expected format: MAJOR.MINOR.PATCH (e.g., "4.3.0") + */ + private fun isValidRVersion(version: String): Boolean { + val parts = version.split(".") + if (parts.size !in MIN_VERSION_SEGMENTS..MAX_VERSION_SEGMENTS) return false + return parts.all { it.toIntOrNull() != null } + } +} diff --git a/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/common/ExcludeFromCoverage.kt b/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/common/ExcludeFromCoverage.kt deleted file mode 100644 index 8e35655..0000000 --- a/carp.dsp.core/src/commonMain/kotlin/carp/dsp/core/common/ExcludeFromCoverage.kt +++ /dev/null @@ -1,32 +0,0 @@ -package carp.dsp.core.common - -/** - * Annotation to exclude classes, methods, or properties from code coverage analysis. - * - * Usage examples: - * ```kotlin - * @ExcludeFromCoverage - * class GeneratedClass { - * // Entire class excluded from coverage - * } - * - * class MyClass { - * @ExcludeFromCoverage - * fun debugMethod() { - * // This method excluded from coverage - * } - * } - * ``` - */ -@Target( - AnnotationTarget.CLASS, - AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY, - AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.PROPERTY_SETTER, - AnnotationTarget.CONSTRUCTOR -) -@Retention(AnnotationRetention.RUNTIME) -annotation class ExcludeFromCoverage( - val reason: String = "Excluded from coverage analysis" -) diff --git a/carp.dsp.core/src/commonTest/kotlin/carp/dsp/core/application/environment/REnvironmentDefinitionTest.kt b/carp.dsp.core/src/commonTest/kotlin/carp/dsp/core/application/environment/REnvironmentDefinitionTest.kt new file mode 100644 index 0000000..c6b2895 --- /dev/null +++ b/carp.dsp.core/src/commonTest/kotlin/carp/dsp/core/application/environment/REnvironmentDefinitionTest.kt @@ -0,0 +1,93 @@ +package carp.dsp.core.application.environment + +import dk.cachet.carp.common.application.UUID +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class REnvironmentDefinitionTest { + + @Test + fun createsREnvironmentDefinition() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "analysis-env", + rVersion = "4.3.0", + rPackages = listOf("ggplot2", "dplyr", "tidyr") + ) + + assertEquals("4.3.0", definition.rVersion) + assertEquals(3, definition.rPackages.size) + } + + @Test + fun validatesValidRVersion() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "env", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val errors = definition.validate() + + assertTrue(errors.isEmpty()) + } + + @Test + fun rejectsInvalidRVersion() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "env", + rVersion = "invalid-version", + rPackages = listOf("ggplot2") + ) + + val errors = definition.validate() + + assertTrue(errors.any { it.contains("Invalid R version format") }) + } + + @Test + fun rejectsBlankRVersion() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "env", + rVersion = "", + rPackages = listOf("ggplot2") + ) + + val errors = definition.validate() + + assertTrue(errors.any { it.contains("R version cannot be blank") }) + } + + @Test + fun acceptsRenvLockFile() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "env", + rVersion = "4.3.0", + renvLockFile = "/path/to/renv.lock" + ) + + val errors = definition.validate() + + assertTrue(errors.isEmpty()) + } + + @Test + fun rejectsMissingPackagesAndLockFile() { + val definition = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "env", + rVersion = "4.3.0", + rPackages = emptyList(), + renvLockFile = null + ) + + val errors = definition.validate() + + assertTrue(errors.any { it.contains("renvLockFile or rPackages") }) + } +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/CommandTemplate.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/CommandTemplate.kt new file mode 100644 index 0000000..cfddcba --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/CommandTemplate.kt @@ -0,0 +1,40 @@ +package carp.dsp.core.infrastructure.execution + +import dk.cachet.carp.analytics.application.plan.EnvironmentRef + +/** + * Command generation from environment template. + * + * Encapsulates the logic of expanding {executable} and {args} + * into the final command string. + */ +data class CommandTemplate( + val environmentRef: EnvironmentRef, + val executable: String, + val args: List +) { + + /** + * Expand template into final command string. + * + * Example: + * Input: environmentRef=CondaRef("myenv"), + * executable="python", + * args=["script.py", "arg1"] + * Output: "conda run -n myenv python script.py arg1" + */ + fun toCommandString(): String { + val template = environmentRef.generateExecutionTemplate() + return template + .replace("{executable}", executable) + .replace("{args}", args.joinToString(" ")) + .trimEnd() + } + + /** + * Get as bash command for ProcessBuilder. + */ + fun toBashCommand(): List { + return listOf("bash", "-c", toCommandString()) + } +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestrator.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestrator.kt new file mode 100644 index 0000000..8e583ca --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestrator.kt @@ -0,0 +1,277 @@ +package carp.dsp.core.infrastructure.execution + + +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.CleanupPolicy +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentConfig +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentExecutionLogs +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentLog +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentMetadata +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentOrchestrator +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentPhase +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentRegistry +import dk.cachet.carp.analytics.infrastructure.execution.ErrorHandling +import dk.cachet.carp.analytics.infrastructure.execution.LogLevel +import kotlinx.datetime.Clock +import java.io.IOException + +/** + * Default implementation of EnvironmentOrchestrator. + * + * Coordinates environment provisioning, execution, and clean-up. + */ +class DefaultEnvironmentOrchestrator( + private val registry: EnvironmentRegistry, + private val config: EnvironmentConfig = EnvironmentConfig() +) : EnvironmentOrchestrator { + + private companion object { + private const val MIN_ENV_ID_PARTS_WITH_STEP = 4 + private const val RUN_ID_PARTS = 3 + private const val STEP_ID_INDEX = 3 + } + + private val logs = mutableListOf() + + override fun setup(environmentRef: EnvironmentRef): Boolean { + if (config.reuseExisting && reuseExisting(environmentRef)) return true + + return performSetup(environmentRef) + } + + override fun generateExecutionCommand( + environmentRef: EnvironmentRef, + command: String + ): String { + val handler = EnvironmentHandlerRegistry.getHandler(environmentRef) + return handler.generateExecutionCommand(environmentRef, command) + } + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + return try { + when (config.cleanupPolicy) { + CleanupPolicy.REUSE -> { + log( + EnvironmentPhase.TEARDOWN, + environmentRef.id, + "Keeping environment (REUSE policy)", + LogLevel.INFO + ) + true + } + CleanupPolicy.CLEAN -> { + val handler = EnvironmentHandlerRegistry.getHandler(environmentRef) + val success = handler.teardown(environmentRef) + + if (success) { + log( + EnvironmentPhase.TEARDOWN, + environmentRef.id, + "Cleaned (CLEAN policy)", + LogLevel.INFO + ) + } + success + } + CleanupPolicy.PURGE -> { + val handler = EnvironmentHandlerRegistry.getHandler(environmentRef) + val success = handler.teardown(environmentRef) + + if (success) { + registry.remove(environmentRef.id) + log( + EnvironmentPhase.TEARDOWN, + environmentRef.id, + "Purged (PURGE policy)", + LogLevel.INFO + ) + } + success + } + } + } catch (e: IllegalArgumentException) { + log(EnvironmentPhase.TEARDOWN, environmentRef.id, "Teardown error: ${e.message}", LogLevel.WARN) + false + } catch (e: IllegalStateException) { + log(EnvironmentPhase.TEARDOWN, environmentRef.id, "Teardown error: ${e.message}", LogLevel.WARN) + false + } catch (e: IOException) { + log(EnvironmentPhase.TEARDOWN, environmentRef.id, "Teardown I/O error: ${e.message}", LogLevel.WARN) + false + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + log(EnvironmentPhase.TEARDOWN, environmentRef.id, "Teardown interrupted: ${e.message}", LogLevel.WARN) + false + } + } + + fun getEnvironmentLogs(): EnvironmentExecutionLogs { + return EnvironmentExecutionLogs( + setupLogs = logs.filter { it.phase == EnvironmentPhase.SETUP }, + executionLogs = logs.filter { it.phase == EnvironmentPhase.EXECUTION }, + teardownLogs = logs.filter { it.phase == EnvironmentPhase.TEARDOWN } + ) + } + + // Private Helpers + + private fun setupWithErrorHandling( + handler: EnvironmentHandler, + environmentRef: EnvironmentRef + ): Boolean = runCatching { handler.setup(environmentRef) } + .getOrElse { handleSetupFailure(environmentRef, it) } + + private fun setupWithRetry( + handler: EnvironmentHandler, + environmentRef: EnvironmentRef + ): Boolean { + var lastException: Throwable? = null + + for (attempt in 1..config.retryAttempts) { + val result = runCatching { handler.setup(environmentRef) } + if (result.isSuccess) return result.getOrThrow() + + val failure = result.exceptionOrNull() + if (failure is InterruptedException) { + Thread.currentThread().interrupt() + throw failure + } + + lastException = failure + if (attempt < config.retryAttempts) { + val backoff = config.retryBackoffMs * attempt + log( + EnvironmentPhase.SETUP, + environmentRef.id, + "Attempt $attempt failed, retrying in ${backoff}ms", + LogLevel.WARN + ) + Thread.sleep(backoff) + } + } + + throw lastException ?: Exception("Setup failed after ${config.retryAttempts} attempts") + } + + private fun reuseExisting(environmentRef: EnvironmentRef): Boolean { + val exists = registry.exists(environmentRef.id) + if (exists) { + log( + EnvironmentPhase.SETUP, + environmentRef.id, + "Reusing existing environment", + LogLevel.INFO + ) + } + return exists + } + + private fun performSetup(environmentRef: EnvironmentRef): Boolean { + val handler = EnvironmentHandlerRegistry.getHandler(environmentRef) + val success = if (config.errorHandling == ErrorHandling.RETRY) { + setupWithRetry(handler, environmentRef) + } else { + setupWithErrorHandling(handler, environmentRef) + } + + if (success) registerEnvironment(environmentRef) + return success + } + + private fun registerEnvironment(environmentRef: EnvironmentRef) { + val metadata = EnvironmentMetadata( + id = environmentRef.id, + name = environmentRef::class.simpleName ?: "unknown", + kind = determinateName(environmentRef), + runId = extractRunId(environmentRef.id), + stepId = extractStepId(environmentRef.id), + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 0L, + status = "active" + ) + registry.register(environmentRef, metadata) + log(EnvironmentPhase.SETUP, environmentRef.id, "Setup complete", LogLevel.INFO) + } + + private fun handleSetupFailure(environmentRef: EnvironmentRef, throwable: Throwable): Boolean { + return when (throwable) { + is IllegalArgumentException, + is IllegalStateException -> { + log( + EnvironmentPhase.SETUP, + environmentRef.id, + "Setup failed: ${throwable.message}", + LogLevel.ERROR + ) + when (config.errorHandling) { + ErrorHandling.FAIL_FAST -> throw throwable + ErrorHandling.CONTINUE -> false + ErrorHandling.RETRY -> false + } + } + is IOException -> { + log( + EnvironmentPhase.SETUP, + environmentRef.id, + "Setup I/O failed: ${throwable.message}", + LogLevel.ERROR + ) + when (config.errorHandling) { + ErrorHandling.FAIL_FAST -> throw throwable + ErrorHandling.CONTINUE -> false + ErrorHandling.RETRY -> false + } + } + is InterruptedException -> { + Thread.currentThread().interrupt() + log( + EnvironmentPhase.SETUP, + environmentRef.id, + "Setup interrupted: ${throwable.message}", + LogLevel.WARN + ) + false + } + else -> throw throwable + } + } + + private fun log(phase: EnvironmentPhase, envId: String, message: String, level: LogLevel) { + logs.add( + EnvironmentLog( + timestamp = Clock.System.now(), + environmentId = envId, + phase = phase, + message = message, + level = level + ) + ) + } + + private fun determinateName(environmentRef: EnvironmentRef): String { + return environmentRef::class.simpleName?.replace("EnvironmentRef", "")?.lowercase() + ?: "unknown" + } + + private fun extractRunId(envId: String): String { + // Format: run-id-step-id-env-name or run-id-env-name + val parts = envId.split("-") + return if (parts.size >= MIN_ENV_ID_PARTS_WITH_STEP) { + parts.take(RUN_ID_PARTS).joinToString("-") + } else { + parts.firstOrNull() ?: envId + } + } + + private fun extractStepId(envId: String): String? { + // If format is run-id-step-id-env-name, extract step-id + val parts = envId.split("-") + return if (parts.size >= MIN_ENV_ID_PARTS_WITH_STEP) { + parts[STEP_ID_INDEX] + } else { + null + } + } +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistry.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistry.kt new file mode 100644 index 0000000..3437720 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistry.kt @@ -0,0 +1,68 @@ +package carp.dsp.core.infrastructure.execution + +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentMetadata +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentRegistry +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * In-memory registry with serialization. + * + * Loads from file on startup. + * Persists to file on every change. + */ +class DefaultEnvironmentRegistry( + private val registryFile: Path +) : EnvironmentRegistry { + + private val metadata = mutableMapOf() + + init { + // Load from file on startup + if (registryFile.exists()) { + try { + val json = registryFile.readText() + val data = Json.decodeFromString>(json) + metadata.putAll(data) + } catch (_: Exception) { + // Log error but continue with empty registry + } + } + } + + override fun register(environmentRef: EnvironmentRef, metadata: EnvironmentMetadata) { + this.metadata[environmentRef.id] = metadata + persist() + } + + override fun exists(environmentId: String): Boolean { + return metadata.containsKey(environmentId) + } + + override fun getMetadata(environmentId: String): EnvironmentMetadata? { + return metadata[environmentId] + } + + override fun list(): List { + return metadata.values.toList() + } + + override fun remove(environmentId: String) { + metadata.remove(environmentId) + persist() + } + + private fun persist() { + try { + val json = Json.encodeToString(metadata) + registryFile.writeText(json) + } catch (_: Exception) { + // Log error but continue + } + } +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutor.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutor.kt index ec7a663..c1d7187 100644 --- a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutor.kt +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutor.kt @@ -11,11 +11,18 @@ import dk.cachet.carp.analytics.application.execution.RunPolicy import dk.cachet.carp.analytics.application.execution.StepRunResult import dk.cachet.carp.analytics.application.execution.workspace.ExecutionWorkspace import dk.cachet.carp.analytics.application.execution.workspace.WorkspaceManager +import dk.cachet.carp.analytics.application.plan.CommandSpec import dk.cachet.carp.analytics.application.plan.ExecutionPlan +import dk.cachet.carp.analytics.application.plan.ExpandedArg import dk.cachet.carp.analytics.application.plan.PlannedStep import dk.cachet.carp.analytics.application.runtime.CommandRunner +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentConfig +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentExecutionLogs +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentOrchestrator +import dk.cachet.carp.analytics.infrastructure.execution.SetupTiming import dk.cachet.carp.common.application.UUID import kotlinx.datetime.Clock +import java.nio.file.Paths /** * Implementation of [PlanExecutor] that drives real command execution. @@ -24,33 +31,27 @@ import kotlinx.datetime.Clock * [SequentialPlanOrder] follows the topological order already produced by the planner. * * Each step is handed to a [CommandStepRunner] which prepares directories, runs the command - * via [commandRunner], and maps the result to a [StepRunResult]. The step's working directory + * via [CommandStepRunner], and maps the result to a [StepRunResult]. The step's working directory * is resolved by the [workspaceManager] itself via [WorkspaceManager.resolveStepWorkingDir], * so no separate base-root path is needed here. * When [RunPolicy.stopOnFailure] is true any failed step causes remaining steps to * be recorded as [ExecutionStatus.SKIPPED]. * * @param workspaceManager Materializes the run workspace on disk and resolves step paths. - * @param artefactStore Stores metadata about produced outputs/artifacts. - * @param commandRunner Underlying OS-process driver. Defaults to [JvmCommandRunner]. - * @param stepOrderStrategy Controls the execution order of steps. Defaults to [SequentialPlanOrder]. - * @param outputValidationPolicy Controls post-execution output checks. Defaults to [OutputValidationPolicy.DEFAULT]. - * @param clock Wall-clock source used by [CommandStepRunner]. Defaults to [Clock.System]. + * @param artefactStore Stores metadata about produced outputs/artefacts. + * @param options Optional execution dependencies (runner, ordering, validation, orchestrator, config, clock). */ class DefaultPlanExecutor( private val workspaceManager: WorkspaceManager, private val artefactStore: ArtefactStore, - private val commandRunner: CommandRunner = JvmCommandRunner(), - private val stepOrderStrategy: StepOrderStrategy = SequentialPlanOrder, - private val outputValidationPolicy: OutputValidationPolicy = OutputValidationPolicy.DEFAULT, - private val clock: Clock = Clock.System + private val options: Options = Options() ) : PlanExecutor { /** * Executes all steps in [plan] and returns a completed [ExecutionReport]. * * 1. Creates a workspace via [workspaceManager]. - * 2. Resolves execution order via [stepOrderStrategy] (default: planner order). + * 2. Resolves execution order via [StepOrderStrategy] (default: planner order). * 3. Runs each step via [CommandStepRunner], collecting [StepRunResult]s and [ExecutionIssue]s. * - If a step ID returned by the order strategy is not present in the plan, a * [ExecutionStatus.FAILED] result and an [ExecutionIssueKind.ORCHESTRATOR_ERROR] issue @@ -63,39 +64,58 @@ class DefaultPlanExecutor( policy: RunPolicy ): ExecutionReport { val workspace = workspaceManager.create(plan, runId) - val stepOrder = stepOrderStrategy.order(plan) + val stepOrder = options.stepOrderStrategy.order(plan) val stepsById: Map = plan.steps.associateBy { it.stepId } val stepRunner = createStepRunner() + val environmentCoordinator = EnvironmentExecutionCoordinator( + plan = plan, + orchestrator = options.orchestrator, + config = options.environmentConfig + ) val stepResults = mutableListOf() val runIssues = mutableListOf() val knownStepContext = KnownStepExecutionContext( workspace = workspace, policy = policy, stepRunner = stepRunner, + environmentCoordinator = environmentCoordinator, stepResults = stepResults, runIssues = runIssues ) var halted = false - - for (stepId in stepOrder) { - val step = stepsById[stepId] - if (step == null) { - halted = handleUnknownStep(stepId, policy, stepResults, runIssues, halted) - continue + try { + if (environmentCoordinator.setupEagerEnvironments(runIssues) && policy.stopOnFailure) { + halted = true } - if (halted) { - recordSkippedStep(stepId, stepResults) - continue - } + for (stepId in stepOrder) { + val step = stepsById[stepId] + if (step == null) { + halted = handleUnknownStep(stepId, policy, stepResults, runIssues, halted) + continue + } - val result = runKnownStep(step, knownStepContext) - if (result.status == ExecutionStatus.FAILED && policy.stopOnFailure) { - halted = true + if (halted) { + recordSkippedStep(stepId, stepResults) + continue + } + + val result = runKnownStep(step, knownStepContext) + if (result.status == ExecutionStatus.FAILED && policy.stopOnFailure) { + halted = true + } } + } finally { + environmentCoordinator.teardownAll(runIssues) } - return buildExecutionReport(plan, runId, stepResults, runIssues) + return buildExecutionReport( + plan = plan, + runId = runId, + stepResults = stepResults, + runIssues = runIssues, + environmentLogs = environmentCoordinator.environmentLogs() + ) } // Helpers @@ -103,20 +123,38 @@ class DefaultPlanExecutor( private fun createStepRunner(): CommandStepRunner = CommandStepRunner( workspaceManager = workspaceManager, - commandRunner = commandRunner, + commandRunner = options.commandRunner, artefactStore = artefactStore, options = CommandStepRunner.Options( artefactRecorder = FileSystemArtefactRecorder(), logRecorder = FileSystemStepLogRecorder(), - outputValidationPolicy = outputValidationPolicy, - clock = clock + outputValidationPolicy = options.outputValidationPolicy, + clock = options.clock ) ) + /** + * Optional dependencies and defaults used by [DefaultPlanExecutor]. + * Groups constructor parameters to keep the primary signature small. + */ + data class Options( + val commandRunner: CommandRunner = JvmCommandRunner(), + val stepOrderStrategy: StepOrderStrategy = SequentialPlanOrder, + val outputValidationPolicy: OutputValidationPolicy = OutputValidationPolicy.DEFAULT, + val orchestrator: EnvironmentOrchestrator = DefaultEnvironmentOrchestrator( + DefaultEnvironmentRegistry( + Paths.get(System.getProperty("java.io.tmpdir"), "carp-dsp-environment-registry.json") + ) + ), + val environmentConfig: EnvironmentConfig = EnvironmentConfig(), + val clock: Clock = Clock.System + ) + private data class KnownStepExecutionContext( val workspace: ExecutionWorkspace, val policy: RunPolicy, val stepRunner: CommandStepRunner, + val environmentCoordinator: EnvironmentExecutionCoordinator, val stepResults: MutableList, val runIssues: MutableList ) @@ -158,7 +196,20 @@ class DefaultPlanExecutor( step: PlannedStep, context: KnownStepExecutionContext ): StepRunResult { - val result = context.stepRunner.run(step, context.workspace, context.policy) + val preparedStep = context.environmentCoordinator.prepareStep(step, context.runIssues) + if (preparedStep == null) { + val failedResult = StepRunResult( + stepId = step.stepId, + status = ExecutionStatus.FAILED, + startedAt = null, + finishedAt = null, + outputs = emptyList() + ) + context.stepResults += failedResult + return failedResult + } + + val result = context.stepRunner.run(preparedStep, context.workspace, context.policy) context.stepResults += result context.runIssues += context.stepRunner.drainIssues(step.stepId) return result @@ -168,7 +219,8 @@ class DefaultPlanExecutor( plan: ExecutionPlan, runId: UUID, stepResults: List, - runIssues: List + runIssues: List, + environmentLogs: EnvironmentExecutionLogs ): ExecutionReport { val overallStatus = deriveOverallStatus(stepResults) @@ -179,7 +231,8 @@ class DefaultPlanExecutor( finishedAt = stepResults.lastOrNull { it.finishedAt != null }?.finishedAt, status = overallStatus, stepResults = stepResults, - issues = runIssues + issues = runIssues, + environmentLogs = environmentLogs ) } @@ -191,4 +244,165 @@ class DefaultPlanExecutor( results.all { it.status == ExecutionStatus.SUCCEEDED } -> ExecutionStatus.SUCCEEDED else -> ExecutionStatus.FAILED } + + /** + * Coordinates environment lifecycle and command wrapping for each step. + */ + private class EnvironmentExecutionCoordinator( + private val plan: ExecutionPlan, + private val orchestrator: EnvironmentOrchestrator, + private val config: EnvironmentConfig + ) { + private val setupEnvironments = mutableSetOf() + private val requiredRefs = plan.requiredEnvironmentRefs.values.distinctBy { it.id } + + fun setupEagerEnvironments(runIssues: MutableList): Boolean { + if (config.setupTiming != SetupTiming.EAGER) return false + + var failed = false + for (environmentRef in requiredRefs) { + if (!ensureSetup(environmentRef.id, runIssues)) { + failed = true + } + } + return failed + } + + fun prepareStep(step: PlannedStep, runIssues: MutableList): PlannedStep? { + val environmentId = step.environmentRef ?: return step + if (plan.requiredEnvironmentRefs.isEmpty()) return step + + val environmentRef = plan.requiredEnvironmentRefs[environmentId] + if (environmentRef == null) { + runIssues += ExecutionIssue( + stepId = step.stepId, + kind = ExecutionIssueKind.ORCHESTRATOR_ERROR, + message = "No EnvironmentRef mapped for step '${step.name}' ($environmentId)." + ) + return null + } + + if (config.setupTiming == SetupTiming.LAZY && !ensureSetup(environmentRef.id, runIssues, step.stepId)) { + return null + } + + return wrapStepCommand(step, environmentRef.id, environmentRef, runIssues) + } + + fun teardownAll(runIssues: MutableList) { + for (environmentRef in requiredRefs) { + val success = runCatching { orchestrator.teardown(environmentRef) }.getOrElse { false } + if (!success) { + runIssues += ExecutionIssue( + kind = ExecutionIssueKind.ORCHESTRATOR_ERROR, + message = "Failed to teardown environment '${environmentRef.id}'." + ) + } + } + } + + fun environmentLogs(): EnvironmentExecutionLogs = + (orchestrator as? DefaultEnvironmentOrchestrator)?.getEnvironmentLogs() + ?: EnvironmentExecutionLogs() + + private fun ensureSetup( + environmentId: String, + runIssues: MutableList, + stepId: UUID? = null + ): Boolean { + if (setupEnvironments.contains(environmentId)) return true + + val ref = requiredRefs.firstOrNull { it.id == environmentId } + if (ref == null) { + runIssues += ExecutionIssue( + stepId = stepId, + kind = ExecutionIssueKind.ORCHESTRATOR_ERROR, + message = "Environment '$environmentId' is not available in requiredEnvironmentRefs." + ) + return false + } + + val setupOk = runCatching { orchestrator.setup(ref) }.getOrElse { false } + if (!setupOk) { + runIssues += ExecutionIssue( + stepId = stepId, + kind = ExecutionIssueKind.ORCHESTRATOR_ERROR, + message = "Failed to setup environment '${ref.id}'." + ) + return false + } + + setupEnvironments += environmentId + return true + } + + private fun wrapStepCommand( + step: PlannedStep, + environmentId: String, + environmentRef: dk.cachet.carp.analytics.application.plan.EnvironmentRef, + runIssues: MutableList + ): PlannedStep? { + val process = step.process + if (process !is CommandSpec) return step + + val wrappedSpec = runCatching { + val baseCommand = buildBaseCommand(process) + val wrapped = orchestrator.generateExecutionCommand(environmentRef, baseCommand) + toShellCommandSpec(wrapped) + }.getOrNull() + + if (wrappedSpec == null) { + runIssues += ExecutionIssue( + stepId = step.stepId, + kind = ExecutionIssueKind.ORCHESTRATOR_ERROR, + message = "Failed to generate execution command for environment '$environmentId'." + ) + return null + } + + return step.copy(process = wrappedSpec) + } + + private fun buildBaseCommand(spec: CommandSpec): String { + val args = spec.args + .map { argToLiteral(it) } + .joinToString(" ") { shellQuote(it) } + return if (args.isBlank()) spec.executable else "${spec.executable} $args" + } + + private fun argToLiteral(arg: ExpandedArg): String = when (arg) { + is ExpandedArg.Literal -> arg.value + is ExpandedArg.DataReference -> arg.dataRefId.toString() + is ExpandedArg.PathSubstitution -> arg.template.replace("$()", arg.dataRefId.toString()) + } + + private fun toShellCommandSpec(command: String): CommandSpec { + val shell = if (isWindows()) "cmd" else "sh" + val switch = if (isWindows()) "/c" else "-lc" + return CommandSpec( + executable = shell, + args = listOf(ExpandedArg.Literal(switch), ExpandedArg.Literal(command)) + ) + } + + private fun shellQuote(value: String): String { + if (value.isEmpty()) return if (isWindows()) "\"\"" else "''" + return if (isWindows()) { + if (value.any { it.isWhitespace() || it == '"' }) { + "\"${value.replace("\"", "\\\"")}\"" + } else { + value + } + } else { + if (value.any { it.isWhitespace() || it == '\'' }) { + "'${value.replace("'", "'\"'\"'")}'" + } else { + value + } + } + } + + private fun isWindows(): Boolean = + System.getProperty("os.name")?.contains("win", ignoreCase = true) == true + } } diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistry.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistry.kt new file mode 100644 index 0000000..1ccbd27 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistry.kt @@ -0,0 +1,34 @@ +package carp.dsp.core.infrastructure.execution + +import carp.dsp.core.infrastructure.execution.handlers.CondaEnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.PixiEnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.REnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.SystemEnvironmentHandler +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler + +/** + * Routes EnvironmentRef to appropriate handler. + */ +object EnvironmentHandlerRegistry { + + private val handlers: List = listOf( + CondaEnvironmentHandler(), + PixiEnvironmentHandler(), + REnvironmentHandler(), + SystemEnvironmentHandler() + + ) + + /** + * Get the handler for an environment reference. + * + * @throws IllegalArgumentException if no handler found + */ + fun getHandler(environmentRef: EnvironmentRef): EnvironmentHandler { + return handlers.find { it.canHandle(environmentRef) } + ?: throw IllegalArgumentException( + "No handler for environment type: ${environmentRef::class.simpleName}" + ) + } +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManager.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManager.kt index 325649c..9935888 100644 --- a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManager.kt +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManager.kt @@ -214,7 +214,7 @@ class PlanBasedWorkspaceManager( * The planId is deliberately excluded as it may be a random UUID that changes between planning runs. */ @Serializable -private data class SimplifiedPlanHashContent( +internal data class SimplifiedPlanHashContent( val workflowId: String, val steps: List, val requiredEnvironmentHandles: List @@ -226,7 +226,7 @@ private data class SimplifiedPlanHashContent( * This excludes the polymorphic TasksRun field to avoid serialization complexity. */ @Serializable -private data class SimplifiedStepHashContent( +internal data class SimplifiedStepHashContent( val stepId: String, val name: String, val environmentDefinitionId: String, diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtils.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtils.kt new file mode 100644 index 0000000..be12f55 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtils.kt @@ -0,0 +1,39 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import java.io.IOException +import java.nio.file.Path + +internal fun executeCommand( + cmd: List, + workingDir: Path? = null +): CommandResult { + return try { + val processBuilder = ProcessBuilder(cmd) + if (workingDir != null) { + processBuilder.directory(workingDir.toFile()) + } + + val process = processBuilder + .redirectErrorStream(false) + .start() + + val exitCode = process.waitFor() + val stdout = process.inputStream.bufferedReader().use { it.readText() } + val stderr = process.errorStream.bufferedReader().use { it.readText() } + + CommandResult(exitCode, stdout, stderr) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + CommandResult(1, "", "Command execution interrupted") + } catch (e: IOException) { + CommandResult(1, "", e.message ?: "I/O error during command execution") + } catch (e: IllegalArgumentException) { + CommandResult(1, "", e.message ?: "Invalid command arguments") + } +} + +internal data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String +) diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandler.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandler.kt new file mode 100644 index 0000000..ecd92f8 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandler.kt @@ -0,0 +1,178 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import carp.dsp.core.application.execution.CommandPolicy +import carp.dsp.core.infrastructure.runtime.JvmCommandRunner +import dk.cachet.carp.analytics.application.plan.CommandSpec +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.application.plan.ExpandedArg +import dk.cachet.carp.analytics.application.runtime.CommandResult +import dk.cachet.carp.analytics.application.runtime.CommandRunner +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler + +/** + * Handles Conda environment setup, execution, and teardown. + * + * @param runner The core [CommandRunner] used for all process invocations. + * Defaults to [JvmCommandRunner] for production. + */ +class CondaEnvironmentHandler( + private val runner: CommandRunner = JvmCommandRunner() +) : EnvironmentHandler { + + private val defaultPolicy = CommandPolicy() + + override fun canHandle(environmentRef: EnvironmentRef): Boolean = + environmentRef is CondaEnvironmentRef + + override fun setup(environmentRef: EnvironmentRef): Boolean { + val conda = environmentRef as CondaEnvironmentRef + + if (!verifyCondaInstalled()) { + throw EnvironmentProvisioningException( + "Conda not found. Install conda and add to PATH." + ) + } + + val createSuccess = createCondaEnvironment( + name = conda.name, + pythonVersion = conda.pythonVersion, + channels = conda.channels, + dependencies = conda.dependencies + ) + if (!createSuccess) { + throw EnvironmentProvisioningException( + "Failed to create conda environment: ${conda.name}" + ) + } + + if (!validate(conda)) { + throw EnvironmentProvisioningException( + "Environment created but validation failed" + ) + } + + return true + } + + override fun generateExecutionCommand(environmentRef: EnvironmentRef, command: String): String { + val conda = environmentRef as CondaEnvironmentRef + return "conda run -n ${conda.name} $command" + } + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + val conda = environmentRef as CondaEnvironmentRef + + return try { + // Mirrors conda's own behaviour: `conda env remove` exits 0 silently when + // the environment does not exist. teardown's postcondition is "the environment + // does not exist", which is already satisfied whether we removed it or it + // was never there. -y suppresses the interactive confirmation prompt in CI. + runCommand("env", "remove", "-n", conda.name, "-y").exitCode == 0 + } catch (_: Exception) { + false + } + } + + override fun validate(environmentRef: EnvironmentRef): Boolean { + val conda = environmentRef as CondaEnvironmentRef + + return try { + if (!condaEnvironmentExists(conda.name)) return false + + if (runCommand("run", "-n", conda.name, "python", "--version").exitCode != 0) + return false + + for (dep in conda.dependencies) { + val moduleName = dep.split("/")[0].split("=")[0] + if (runCommand("run", "-n", conda.name, "python", "-c", "import $moduleName").exitCode != 0) + return false + } + + true + } catch (_: Exception) { + false + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun verifyCondaInstalled(): Boolean = try { + runCommand("--version").exitCode == 0 + } catch (_: Exception) { + false + } + + private fun condaEnvironmentExists(envName: String): Boolean { + return try { + val result = runCommand("env", "list") + if (result.exitCode != 0) return false + + result.stdout.lines().any { line -> + val t = line.trim() + t.startsWith("$envName ") || + t.startsWith("* $envName ") || + t.contains("/$envName") || + t.contains("\\$envName") + } + } catch (_: Exception) { + false + } + } + + private fun createCondaEnvironment( + name: String, + pythonVersion: String, + channels: List, + dependencies: List + ): Boolean = try { + val condaPackages = mutableListOf() + val pipPackages = mutableListOf() + for (dep in dependencies) { + if (dep.startsWith("pip:")) pipPackages.add(dep.removePrefix("pip:")) + else condaPackages.add(dep) + } + + val createArgs = mutableListOf("create", "-n", name, "-y", "python=$pythonVersion") + for (channel in channels) { + createArgs.add("-c") + createArgs.add(channel) + } + createArgs.addAll(condaPackages) + if (pipPackages.isNotEmpty()) createArgs.add("pip") + + val createResult = runCommand(createArgs) + if (createResult.exitCode != 0) { + throw CondaCommandExecutionException("conda create failed:\n${createResult.stderr}") + } + + if (pipPackages.isNotEmpty()) { + val pipCmd = listOf("run", "-n", name, "pip", "install") + pipPackages + val pipResult = runCommand(pipCmd) + if (pipResult.exitCode != 0) { + throw CondaCommandExecutionException("pip install failed:\n${pipResult.stderr}") + } + } + + true + } catch (_: Exception) { + false + } + + /** + * Builds a [CommandSpec] with all-[ExpandedArg.Literal] arguments and runs it + * via [runner] with [defaultPolicy]. + * + * All conda management commands are plain strings — no data-reference or + * path-substitution tokens are ever needed here. + */ + private fun runCommand(args: List): CommandResult = + runner.run( + CommandSpec(executable = "conda", args = args.map { ExpandedArg.Literal(it) }), + defaultPolicy + ) + + private fun runCommand(vararg args: String): CommandResult = runCommand(args.toList()) +} + +class CondaCommandExecutionException(message: String) : IllegalStateException(message) diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/EnvironmentProvisioningException.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/EnvironmentProvisioningException.kt new file mode 100644 index 0000000..6e55664 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/EnvironmentProvisioningException.kt @@ -0,0 +1,10 @@ +package carp.dsp.core.infrastructure.execution.handlers + +/** + * Raised when an environment cannot be provisioned or validated. + */ +class EnvironmentProvisioningException( + message: String, + cause: Throwable? = null +) : IllegalStateException(message, cause) + diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandler.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandler.kt new file mode 100644 index 0000000..d2df3a0 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandler.kt @@ -0,0 +1,161 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import carp.dsp.core.application.execution.CommandPolicy +import carp.dsp.core.infrastructure.runtime.JvmCommandRunner +import dk.cachet.carp.analytics.application.plan.CommandSpec +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.application.plan.ExpandedArg +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import dk.cachet.carp.analytics.application.runtime.CommandResult +import dk.cachet.carp.analytics.application.runtime.CommandRunner +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler +import java.io.IOException +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +/** + * Handles Pixi environment setup, execution, and teardown. + * + * @param runner The core [CommandRunner] used for process invocations. + * Defaults to [JvmCommandRunner] for production. + * + */ +class PixiEnvironmentHandler( + private val runner: CommandRunner = JvmCommandRunner() +) : EnvironmentHandler { + + private val defaultPolicy = CommandPolicy() + + override fun canHandle(environmentRef: EnvironmentRef): Boolean = + environmentRef is PixiEnvironmentRef + + override fun setup(environmentRef: EnvironmentRef): Boolean { + val pixi = environmentRef as PixiEnvironmentRef + + return try { + check(verifyPixiInstalled()) { "Pixi not found. Install pixi and add to PATH." } + + val projectDir = getProjectDirectory(pixi.id) + projectDir.createDirectories() + + generatePixiToml(projectDir, pixi) + + check(installPixiEnvironment(projectDir)) { "pixi install failed" } + + check(validate(pixi)) { "Environment created but validation failed" } + + true + } catch (e: IllegalStateException) { + throw EnvironmentProvisioningException("Pixi setup failed: ${e.message}", e) + } catch (e: IOException) { + throw EnvironmentProvisioningException("Pixi setup failed: ${e.message}", e) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw EnvironmentProvisioningException("Pixi setup interrupted", e) + } + } + + override fun generateExecutionCommand(environmentRef: EnvironmentRef, command: String): String = + "pixi run $command" + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + val pixi = environmentRef as PixiEnvironmentRef + + return try { + val projectDir = getProjectDirectory(pixi.id) + if (projectDir.toFile().exists()) { + projectDir.toFile().deleteRecursively() + } + true + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false + } + } + + override fun validate(environmentRef: EnvironmentRef): Boolean { + val pixi = environmentRef as PixiEnvironmentRef + + return try { + val projectDir = getProjectDirectory(pixi.id) + if (!projectDir.toFile().exists()) return false + + val pythonExe = findPythonExecutable(projectDir) ?: return false + if (!pythonExe.toFile().exists()) return false + + runCommand(pythonExe.toString()).exitCode == 0 + } catch (_: IOException) { + false + } catch (_: IllegalStateException) { + false + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun verifyPixiInstalled(): Boolean = try { + runCommand("pixi").exitCode == 0 + } catch (_: IOException) { + false + } catch (_: IllegalArgumentException) { + false + } + + private fun getProjectDirectory(envId: String): Path = + Path.of(System.getProperty("user.home"), ".carp-dsp", "envs", "pixi", envId) + + private fun generatePixiToml(projectDir: Path, pixi: PixiEnvironmentRef) { + val dependenciesStr = pixi.dependencies.joinToString( + separator = "\n ", + prefix = "\n ", + postfix = "\n" + ) { "\"$it\"" } + + val tomlContent = """ + [project] + name = "carp-dsp-env" + version = "0.1.0" + description = "CARP-DSP Pixi Environment" + + [dependencies] + python = "${pixi.pythonVersion}"$dependenciesStr + + [tasks] + """.trimIndent() + + projectDir.resolve("pixi.toml").writeText(tomlContent) + } + + private fun installPixiEnvironment(projectDir: Path): Boolean = try { + // `pixi install` must run with the project directory as its working directory. + // See class KDoc for why we use JvmCommandRunner's workspace-root overload here. + val spec = CommandSpec( + executable = "pixi", + args = listOf(ExpandedArg.Literal("install")) + ) + val result = when (val r = runner) { + is JvmCommandRunner -> r.run(spec, defaultPolicy, projectDir) + else -> runner.run(spec, defaultPolicy) // MockCommandRunner captures workingDir separately + } + result.exitCode == 0 + } catch (_: IOException) { + false + } catch (_: IllegalArgumentException) { + false + } + + private fun findPythonExecutable(projectDir: Path): Path? = + listOf( + projectDir.resolve(".pixi/envs/default/bin/python"), + projectDir.resolve(".pixi/envs/default/bin/python3"), + projectDir.resolve(".pixi/envs/default/Scripts/python.exe") + ).find { it.toFile().exists() } + + private fun runCommand(executable: String): CommandResult = + runner.run( + CommandSpec(executable = executable, args = listOf(ExpandedArg.Literal("--version")) ), + defaultPolicy + ) +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandler.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandler.kt new file mode 100644 index 0000000..9e858d5 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandler.kt @@ -0,0 +1,210 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import carp.dsp.core.application.execution.CommandPolicy +import carp.dsp.core.infrastructure.runtime.JvmCommandRunner +import dk.cachet.carp.analytics.application.plan.CommandSpec +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.application.plan.ExpandedArg +import dk.cachet.carp.analytics.application.plan.REnvironmentRef +import dk.cachet.carp.analytics.application.runtime.CommandResult +import dk.cachet.carp.analytics.application.runtime.CommandRunner +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler +import java.io.IOException +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText + +/** + * Handles R environment setup, execution, and teardown. + * + * Supports: + * - renv-managed environments (preferred) + * - System R with direct package installation + * - R with a custom installation path via [rscriptCommand] + * + * @param runner The core [CommandRunner] used for all process invocations. + */ +class REnvironmentHandler( + private val runner: CommandRunner = JvmCommandRunner() +) : EnvironmentHandler { + + private val defaultPolicy = CommandPolicy() + + override fun canHandle(environmentRef: EnvironmentRef): Boolean = + environmentRef is REnvironmentRef + + override fun setup(environmentRef: EnvironmentRef): Boolean { + val r = environmentRef as REnvironmentRef + + return try { + check(verifyRInstalled(r)) { "R ${r.rVersion} not found. Install R and add to PATH." } + + val envDir = getREnvironmentDirectory(r.id) + envDir.createDirectories() + + if (r.renvLockFile != null) { + check(setupRenv(envDir, r)) { "Failed to setup renv environment" } + } else { + check(installRPackages(r)) { "Failed to install R packages" } + } + + check(validate(r)) { "Environment created but validation failed" } + + true + } catch (e: IllegalStateException) { + throw EnvironmentProvisioningException("R setup failed: ${e.message}", e) + } catch (e: IOException) { + throw EnvironmentProvisioningException("R setup failed: ${e.message}", e) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw EnvironmentProvisioningException("R setup interrupted", e) + } + } + + override fun generateExecutionCommand(environmentRef: EnvironmentRef, command: String): String { + val r = environmentRef as REnvironmentRef + return if (r.renvLockFile != null) "Rscript --vanilla $command" + else "Rscript $command" + } + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + val r = environmentRef as REnvironmentRef + + return try { + val envDir = getREnvironmentDirectory(r.id) + if (envDir.toFile().exists()) { + envDir.toFile().deleteRecursively() + } + true + } catch (_: Exception) { + false + } + } + + override fun validate(environmentRef: EnvironmentRef): Boolean { + val r = environmentRef as REnvironmentRef + + return try { + val versionCheck = runCommand(rscriptCommand(), "--version") + if (versionCheck.exitCode != 0) return false + + val rVersionOutput = "${versionCheck.stdout}\n${versionCheck.stderr}".trim() + if (!isRVersionCompatible(rVersionOutput, r.rVersion)) return false + + for (pkg in r.rPackages) { + val pkgName = pkg.split("/")[0].split("@")[0].split("=")[0] + val pkgCheck = runCommand( + rscriptCommand(), + "-e", + "if (!require('$pkgName', quietly=TRUE)) quit(status=1)" + ) + if (pkgCheck.exitCode != 0) return false + } + + true + } catch (_: IOException) { + false + } catch (_: IllegalArgumentException) { + false + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private fun verifyRInstalled(r: REnvironmentRef): Boolean = try { + val result = runCommand(rscriptCommand(), "--version") + result.exitCode == 0 && + isRVersionCompatible("${result.stdout}\n${result.stderr}", r.rVersion) + } catch (_: IOException) { + false + } catch (_: IllegalArgumentException) { + false + } + + private fun isRVersionCompatible(versionOutput: String, requestedVersion: String): Boolean { + val normalizedRequested = requestedVersion.trim() + if (normalizedRequested.isEmpty()) return false + + val installedVersion = + Regex("""R version\s+(\d+(?:\.\d+){0,2})""") + .find(versionOutput)?.groupValues?.get(1) + ?: Regex("""(\d+(?:\.\d+){0,2})""") + .find(versionOutput)?.groupValues?.get(1) + ?: return false + + val requestedParts = normalizedRequested.split(".") + val installedParts = installedVersion.split(".") + val segmentsToMatch = minOf(2, requestedParts.size, installedParts.size) + + return (0 until segmentsToMatch).all { idx -> requestedParts[idx] == installedParts[idx] } + } + + private fun getREnvironmentDirectory(envId: String): Path = + Path.of(System.getProperty("user.home"), ".carp-dsp", "envs", "r", envId) + + private fun setupRenv(envDir: Path, r: REnvironmentRef): Boolean = try { + val lockFilePath = Path.of(r.renvLockFile!!) + check(lockFilePath.exists()) { "renv.lock file not found: ${r.renvLockFile}" } + + val targetLock = envDir.resolve("renv.lock") + lockFilePath.toFile().copyTo(targetLock.toFile(), overwrite = true) + + val rprofile = envDir.resolve(".Rprofile") + rprofile.writeText( + """ + if (file.exists("renv/activate.R")) { + source("renv/activate.R") + } else { + message("renv not activated") + } + """.trimIndent() + ) + + // renv::restore() must run from the project directory. + // See PixiEnvironmentHandler for notes on working-dir handling. + val spec = CommandSpec( + executable = rscriptCommand(), + args = listOf(ExpandedArg.Literal("-e"), ExpandedArg.Literal("renv::restore()")) + ) + val result = when (val r2 = runner) { + is JvmCommandRunner -> r2.run(spec, defaultPolicy, envDir) + else -> runner.run(spec, defaultPolicy) + } + result.exitCode == 0 + } catch (_: IOException) { + false + } catch (_: IllegalStateException) { + false + } + + private fun installRPackages(r: REnvironmentRef): Boolean = try { + for (pkg in r.rPackages) { + val result = runCommand(rscriptCommand(), "-e", "install.packages('$pkg')") + check(result.exitCode == 0) { "Failed to install package: $pkg" } + } + true + } catch (_: IOException) { + false + } catch (_: IllegalStateException) { + false + } + + /** + * Builds a [CommandSpec] with all-[ExpandedArg.Literal] arguments and runs it + * via [runner] with [defaultPolicy] and no working-directory override. + */ + private fun runCommand(executable: String, vararg args: String): CommandResult = + runner.run( + CommandSpec(executable = executable, args = args.map { ExpandedArg.Literal(it) }), + defaultPolicy + ) + + /** + * Resolves the Rscript binary path. + * + * Check order: `carp.rscript` system property → `CARP_RSCRIPT` env var → `"Rscript"`. + */ + private fun rscriptCommand(): String = + System.getProperty("carp.rscript") ?: System.getenv("CARP_RSCRIPT") ?: "Rscript" +} diff --git a/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandler.kt b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandler.kt new file mode 100644 index 0000000..b776e33 --- /dev/null +++ b/carp.dsp.core/src/jvmMain/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandler.kt @@ -0,0 +1,41 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import dk.cachet.carp.analytics.application.plan.EnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentHandler + +/** + * No-op handler for system environments. + * + * System environment is always available. + * No setup, teardown, or validation needed. + */ +class SystemEnvironmentHandler : EnvironmentHandler { + + override fun canHandle(environmentRef: EnvironmentRef): Boolean { + return environmentRef is SystemEnvironmentRef + } + + override fun setup(environmentRef: EnvironmentRef): Boolean { + // No-op: system is always available + return true + } + + override fun generateExecutionCommand( + environmentRef: EnvironmentRef, + command: String + ): String { + // No wrapping: use command as-is + return command + } + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + // No-op: nothing to clean up + return true + } + + override fun validate(environmentRef: EnvironmentRef): Boolean { + // System is always valid + return true + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/environment/SystemEnvironmentDefinitionTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/environment/SystemEnvironmentDefinitionTest.kt new file mode 100644 index 0000000..c15ae9d --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/environment/SystemEnvironmentDefinitionTest.kt @@ -0,0 +1,39 @@ +package carp.dsp.core.application.environment + +import dk.cachet.carp.common.application.UUID +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SystemEnvironmentDefinitionTest { + + private val json = Json + + @Test + fun `defaults are empty lists and maps`() { + val def = SystemEnvironmentDefinition( + id = UUID.randomUUID(), + name = "system" + ) + + assertTrue(def.dependencies.isEmpty()) + assertTrue(def.environmentVariables.isEmpty()) + } + + @Test + fun `serializes and deserializes`() { + val def = SystemEnvironmentDefinition( + id = UUID.randomUUID(), + name = "host", + dependencies = listOf("curl", "bash"), + environmentVariables = mapOf("PATH" to "/usr/bin", "HOME" to "/home/test") + ) + + val encoded = json.encodeToString(SystemEnvironmentDefinition.serializer(), def) + val decoded = json.decodeFromString(SystemEnvironmentDefinition.serializer(), encoded) + + assertEquals(def, decoded) + } +} + diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/execution/CommandPolicyTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/execution/CommandPolicyTest.kt index 4e0cfa8..70bddc6 100644 --- a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/execution/CommandPolicyTest.kt +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/application/execution/CommandPolicyTest.kt @@ -304,4 +304,31 @@ class CommandPolicyTest { ) assertEquals(original, roundTrip) } + + @Test + fun `deserializes from minimal json using defaults`() { + val json = Json { encodeDefaults = false } + + val decoded = json.decodeFromString(CommandPolicy.serializer(), "{}") + + assertEquals(null, decoded.timeoutMs) + assertEquals(null, decoded.workingDirectory) + assertTrue(decoded.stopOnFailure) + assertEquals(false, decoded.failOnWarnings) + assertEquals(1, decoded.maxAttempts) + } + + @Test + fun `deserializes with non-default booleans and maxAttempts`() { + val json = Json { encodeDefaults = false } + val payload = """ + {"stopOnFailure":false,"failOnWarnings":true,"maxAttempts":3} + """.trimIndent() + + val decoded = json.decodeFromString(CommandPolicy.serializer(), payload) + + assertEquals(false, decoded.stopOnFailure) + assertEquals(true, decoded.failOnWarnings) + assertEquals(3, decoded.maxAttempts) + } } diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestratorTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestratorTest.kt new file mode 100644 index 0000000..3203774 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentOrchestratorTest.kt @@ -0,0 +1,239 @@ +package carp.dsp.core.infrastructure.execution + +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.* +import kotlinx.datetime.Clock +import java.nio.file.Files +import kotlin.test.* + +class DefaultEnvironmentOrchestratorTest { + + private lateinit var tmpDir: java.nio.file.Path + private lateinit var registry: EnvironmentRegistry + private lateinit var orchestrator: DefaultEnvironmentOrchestrator + + @BeforeTest + fun setup() { + tmpDir = Files.createTempDirectory("orch-test") + registry = DefaultEnvironmentRegistry(tmpDir.resolve("environments.json")) + orchestrator = DefaultEnvironmentOrchestrator(registry) + } + + @Test + fun generateExecutionCommandForSystem() { + val ref = SystemEnvironmentRef(id = "system-001") + + val command = orchestrator.generateExecutionCommand(ref, "python script.py") + + assertEquals("python script.py", command) + } + + @Test + fun generateExecutionCommandForConda() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "my-env", + dependencies = emptyList() + ) + + val command = orchestrator.generateExecutionCommand(ref, "python script.py") + + assertTrue(command.contains("conda run -n my-env")) + assertTrue(command.contains("python script.py")) + } + + @Test + fun generateExecutionCommandForPixi() { + val ref = PixiEnvironmentRef(id = "test-001", dependencies = emptyList()) + + val command = orchestrator.generateExecutionCommand(ref, "python script.py") + + assertTrue(command.contains("pixi run")) + assertTrue(command.contains("python script.py")) + } + + @Test + fun setupReturnsTrue() { + val ref = SystemEnvironmentRef(id = "system-001") + + val result = orchestrator.setup(ref) + + assertTrue(result) + } + + @Test + fun teardownWithReusePolicy() { + val config = EnvironmentConfig(cleanupPolicy = CleanupPolicy.REUSE) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "system-001") + + val result = orch.teardown(ref) + + assertTrue(result) + } + + @Test + fun teardownWithCleanPolicy() { + val config = EnvironmentConfig(cleanupPolicy = CleanupPolicy.CLEAN) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "system-001") + + val result = orch.teardown(ref) + + assertTrue(result) + } + + @Test + fun teardownWithPurgePolicy() { + val config = EnvironmentConfig(cleanupPolicy = CleanupPolicy.PURGE) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + // Register an environment + val ref = SystemEnvironmentRef(id = "system-001") + val metadata = EnvironmentMetadata( + id = ref.id, + name = "system", + kind = "system", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 0L + ) + registry.register(ref, metadata) + + assertTrue(registry.exists(ref.id)) + + orch.teardown(ref) + + // PURGE should remove from registry + assertFalse(registry.exists(ref.id)) + } + + @Test + fun registersEnvironmentAfterSetup() { + val ref = SystemEnvironmentRef(id = "system-001") + + assertFalse(registry.exists(ref.id)) + + orchestrator.setup(ref) + + assertTrue(registry.exists(ref.id)) + } + + @Test + fun reuseExistingEnvironment() { + val config = EnvironmentConfig(reuseExisting = true) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "system-001") + + // First setup + orch.setup(ref) + + val metadata1 = registry.getMetadata(ref.id) + assertNotNull(metadata1) + + val metadata2 = metadata1.copy(reuseCount = 5) + registry.register(ref, metadata2) + + // Second setup (should reuse) + val result = orch.setup(ref) + assertTrue(result) + + // Reuse count should be updated from registry + val retrieved = registry.getMetadata(ref.id) + assertEquals(5, retrieved?.reuseCount) + } + + @Test + fun logsEnvironmentOperations() { + val ref = SystemEnvironmentRef(id = "system-001") + + orchestrator.setup(ref) + orchestrator.teardown(ref) + + val logs = orchestrator.getEnvironmentLogs() + + assertTrue(logs.setupLogs.isNotEmpty()) + assertTrue(logs.teardownLogs.isNotEmpty()) + } + + @Test + fun logsContainCorrectPhase() { + val ref = SystemEnvironmentRef(id = "system-001") + + orchestrator.setup(ref) + + val logs = orchestrator.getEnvironmentLogs() + + assertTrue(logs.setupLogs.any { it.phase == EnvironmentPhase.SETUP }) + } + + @Test + fun setupTimingEager() { + val config = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "system-001") + + orch.setup(ref) + + assertTrue(registry.exists(ref.id)) + } + + @Test + fun setupTimingLazy() { + val config = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "system-001") + + // Should set up when requested + orch.setup(ref) + + assertTrue(registry.exists(ref.id)) + } + + @Test + fun errorHandlingFailFast() { + val config = EnvironmentConfig(errorHandling = ErrorHandling.FAIL_FAST) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + // System handler won't fail, but config is set + val ref = SystemEnvironmentRef(id = "system-001") + val result = orch.setup(ref) + + assertTrue(result) + } + + @Test + fun environmentMetadataHasCorrectFields() { + val ref = SystemEnvironmentRef(id = "system-001") + + orchestrator.setup(ref) + + val metadata = registry.getMetadata(ref.id) + + assertNotNull(metadata) + assertEquals("system-001", metadata.id) + assertEquals("system", metadata.kind) + assertEquals("active", metadata.status) + } + + @Test + fun `run and step ids extracted from environment id`() { + val ref = SystemEnvironmentRef(id = "run-a-b-step-42-system") + + orchestrator.setup(ref) + + val metadata = registry.getMetadata(ref.id) + + assertNotNull(metadata) + assertEquals("run-a-b", metadata.runId) + assertEquals("step", metadata.stepId) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistryTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistryTest.kt new file mode 100644 index 0000000..fba1a01 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultEnvironmentRegistryTest.kt @@ -0,0 +1,227 @@ +package carp.dsp.core.infrastructure.execution + + +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentMetadata +import kotlinx.datetime.Clock +import java.nio.file.Files +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively +import kotlin.test.* + +class DefaultEnvironmentRegistryTest { + + private lateinit var tmpDir: java.nio.file.Path + private lateinit var registry: DefaultEnvironmentRegistry + + @BeforeTest + fun setup() { + tmpDir = Files.createTempDirectory("env-registry-test") + registry = DefaultEnvironmentRegistry(tmpDir.resolve("environments.json")) + } + + @OptIn(ExperimentalPathApi::class) + @AfterTest + fun cleanup() { + tmpDir.deleteRecursively() + } + + @Test + fun registersEnvironmentMetadata() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L + ) + + registry.register(ref, metadata) + + assertTrue(registry.exists(ref.id)) + } + + @Test + fun retrievesRegisteredMetadata() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val now = Clock.System.now() + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = now, + lastUsedAt = now, + sizeBytes = 2000L + ) + + registry.register(ref, metadata) + + val retrieved = registry.getMetadata(ref.id) + + assertNotNull(retrieved) + assertEquals("test-env", retrieved.name) + assertEquals("conda", retrieved.kind) + assertEquals(2000L, retrieved.sizeBytes) + } + + @Test + fun listsAllEnvironments() { + val ref1 = CondaEnvironmentRef(id = "test-001", name = "env1", dependencies = emptyList()) + val ref2 = CondaEnvironmentRef(id = "test-002", name = "env2", dependencies = emptyList()) + + val metadata1 = EnvironmentMetadata( + id = ref1.id, + name = "env1", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L + ) + + val metadata2 = EnvironmentMetadata( + id = ref2.id, + name = "env2", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 2000L + ) + + registry.register(ref1, metadata1) + registry.register(ref2, metadata2) + + val list = registry.list() + + assertEquals(2, list.size) + } + + @Test + fun removesEnvironment() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L + ) + + registry.register(ref, metadata) + assertTrue(registry.exists(ref.id)) + + registry.remove(ref.id) + assertFalse(registry.exists(ref.id)) + } + + @Test + fun persistsToFile() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L + ) + + registry.register(ref, metadata) + + // File should be created + assertTrue(tmpDir.resolve("environments.json").toFile().exists()) + } + + @Test + fun loadsFromFile() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L + ) + + registry.register(ref, metadata) + + // Create new registry from same file + val registry2 = DefaultEnvironmentRegistry(tmpDir.resolve("environments.json")) + + // Should have loaded the environment + assertTrue(registry2.exists(ref.id)) + assertNotNull(registry2.getMetadata(ref.id)) + } + + @Test + fun handlesNonexistentEnvironment() { + assertFalse(registry.exists("nonexistent-id")) + + val metadata = registry.getMetadata("nonexistent-id") + assertEquals(metadata, null) + } + + @Test + fun updateReuseCount() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + var metadata = EnvironmentMetadata( + id = ref.id, + name = "test-env", + kind = "conda", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 1000L, + reuseCount = 0 + ) + + registry.register(ref, metadata) + + // Update with higher reuse count + metadata = metadata.copy(reuseCount = 5) + registry.register(ref, metadata) + + val retrieved = registry.getMetadata(ref.id) + assertEquals(5, retrieved?.reuseCount) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutorTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutorTest.kt index 830e3cf..bcf2e97 100644 --- a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutorTest.kt +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/DefaultPlanExecutorTest.kt @@ -2,6 +2,8 @@ package carp.dsp.core.infrastructure.execution import dk.cachet.carp.analytics.application.execution.ArtefactMetadata import dk.cachet.carp.analytics.application.execution.ArtefactStore +import dk.cachet.carp.analytics.application.execution.DefaultRunPolicy +import dk.cachet.carp.analytics.application.execution.ExecutionIssueKind import dk.cachet.carp.analytics.application.execution.ExecutionStatus import dk.cachet.carp.analytics.application.execution.ProducedOutputRef import dk.cachet.carp.analytics.application.execution.ResourceRef @@ -9,24 +11,29 @@ import dk.cachet.carp.analytics.application.execution.RunPolicy import dk.cachet.carp.analytics.application.execution.workspace.ExecutionWorkspace import dk.cachet.carp.analytics.application.execution.workspace.WorkspaceManager import dk.cachet.carp.analytics.application.plan.CommandSpec +import dk.cachet.carp.analytics.application.plan.EnvironmentRef import dk.cachet.carp.analytics.application.plan.ExecutionPlan import dk.cachet.carp.analytics.application.plan.ExpandedArg import dk.cachet.carp.analytics.application.plan.PlannedStep import dk.cachet.carp.analytics.application.plan.ResolvedBindings +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef import dk.cachet.carp.analytics.application.runtime.CommandResult import dk.cachet.carp.analytics.application.runtime.CommandRunner +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentConfig +import dk.cachet.carp.analytics.infrastructure.execution.EnvironmentOrchestrator +import dk.cachet.carp.analytics.infrastructure.execution.SetupTiming import dk.cachet.carp.common.application.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class DefaultPlanExecutorTest { - // ------------------------------------------------------------------------- - // Test doubles - // ------------------------------------------------------------------------- + // ── Test doubles ────────────────────────────────────────────────────────── - /** Records every call to [create] and returns a stub workspace. */ private class RecordingWorkspaceManager : WorkspaceManager { val createCalls = mutableListOf>() @@ -36,12 +43,13 @@ class DefaultPlanExecutorTest { } override fun prepareStepDirectories(workspace: ExecutionWorkspace, stepId: UUID) = Unit + + // Returns a path string; CommandStepRunner uses it as the working dir. + // With a StubCommandRunner no real process is spawned so the path need not exist. + override fun resolveStepWorkingDir(workspace: ExecutionWorkspace, stepId: UUID): String = + "${workspace.executionRoot}/$stepId" } - /** - * Stub runner that returns a fixed [CommandResult] without spawning any OS process. - * Defaults to exit code 0 (success). - */ private class StubCommandRunner( private val exitCode: Int = 0, private val stdout: String = "", @@ -49,68 +57,111 @@ class DefaultPlanExecutorTest { private val timedOut: Boolean = false ) : CommandRunner { override fun run(command: CommandSpec, policy: RunPolicy) = CommandResult( - exitCode = exitCode, - stdout = stdout, - stderr = stderr, - durationMs = 1L, - timedOut = timedOut + exitCode = exitCode, stdout = stdout, stderr = stderr, + durationMs = 1L, timedOut = timedOut ) } - /** Stub ArtefactStore that does nothing and returns stub values. */ + /** Records the exact CommandSpec that was last executed. */ + private class CapturingCommandRunner(private val exitCode: Int = 0) : CommandRunner { + val capturedCommands = mutableListOf() + override fun run(command: CommandSpec, policy: RunPolicy): CommandResult { + capturedCommands += command + return CommandResult(exitCode = exitCode, stdout = "", stderr = "", durationMs = 1L, timedOut = false) + } + } + private class StubArtefactStore : ArtefactStore { override fun recordArtefact( stepId: UUID, outputId: UUID, location: ResourceRef, metadata: ArtefactMetadata - ): ProducedOutputRef = ProducedOutputRef( - outputId = outputId, - location = location, - sizeBytes = metadata.sizeBytes, - sha256 = metadata.sha256, - contentType = metadata.contentType + ) = ProducedOutputRef( + outputId = outputId, location = location, + sizeBytes = metadata.sizeBytes, sha256 = metadata.sha256, contentType = metadata.contentType ) - override fun getArtefact(outputId: UUID): ProducedOutputRef? = null override fun getArtefactsByStep(stepId: UUID): List = emptyList() override fun getAllArtefacts(): List = emptyList() override fun resolvePath(outputId: UUID): String? = null } - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- + /** + * Configurable stub for [EnvironmentOrchestrator]. + * + * Defaults to returning true for setup/teardown and wrapping commands with "stub-env ". + */ + private class StubEnvironmentOrchestrator( + private val setupResult: Boolean = true, + private val teardownResult: Boolean = true, + private val commandPrefix: String = "stub-env" + ) : EnvironmentOrchestrator { + val setupCalls = mutableListOf() // env IDs setup was called for + val teardownCalls = mutableListOf() + + override fun setup(environmentRef: EnvironmentRef): Boolean { + setupCalls += environmentRef.id + return setupResult + } + + override fun teardown(environmentRef: EnvironmentRef): Boolean { + teardownCalls += environmentRef.id + return teardownResult + } - private fun plannedStep(name: String = "step") = PlannedStep( - stepId = UUID.randomUUID(), + override fun generateExecutionCommand(environmentRef: EnvironmentRef, command: String): String = + "$commandPrefix $command" + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun plannedStep( + name: String = "step", + stepId: UUID = UUID.randomUUID(), + environmentRef: UUID? = UUID.randomUUID() + ) = PlannedStep( + stepId = stepId, name = name, process = CommandSpec("echo", listOf(ExpandedArg.Literal(name))), bindings = ResolvedBindings(), - environmentRef = UUID.randomUUID() + environmentRef = environmentRef ) - private fun plan(vararg steps: PlannedStep) = ExecutionPlan( - workflowId = "wf-1", + private fun plan( + vararg steps: PlannedStep, + workflowId: String = "wf-1", + requiredEnvironmentRefs: Map = emptyMap() + ) = ExecutionPlan( + workflowId = workflowId, planId = UUID.randomUUID().toString(), - steps = steps.toList() + steps = steps.toList(), + requiredEnvironmentRefs = requiredEnvironmentRefs ) private fun executor( manager: WorkspaceManager = RecordingWorkspaceManager(), runner: CommandRunner = StubCommandRunner(), artefactStore: ArtefactStore = StubArtefactStore(), - strategy: StepOrderStrategy = SequentialPlanOrder + strategy: StepOrderStrategy = SequentialPlanOrder, + orchestrator: EnvironmentOrchestrator? = null, + environmentConfig: EnvironmentConfig = EnvironmentConfig() ) = DefaultPlanExecutor( workspaceManager = manager, artefactStore = artefactStore, - commandRunner = runner, - stepOrderStrategy = strategy + options = DefaultPlanExecutor.Options( + commandRunner = runner, + stepOrderStrategy = strategy, + orchestrator = orchestrator ?: DefaultEnvironmentOrchestrator( + DefaultEnvironmentRegistry( + java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"), "carp-dsp-test-registry.json") + ) + ), + environmentConfig = environmentConfig + ) ) - // ------------------------------------------------------------------------- - // Tests - // ------------------------------------------------------------------------- + // ── Original tests (unchanged) ──────────────────────────────────────────── @Test fun `execute calls workspaceManager_create exactly once with plan and runId`() { @@ -120,7 +171,7 @@ class DefaultPlanExecutorTest { executor(manager = manager).execute(p, runId) - assertEquals(1, manager.createCalls.size, "workspaceManager.create must be called exactly once") + assertEquals(1, manager.createCalls.size) val (calledPlan, calledRunId) = manager.createCalls.single() assertSame(p, calledPlan) assertEquals(runId, calledRunId) @@ -134,17 +185,12 @@ class DefaultPlanExecutorTest { } @Test - fun `execute preserves topo order - results appear in plan declaration order`() { + fun `execute preserves topo order`() { val stepA = plannedStep("alpha") val stepB = plannedStep("beta") val stepC = plannedStep("gamma") - val report = executor().execute(plan(stepA, stepB, stepC), UUID.randomUUID()) - - assertEquals( - listOf(stepA.stepId, stepB.stepId, stepC.stepId), - report.stepResults.map { it.stepId } - ) + assertEquals(listOf(stepA.stepId, stepB.stepId, stepC.stepId), report.stepResults.map { it.stepId }) } @Test @@ -163,7 +209,6 @@ class DefaultPlanExecutorTest { @Test fun `execute skips remaining steps after failure when stopOnFailure is true`() { - // First step fails, second and third should be SKIPPED var callCount = 0 val countingRunner = object : CommandRunner { override fun run(command: CommandSpec, policy: RunPolicy): CommandResult { @@ -177,53 +222,559 @@ class DefaultPlanExecutorTest { val stepA = plannedStep("a") val stepB = plannedStep("b") val stepC = plannedStep("c") - - val report = executor(runner = countingRunner) - .execute(plan(stepA, stepB, stepC), UUID.randomUUID()) - + val report = executor(runner = countingRunner).execute(plan(stepA, stepB, stepC), UUID.randomUUID()) assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) assertEquals(ExecutionStatus.SKIPPED, report.stepResults[1].status) assertEquals(ExecutionStatus.SKIPPED, report.stepResults[2].status) - assertEquals(1, callCount, "Runner must not be invoked for skipped steps") + assertEquals(1, callCount) } @Test fun `execute report carries correct planId and runId`() { val p = plan(plannedStep("z")) val runId = UUID.randomUUID() - val report = executor().execute(p, runId) - assertEquals(runId, report.runId) assertEquals(UUID.parse(p.planId), report.planId) } @Test - fun `default strategy is SequentialPlanOrder - follows planner order`() { + fun `default strategy is SequentialPlanOrder`() { val stepA = plannedStep("first") val stepB = plannedStep("second") val stepC = plannedStep("third") - val report = executor().execute(plan(stepA, stepB, stepC), UUID.randomUUID()) - - assertEquals( - listOf(stepA.stepId, stepB.stepId, stepC.stepId), - report.stepResults.map { it.stepId } - ) + assertEquals(listOf(stepA.stepId, stepB.stepId, stepC.stepId), report.stepResults.map { it.stepId }) } @Test - fun `custom StepOrderStrategy is honoured - reversed order example`() { + fun `custom StepOrderStrategy is honoured`() { val stepA = plannedStep("alpha") val stepB = plannedStep("beta") val stepC = plannedStep("gamma") - val report = executor(strategy = StepOrderStrategy { p -> p.steps.map { it.stepId }.reversed() }) .execute(plan(stepA, stepB, stepC), UUID.randomUUID()) + assertEquals(listOf(stepC.stepId, stepB.stepId, stepA.stepId), report.stepResults.map { it.stepId }) + } + + // ── handleUnknownStep ───────────────────────────────────────────────────── + + @Test + fun `execute records FAILED result and ORCHESTRATOR_ERROR when order strategy returns unknown step id`() { + val unknownId = UUID.randomUUID() + val report = executor(strategy = StepOrderStrategy { _ -> listOf(unknownId) }) + .execute(plan(), UUID.randomUUID()) // plan has no steps + + assertEquals(1, report.stepResults.size) + assertEquals(unknownId, report.stepResults[0].stepId) + assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) + assertTrue( + report.issues.any { + it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR && it.stepId == unknownId + } + ) + assertEquals(ExecutionStatus.FAILED, report.status) + } + + @Test + fun `execute halts after unknown step when stopOnFailure is true`() { + val knownStep = plannedStep("known") + val unknownId = UUID.randomUUID() + // Order: unknown first, then known — known should be skipped + val report = executor( + strategy = StepOrderStrategy { p -> + listOf(unknownId) + p.steps.map { it.stepId } + } + ).execute(plan(knownStep), UUID.randomUUID()) + + assertEquals(2, report.stepResults.size) + assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) + assertEquals(ExecutionStatus.SKIPPED, report.stepResults[1].status) + } + + @Test + fun `execute does not halt after unknown step when stopOnFailure is false`() { + val knownStep = plannedStep("known") + val unknownId = UUID.randomUUID() + val report = executor( + runner = StubCommandRunner(exitCode = 0), + strategy = StepOrderStrategy { p -> listOf(unknownId) + p.steps.map { it.stepId } } + ).execute(plan(knownStep), UUID.randomUUID(), DefaultRunPolicy(stopOnFailure = false)) + + assertEquals(2, report.stepResults.size) + assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) // unknown + assertEquals(ExecutionStatus.SUCCEEDED, report.stepResults[1].status) // known + } + + // ── deriveOverallStatus ─────────────────────────────────────────────────── + + @Test + fun `deriveOverallStatus returns FAILED when plan has no steps`() { + // empty results: all{} returns true for empty list → SUCCEEDED + val report = executor().execute(plan(), UUID.randomUUID()) + // No steps → no results → all{SUCCEEDED} is vacuously true + assertEquals(ExecutionStatus.SUCCEEDED, report.status) + } + + @Test + fun `deriveOverallStatus returns FAILED when results contain only SKIPPED steps`() { + // To get SKIPPED-but-no-FAILED: EAGER env setup failure + stopOnFailure=true + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val failingOrchestrator = StubEnvironmentOrchestrator(setupResult = false) + + val report = executor( + orchestrator = failingOrchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + ).execute( + plan( + plannedStep("a"), plannedStep("b"), + requiredEnvironmentRefs = mapOf(envId to envRef) + ), + UUID.randomUUID() + ) + + // Setup failed → halted before any step ran → all steps SKIPPED, no FAILED step result + assertTrue(report.stepResults.all { it.status == ExecutionStatus.SKIPPED }) + assertEquals(ExecutionStatus.FAILED, report.status) + } + + // ── startedAt / finishedAt null paths ───────────────────────────────────── + + @Test + fun `buildExecutionReport startedAt and finishedAt are null when all steps are skipped`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val failingOrchestrator = StubEnvironmentOrchestrator(setupResult = false) + + val report = executor( + orchestrator = failingOrchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + ).execute( + plan( + plannedStep("a"), + requiredEnvironmentRefs = mapOf(envId to envRef) + ), + UUID.randomUUID() + ) + + // Skipped steps have startedAt = null and finishedAt = null, + // so firstOrNull{startedAt != null} returns null → report.startedAt is null. + assertNull(report.startedAt) + assertNull(report.finishedAt) + } + + @Test + fun `buildExecutionReport startedAt and finishedAt are non-null when steps ran`() { + val report = executor(runner = StubCommandRunner(exitCode = 0)) + .execute(plan(plannedStep("a")), UUID.randomUUID()) + // CommandStepRunner records timestamps for steps that actually ran. + assertNotNull(report.startedAt) + assertNotNull(report.finishedAt) + } + + // ── EnvironmentExecutionCoordinator — EAGER setup ───────────────────────── + + @Test + fun `EAGER setup calls orchestrator setup before any step runs`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val orchestrator = StubEnvironmentOrchestrator(setupResult = true) + + executor( + orchestrator = orchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + assertTrue(orchestrator.setupCalls.contains(envId.toString())) + } + + @Test + fun `EAGER setup failure with stopOnFailure halts all steps`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + val report = executor( + orchestrator = StubEnvironmentOrchestrator(setupResult = false), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + ).execute( + plan( + plannedStep("a"), plannedStep("b"), + requiredEnvironmentRefs = mapOf(envId to envRef) + ), + UUID.randomUUID() + ) + + assertTrue(report.stepResults.all { it.status == ExecutionStatus.SKIPPED }) + assertTrue(report.issues.any { it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR }) + } + + @Test + fun `EAGER setup does not halt when stopOnFailure is false`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + val report = executor( + runner = StubCommandRunner(exitCode = 0), + orchestrator = StubEnvironmentOrchestrator(setupResult = false), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.EAGER) + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID(), + DefaultRunPolicy(stopOnFailure = false) + ) + + // Steps still run even though setup failed, because stopOnFailure = false + assertTrue(report.stepResults.isNotEmpty()) + assertTrue(report.issues.any { it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR }) + } + + @Test + fun `EAGER setup does not call orchestrator when setupTiming is not EAGER`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val orchestrator = StubEnvironmentOrchestrator() + + executor( + orchestrator = orchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) // not EAGER + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + // With default config (not EAGER) setupEagerEnvironments returns false immediately + assertTrue(orchestrator.setupCalls.isEmpty()) + } + + // ── EnvironmentExecutionCoordinator — teardownAll ───────────────────────── + + @Test + fun `teardownAll is called for every required environment ref`() { + val envId1 = UUID.randomUUID() + val envId2 = UUID.randomUUID() + val refs = mapOf( + envId1 to SystemEnvironmentRef(id = envId1.toString(), dependencies = emptyList()), + envId2 to SystemEnvironmentRef(id = envId2.toString(), dependencies = emptyList()) + ) + val orchestrator = StubEnvironmentOrchestrator() + + executor(orchestrator = orchestrator) + .execute(plan(plannedStep("a"), requiredEnvironmentRefs = refs), UUID.randomUUID()) + + assertTrue(orchestrator.teardownCalls.contains(envId1.toString())) + assertTrue(orchestrator.teardownCalls.contains(envId2.toString())) + } + + @Test + fun `teardownAll records ORCHESTRATOR_ERROR issue when teardown fails`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + val report = executor( + orchestrator = StubEnvironmentOrchestrator(teardownResult = false) + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + assertTrue( + report.issues.any { + it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR && + it.message.contains("teardown") + } + ) + } + + @Test + fun `teardownAll is called even when a step fails (finally block)`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val orchestrator = StubEnvironmentOrchestrator() + + executor( + runner = StubCommandRunner(exitCode = 1), + orchestrator = orchestrator + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + // Even on failure the finally block runs teardown + assertTrue(orchestrator.teardownCalls.contains(envId.toString())) + } + + // ── EnvironmentExecutionCoordinator — prepareStep ───────────────────────── + + @Test + fun `prepareStep returns step unchanged when step has no environmentRef`() { + // Step with null environmentRef skips all env coordination + val stepWithNullEnv = plannedStep("no-env", environmentRef = null) + val capturing = CapturingCommandRunner(exitCode = 0) + + val report = executor(runner = capturing) + .execute(plan(stepWithNullEnv), UUID.randomUUID()) + + assertEquals(ExecutionStatus.SUCCEEDED, report.stepResults[0].status) + // Command should be the original "echo no-env", not wrapped + assertTrue(capturing.capturedCommands.any { it.executable == "echo" }) + } + + @Test + fun `prepareStep returns step unchanged when requiredEnvironmentRefs is empty`() { + // Even if environmentRef is set, an empty requiredEnvironmentRefs map causes early return + val capturing = CapturingCommandRunner(exitCode = 0) + val step = plannedStep("plain-step") + + executor(runner = capturing).execute( + plan(step, requiredEnvironmentRefs = emptyMap()), + UUID.randomUUID() + ) + + assertTrue(capturing.capturedCommands.any { it.executable == "echo" }) + } + + @Test + fun `prepareStep records ORCHESTRATOR_ERROR when step env ref not in requiredEnvironmentRefs`() { + val knownEnvId = UUID.randomUUID() + val unknownEnvId = UUID.randomUUID() // step references this, but plan doesn't declare it + val step = plannedStep("orphan", environmentRef = unknownEnvId) + + val report = executor( + orchestrator = StubEnvironmentOrchestrator() + ).execute( + plan( + step, + requiredEnvironmentRefs = mapOf( + knownEnvId to SystemEnvironmentRef(id = knownEnvId.toString(), dependencies = emptyList()) + ) + ), + UUID.randomUUID() + ) + + assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) + assertTrue( + report.issues.any { + it.stepId == step.stepId && it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR + } + ) + } + + // ── EnvironmentExecutionCoordinator — LAZY setup ────────────────────────── + + @Test + fun `LAZY setup calls orchestrator setup per-step and wraps command`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val orchestrator = StubEnvironmentOrchestrator(commandPrefix = "lazy-wrap") + val capturing = CapturingCommandRunner(exitCode = 0) + + executor( + runner = capturing, + orchestrator = orchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan(plannedStep("a", environmentRef = envId), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + assertTrue(orchestrator.setupCalls.contains(envId.toString())) + // The command should be wrapped by the orchestrator — sh/cmd wrapping the lazy-wrap prefix + val wrappedCmd = capturing.capturedCommands.firstOrNull() + assertNotNull(wrappedCmd) + assertTrue( + wrappedCmd.executable == "sh" || wrappedCmd.executable == "cmd", + "Expected shell wrapper, got: ${wrappedCmd.executable}" + ) + } + + @Test + fun `LAZY setup, same environment not set up twice across steps`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val orchestrator = StubEnvironmentOrchestrator() + + executor( + orchestrator = orchestrator, + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan( + plannedStep("a", environmentRef = envId), + plannedStep("b", environmentRef = envId), // same env + requiredEnvironmentRefs = mapOf(envId to envRef) + ), + UUID.randomUUID() + ) assertEquals( - listOf(stepC.stepId, stepB.stepId, stepA.stepId), - report.stepResults.map { it.stepId } + 1, orchestrator.setupCalls.count { it == envId.toString() }, + "Same environment must not be set up more than once" + ) + } + + @Test + fun `LAZY setup failure records issue and marks step as FAILED`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + val report = executor( + orchestrator = StubEnvironmentOrchestrator(setupResult = false), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + assertEquals(ExecutionStatus.FAILED, report.stepResults[0].status) + assertTrue(report.issues.any { it.kind == ExecutionIssueKind.ORCHESTRATOR_ERROR }) + } + + // ── EnvironmentExecutionCoordinator — environmentLogs ──────────────────── + + @Test + fun `environmentLogs returns empty when orchestrator is not a DefaultEnvironmentOrchestrator`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + // StubEnvironmentOrchestrator is NOT a DefaultEnvironmentOrchestrator, + // so the `as? DefaultEnvironmentOrchestrator` cast returns null + // and the fallback `?: EnvironmentExecutionLogs()` is taken. + val report = executor( + orchestrator = StubEnvironmentOrchestrator() + ).execute( + plan(plannedStep("a"), requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + // The report should still be produced successfully; environment logs are empty. + assertNotNull(report.environmentLogs) + } + + // ── EnvironmentExecutionCoordinator — command building ──────────────────── + + @Test + fun `buildBaseCommand produces executable-only string when step has no args`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val noArgStep = PlannedStep( + stepId = UUID.randomUUID(), + name = "no-args", + process = CommandSpec("mybin", emptyList()), // no args + bindings = ResolvedBindings(), + environmentRef = envId + ) + val capturing = CapturingCommandRunner(exitCode = 0) + + executor( + runner = capturing, + orchestrator = StubEnvironmentOrchestrator(commandPrefix = "wrap"), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan(noArgStep, requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + // The wrapped command arg should contain "mybin" without trailing spaces + val shellArg = capturing.capturedCommands.firstOrNull() + ?.args?.filterIsInstance() + ?.lastOrNull()?.value ?: "" + assertTrue(shellArg.contains("mybin"), "Expected 'mybin' in wrapped command: $shellArg") + assertTrue( + !shellArg.endsWith(" mybin") && shellArg.contains("wrap mybin"), + "Expected 'wrap mybin' (no trailing space before executable): $shellArg" + ) + } + + @Test + fun `argToLiteral handles DataReference and PathSubstitution args`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + val dataRefId = UUID.randomUUID() + + val stepWithVariousArgs = PlannedStep( + stepId = UUID.randomUUID(), + name = "arg-types", + process = CommandSpec( + "mybin", + listOf( + ExpandedArg.Literal("literal-value"), + ExpandedArg.DataReference(dataRefId), + ExpandedArg.PathSubstitution(dataRefId, "--out=$()") + ) + ), + bindings = ResolvedBindings(), + environmentRef = envId + ) + val capturing = CapturingCommandRunner(exitCode = 0) + + executor( + runner = capturing, + orchestrator = StubEnvironmentOrchestrator(commandPrefix = "wrap"), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan(stepWithVariousArgs, requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + val shellArg = capturing.capturedCommands.firstOrNull() + ?.args?.filterIsInstance() + ?.lastOrNull()?.value ?: "" + + // DataReference → dataRefId.toString() should appear in the command + assertTrue( + shellArg.contains(dataRefId.toString()), + "DataReference UUID should be in command: $shellArg" + ) + // PathSubstitution → "--out=" should appear + assertTrue( + shellArg.contains("--out="), + "PathSubstitution template should be resolved: $shellArg" + ) + } + + @Test + fun `shellQuote handles args that need quoting on the current platform`() { + val envId = UUID.randomUUID() + val envRef = SystemEnvironmentRef(id = envId.toString(), dependencies = emptyList()) + + val stepWithSpacedArg = PlannedStep( + stepId = UUID.randomUUID(), + name = "spaced", + process = CommandSpec( + "mybin", + listOf( + ExpandedArg.Literal("arg with spaces"), + ExpandedArg.Literal("plain"), + ExpandedArg.Literal("") // empty arg + ) + ), + bindings = ResolvedBindings(), + environmentRef = envId + ) + val capturing = CapturingCommandRunner(exitCode = 0) + + executor( + runner = capturing, + orchestrator = StubEnvironmentOrchestrator(commandPrefix = "wrap"), + environmentConfig = EnvironmentConfig(setupTiming = SetupTiming.LAZY) + ).execute( + plan(stepWithSpacedArg, requiredEnvironmentRefs = mapOf(envId to envRef)), + UUID.randomUUID() + ) + + val shellArg = capturing.capturedCommands.firstOrNull() + ?.args?.filterIsInstance() + ?.lastOrNull()?.value ?: "" + + // The spaced arg should be quoted in the shell command + assertTrue( + shellArg.contains("arg with spaces") || shellArg.contains("'arg with spaces'"), + "Spaced arg should be quoted or present: $shellArg" ) + // "plain" needs no quoting — appears as-is + assertTrue(shellArg.contains("plain"), "Plain arg should appear: $shellArg") } } diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistryTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistryTest.kt new file mode 100644 index 0000000..10e134b --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/EnvironmentHandlerRegistryTest.kt @@ -0,0 +1,111 @@ +package carp.dsp.core.infrastructure.execution + +import carp.dsp.core.infrastructure.execution.handlers.CondaEnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.PixiEnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.REnvironmentHandler +import carp.dsp.core.infrastructure.execution.handlers.SystemEnvironmentHandler +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import dk.cachet.carp.analytics.application.plan.REnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import kotlin.test.Test +import kotlin.test.assertIs + +class EnvironmentHandlerRegistryTest { + + @Test + fun `selects Conda handler for CondaEnvironmentRef`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val handler = EnvironmentHandlerRegistry.getHandler(ref) + + assertIs(handler) + } + + @Test + fun `selects Pixi handler for PixiEnvironmentRef`() { + val ref = PixiEnvironmentRef( + id = "test-001", + dependencies = emptyList() + ) + + val handler = EnvironmentHandlerRegistry.getHandler(ref) + + assertIs(handler) + } + + @Test + fun `selects System handler for SystemEnvironmentRef`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val handler = EnvironmentHandlerRegistry.getHandler(ref) + + assertIs(handler) + } + + @Test + fun `throws exception for unknown environment type`() { + // Create a mock unknown environment type + // This would require a test double or similar + + // For now, we test that the registry can be queried + val conda = CondaEnvironmentRef(id = "test", name = "env", dependencies = emptyList()) + val handler = EnvironmentHandlerRegistry.getHandler(conda) + + // Verify it's not null + kotlin.test.assertNotNull(handler) + } + + @Test + fun `registry contains all required handlers`() { + val conda = CondaEnvironmentRef(id = "test", name = "env", dependencies = emptyList()) + val pixi = PixiEnvironmentRef(id = "test", dependencies = emptyList()) + val system = SystemEnvironmentRef(id = "test") + + val condaHandler = EnvironmentHandlerRegistry.getHandler(conda) + val pixiHandler = EnvironmentHandlerRegistry.getHandler(pixi) + val systemHandler = EnvironmentHandlerRegistry.getHandler(system) + + kotlin.test.assertNotNull(condaHandler) + kotlin.test.assertNotNull(pixiHandler) + kotlin.test.assertNotNull(systemHandler) + } + + @Test + fun `selects R handler for REnvironmentRef`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val handler = EnvironmentHandlerRegistry.getHandler(ref) + + assertIs(handler) + } + + @Test + fun `registry contains all four handlers`() { + val conda = CondaEnvironmentRef(id = "test", name = "env", dependencies = emptyList()) + val pixi = PixiEnvironmentRef(id = "test", dependencies = emptyList()) + val system = SystemEnvironmentRef(id = "test") + val r = REnvironmentRef(id = "test", rVersion = "4.3.0", rPackages = listOf("pkg")) + + val condaHandler = EnvironmentHandlerRegistry.getHandler(conda) + val pixiHandler = EnvironmentHandlerRegistry.getHandler(pixi) + val systemHandler = EnvironmentHandlerRegistry.getHandler(system) + val rHandler = EnvironmentHandlerRegistry.getHandler(r) + + kotlin.test.assertNotNull(condaHandler) + kotlin.test.assertNotNull(pixiHandler) + kotlin.test.assertNotNull(systemHandler) + kotlin.test.assertNotNull(rHandler) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManagerTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManagerTest.kt index 2048d42..0bf7ec0 100644 --- a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManagerTest.kt +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/PlanBasedWorkspaceManagerTest.kt @@ -7,6 +7,7 @@ import dk.cachet.carp.analytics.application.plan.PlannedStep import dk.cachet.carp.analytics.application.plan.ResolvedBindings import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef import dk.cachet.carp.common.application.UUID +import kotlinx.serialization.json.Json import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -393,6 +394,128 @@ class PlanBasedWorkspaceManagerTest { assertNotEquals(executionRoot1, executionRoot2, "Workspaces should be in different directories") } + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = false + } + + // ── SimplifiedStepHashContent ───────────────────────────────────────────── + + @Test + fun `SimplifiedStepHashContent round-trips through JSON`() { + val original = SimplifiedStepHashContent( + stepId = "step-uuid-001", + name = "Preprocess EEG", + environmentDefinitionId = "env-uuid-001", + inputBindings = listOf("input-uuid-001", "input-uuid-002"), + outputBindings = listOf("output-uuid-001") + ) + + val json = json.encodeToString(SimplifiedStepHashContent.serializer(), original) + val decoded = this.json.decodeFromString(SimplifiedStepHashContent.serializer(), json) + + assertEquals(original, decoded) + } + + @Test + fun `SimplifiedStepHashContent round-trips with empty binding lists`() { + val original = SimplifiedStepHashContent( + stepId = "step-uuid-002", + name = "Validate Input", + environmentDefinitionId = "env-uuid-002", + inputBindings = emptyList(), + outputBindings = emptyList() + ) + + val serialized = json.encodeToString(SimplifiedStepHashContent.serializer(), original) + val decoded = json.decodeFromString(SimplifiedStepHashContent.serializer(), serialized) + + assertEquals(original, decoded) + } + + // ── SimplifiedPlanHashContent ───────────────────────────────────────────── + + @Test + fun `SimplifiedPlanHashContent round-trips through JSON`() { + val original = SimplifiedPlanHashContent( + workflowId = "workflow-uuid-001", + steps = listOf( + SimplifiedStepHashContent( + stepId = "step-uuid-001", + name = "Validate Input", + environmentDefinitionId = "env-uuid-001", + inputBindings = emptyList(), + outputBindings = listOf("output-uuid-001") + ), + SimplifiedStepHashContent( + stepId = "step-uuid-002", + name = "Preprocess EEG", + environmentDefinitionId = "env-uuid-001", + inputBindings = listOf("input-uuid-001"), + outputBindings = listOf("output-uuid-002") + ) + ), + requiredEnvironmentHandles = listOf("env-uuid-001") + ) + + val serialized = json.encodeToString(SimplifiedPlanHashContent.serializer(), original) + val decoded = json.decodeFromString(SimplifiedPlanHashContent.serializer(), serialized) + + assertEquals(original, decoded) + } + + @Test + fun `SimplifiedPlanHashContent round-trips with no steps`() { + val original = SimplifiedPlanHashContent( + workflowId = "workflow-uuid-empty", + steps = emptyList(), + requiredEnvironmentHandles = emptyList() + ) + + val serialized = json.encodeToString(SimplifiedPlanHashContent.serializer(), original) + val decoded = json.decodeFromString(SimplifiedPlanHashContent.serializer(), serialized) + + assertEquals(original, decoded) + } + + // ── JSON field name stability ───────────────────────────────────────────── + // Guards against accidental field renames breaking the on-disk hash format. + + @Test + fun `SimplifiedStepHashContent serializes to expected field names`() { + val step = SimplifiedStepHashContent( + stepId = "s1", + name = "my-step", + environmentDefinitionId = "e1", + inputBindings = listOf("i1"), + outputBindings = listOf("o1") + ) + + val serialized = json.encodeToString(SimplifiedStepHashContent.serializer(), step) + + assert(serialized.contains("\"stepId\"")) { "stepId field missing" } + assert(serialized.contains("\"name\"")) { "name field missing" } + assert(serialized.contains("\"environmentDefinitionId\"")) { "environmentDefinitionId field missing" } + assert(serialized.contains("\"inputBindings\"")) { "inputBindings field missing" } + assert(serialized.contains("\"outputBindings\"")) { "outputBindings field missing" } + } + + @Test + fun `SimplifiedPlanHashContent serializes to expected field names`() { + val content = SimplifiedPlanHashContent( + workflowId = "w1", + steps = emptyList(), + requiredEnvironmentHandles = listOf("e1") + ) + + val serialized = json.encodeToString(SimplifiedPlanHashContent.serializer(), content) + + assert(serialized.contains("\"workflowId\"")) { "workflowId field missing" } + assert(serialized.contains("\"steps\"")) { "steps field missing" } + assert(serialized.contains("\"requiredEnvironmentHandles\"")) { "requiredEnvironmentHandles field missing" } + } + // Helper methods private fun createTestExecutionPlan(workflowId: String, planId: String): ExecutionPlan { diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtilsTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtilsTest.kt new file mode 100644 index 0000000..a86acbe --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandExecutionUtilsTest.kt @@ -0,0 +1,102 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class CommandExecutionUtilsTest { + + @Test + fun `execute command returns output on successful command`() { + val tempDir = createTempDirectory("command-utils-success-") + + try { + val script = tempDir.resolve("Hello.java") + script.writeText( + """ + class Hello { + public static void main(String[] args) { + System.out.print("COMMAND_UTILS_OK"); + } + } + """.trimIndent() + ) + + val result = executeCommand(listOf(resolveJavaBinary().toString(), script.toString())) + + assertEquals(0, result.exitCode) + assertTrue(result.stdout.contains("COMMAND_UTILS_OK")) + } finally { + tempDir.toFile().deleteRecursively() + } + } + + @Test + fun `execute command returns non-zero on failing command`() { + val result = executeCommand(listOf(resolveJavaBinary().toString(), "--this-option-does-not-exist")) + + assertNotEquals(0, result.exitCode) + assertTrue(result.stderr.isNotBlank()) + } + + @Test + fun `execute command honors working dir`() { + val tempDir = createTempDirectory("command-utils-cwd-") + + try { + val scriptFileName = "PrintCwd.java" + tempDir.resolve(scriptFileName).writeText( + """ + class PrintCwd { + public static void main(String[] args) { + System.out.print(System.getProperty("user.dir")); + } + } + """.trimIndent() + ) + + val result = executeCommand( + listOf(resolveJavaBinary().toString(), scriptFileName), + workingDir = tempDir + ) + + assertEquals(0, result.exitCode) + assertTrue(normalizePath(result.stdout.trim()).contains(normalizePath(tempDir))) + } finally { + tempDir.toFile().deleteRecursively() + } + } + + @Test + fun `catch IOException when executable does not exist`() { + val result = executeCommand(listOf("this-binary-does-not-exist-xyzzy")) + assertEquals(1, result.exitCode) + assertTrue(result.stderr.isNotBlank()) + } + + @Test + fun `catch IllegalArgumentException when command list is empty`() { + val result = executeCommand(listOf("")) + assertEquals(1, result.exitCode) + assertTrue(result.stderr.isNotBlank()) + } + + private fun resolveJavaBinary(): Path { + val javaHome = Path.of(System.getProperty("java.home")) + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val javaExecutable = if (isWindows) "java.exe" else "java" + return javaHome.resolve("bin").resolve(javaExecutable) + } + + private fun normalizePath(path: Path): String = + normalizePath(path.toAbsolutePath().toString()) + + private fun normalizePath(path: String): String = + path.replace('\\', '/').trimEnd('/') +} + + diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandTemplateTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandTemplateTest.kt new file mode 100644 index 0000000..e0db7ba --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CommandTemplateTest.kt @@ -0,0 +1,149 @@ +package carp.dsp.core.infrastructure.execution.handlers + + +import carp.dsp.core.infrastructure.execution.CommandTemplate +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CommandTemplateTest { + + @Test + fun `expands conda template`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "my-env", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = listOf("script.py", "arg1", "arg2") + ) + + val command = template.toCommandString() + + assertEquals("conda run -n my-env python script.py arg1 arg2", command) + } + + @Test + fun `expands pixi template`() { + val ref = PixiEnvironmentRef( + id = "test-001", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = listOf("script.py", "input.csv", "output.json") + ) + + val command = template.toCommandString() + + assertEquals("pixi run python script.py input.csv output.json", command) + } + + @Test + fun `expands system template`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = listOf("script.py") + ) + + val command = template.toCommandString() + + assertEquals("python script.py", command) + } + + @Test + fun `handles python with multiple args`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "analysis-env", + dependencies = listOf("numpy", "pandas") + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = listOf("-m", "mymodule", "arg1", "arg2", "arg3") + ) + + val command = template.toCommandString() + + assertTrue(command.contains("conda run -n analysis-env")) + assertTrue(command.contains("python -m mymodule arg1 arg2 arg3")) + } + + @Test + fun `handles missing args`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = emptyList() + ) + + val command = template.toCommandString() + + assertEquals("conda run -n test-env python", command) + } + + @Test + fun `converts to bash command`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "env", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "echo", + args = listOf("hello", "world") + ) + + val bashCmd = template.toBashCommand() + + assertEquals(3, bashCmd.size) + assertEquals("bash", bashCmd[0]) + assertEquals("-c", bashCmd[1]) + assertTrue(bashCmd[2].contains("echo hello world")) + } + + @Test + fun `preserves special characters in args`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "env", + dependencies = emptyList() + ) + + val template = CommandTemplate( + environmentRef = ref, + executable = "python", + args = listOf("script.py", "path/to/file.csv", "--option=value") + ) + + val command = template.toCommandString() + + assertTrue(command.contains("path/to/file.csv")) + assertTrue(command.contains("--option=value")) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandlerTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandlerTest.kt new file mode 100644 index 0000000..33b84be --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/CondaEnvironmentHandlerTest.kt @@ -0,0 +1,354 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import carp.dsp.core.testing.MockCommandRunner +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import java.io.IOException +import kotlin.test.* + +class CondaEnvironmentHandlerTest { + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun ref( + name: String = "test-env", + pythonVersion: String = "3.11", + dependencies: List = emptyList(), + channels: List = emptyList(), + ) = CondaEnvironmentRef( + id = "test-001", name = name, pythonVersion = pythonVersion, + dependencies = dependencies, channels = channels + ) + + private fun MockCommandRunner.stubFullSuccess(name: String = "test-env") { + on("conda --version") + on("conda create") + on("conda env list", stdout = "$name /opt/conda/envs/$name") + on("conda run -n $name python --version", stdout = "Python 3.11.0") + } + + // ── canHandle ───────────────────────────────────────────────────────────── + + @Test fun `can handle CondaEnvironmentRef`() = + assertTrue(CondaEnvironmentHandler().canHandle(ref())) + + @Test fun `cannot handle PixiEnvironmentRef`() = + assertFalse( + CondaEnvironmentHandler().canHandle( + PixiEnvironmentRef(id = "p", dependencies = emptyList()) + ) + ) + + // ── generateExecutionCommand ────────────────────────────────────────────── + // Pure string logic — no process execution, no mock needed. + + @Test fun `generates correct execution command`() = + assertEquals( + "conda run -n my-env python script.py arg1", + CondaEnvironmentHandler().generateExecutionCommand(ref(name = "my-env"), "python script.py arg1") + ) + + @Test fun `generates command with hyphenated env name`() { + val cmd = CondaEnvironmentHandler().generateExecutionCommand( + ref(name = "eeg-analysis-v2"), "python analyze.py" + ) + assertTrue(cmd.startsWith("conda run -n eeg-analysis-v2")) + } + + @Test fun `generates module invocation command`() = + assertEquals( + "conda run -n ml-env python -m pkg.mod --flag=v", + CondaEnvironmentHandler().generateExecutionCommand(ref(name = "ml-env"), "python -m pkg.mod --flag=v") + ) + + // ── teardown ────────────────────────────────────────────────────────────── + + @Test fun `teardown returns true when conda exits 0`() { + val mock = MockCommandRunner().apply { on("conda env remove") } + assertTrue(CondaEnvironmentHandler(mock).teardown(ref(name = "my-env"))) + } + + @Test fun `teardown returns true for nonexistent environment (conda silent exit 0)`() { + // conda env remove exits 0 silently when the env doesn't exist. + // Teardown's postcondition is "env does not exist" — already satisfied either way. + val mock = MockCommandRunner().apply { on("conda env remove") } + assertTrue(CondaEnvironmentHandler(mock).teardown(ref(name = "ghost-env"))) + } + + @Test fun `teardown returns false when conda exits non-zero`() { + val mock = MockCommandRunner().apply { on("conda env remove", exitCode = 1) } + assertFalse(CondaEnvironmentHandler(mock).teardown(ref(name = "my-env"))) + } + + @Test fun `teardown returns false when runner throws`() { + val mock = MockCommandRunner().apply { + onThrow("conda env remove", IOException("conda binary not found")) + } + assertFalse(CondaEnvironmentHandler(mock).teardown(ref(name = "my-env"))) + } + + @Test fun `teardown command includes -y flag to suppress interactive prompt`() { + val mock = MockCommandRunner().apply { on("conda env remove") } + CondaEnvironmentHandler(mock).teardown(ref(name = "my-env")) + val call = mock.capturedCommands.single { it.contains("remove") } + assertTrue(call.contains("-y"), "remove command must include -y: $call") + } + + // ── setup ───────────────────────────────────────────────────────────────── + + @Test fun `setup throws EnvironmentProvisioningException when conda is not installed`() { + val mock = MockCommandRunner().apply { on("conda --version", exitCode = 1) } + val ex = assertFailsWith { + CondaEnvironmentHandler(mock).setup(ref()) + } + assertTrue(ex.message!!.contains("Conda not found")) + } + + @Test fun `setup throws EnvironmentProvisioningException when conda create fails`() { + val mock = MockCommandRunner().apply { + on("conda --version") + on("conda create", exitCode = 1, stderr = "SolverError: package not found") + } + val ex = assertFailsWith { + CondaEnvironmentHandler(mock).setup(ref()) + } + assertTrue(ex.message!!.contains("Failed to create conda environment")) + } + + @Test fun `setup throws EnvironmentProvisioningException when validation fails after create`() { + val mock = MockCommandRunner().apply { + on("conda --version") + on("conda create") + on("conda env list", stdout = "base * /opt/conda") // env absent from list + } + val ex = assertFailsWith { + CondaEnvironmentHandler(mock).setup(ref(name = "my-env")) + } + assertTrue(ex.message!!.contains("validation failed")) + } + + @Test fun `setup returns true on full success with no dependencies`() { + val mock = MockCommandRunner().apply { stubFullSuccess("my-env") } + assertTrue(CondaEnvironmentHandler(mock).setup(ref(name = "my-env"))) + } + + @Test fun `setup passes conda packages and channels to create command`() { + val mock = MockCommandRunner().apply { + stubFullSuccess("my-env") + on("conda run -n my-env python -c import numpy") + on("conda run -n my-env python -c import scipy") + } + CondaEnvironmentHandler(mock).setup( + ref( + name = "my-env", + dependencies = listOf("numpy", "scipy"), + channels = listOf("conda-forge", "bioconda") + ) + ) + val createCmd = mock.capturedCommands.single { it.contains("create") } + assertTrue(createCmd.contains("numpy")) + assertTrue(createCmd.contains("scipy")) + assertTrue(createCmd.contains("-c conda-forge")) + assertTrue(createCmd.contains("-c bioconda")) + } + + @Test fun `setup installs pip packages via separate conda run pip install`() { + val mock = MockCommandRunner().apply { + on("conda --version") + on("conda create") + on("conda run -n my-env pip install") + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import pip:black") + on("conda run -n my-env python -c import pip:ruff") + } + assertTrue( + CondaEnvironmentHandler(mock).setup( + ref( + name = "my-env", dependencies = listOf("pip:black", "pip:ruff") + ) + ) + ) + val pipCmd = mock.capturedCommands.single { it.contains("pip install") } + assertTrue(pipCmd.contains("black")) + assertTrue(pipCmd.contains("ruff")) + } + + @Test fun `setup throws when pip install step fails`() { + val mock = MockCommandRunner().apply { + on("conda --version") + on("conda create") + on("conda run -n my-env pip install", exitCode = 1, stderr = "pip error") + } + assertFailsWith { + CondaEnvironmentHandler(mock).setup(ref(name = "my-env", dependencies = listOf("pip:black"))) + } + } + + @Test fun `setup splits mixed conda and pip dependencies correctly`() { + val mock = MockCommandRunner().apply { + on("conda --version") + on("conda create") + on("conda run -n my-env pip install") + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import numpy") + on("conda run -n my-env python -c import pip:black") + } + assertTrue( + CondaEnvironmentHandler(mock).setup( + ref( + name = "my-env", dependencies = listOf("numpy", "pip:black") + ) + ) + ) + val createCmd = mock.capturedCommands.single { it.contains("create") } + assertTrue(createCmd.contains("numpy"), "conda package must appear in create cmd") + assertFalse(createCmd.contains("black"), "pip package must NOT appear in create cmd") + } + + @Test fun `setup throws when verifyCondaInstalled runner throws`() { + val mock = MockCommandRunner().apply { + onThrow("conda --version", IOException("conda not on PATH")) + } + val ex = assertFailsWith { + CondaEnvironmentHandler(mock).setup(ref()) + } + assertTrue(ex.message!!.contains("Conda not found")) + } + + // ── validate ────────────────────────────────────────────────────────────── + + @Test fun `validate returns false when conda env list exits non-zero`() { + val mock = MockCommandRunner().apply { on("conda env list", exitCode = 1) } + assertFalse(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate returns false when env is absent from conda env list`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "base * /opt/conda\nother /opt/conda/envs/other") + } + assertFalse(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate returns false when python version check fails`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", exitCode = 1) + } + assertFalse(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate returns false when a dependency import fails`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import numpy") + on("conda run -n my-env python -c import scipy", exitCode = 1) + } + assertFalse( + CondaEnvironmentHandler(mock).validate( + ref( + name = "my-env", dependencies = listOf("numpy", "scipy") + ) + ) + ) + } + + @Test fun `validate returns true when env exists and all deps import`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import numpy") + on("conda run -n my-env python -c import scipy") + } + assertTrue( + CondaEnvironmentHandler(mock).validate( + ref( + name = "my-env", dependencies = listOf("numpy", "scipy") + ) + ) + ) + } + + @Test fun `validate returns false when runner throws`() { + val mock = MockCommandRunner().apply { + onThrow("conda env list", IOException("conda not found")) + } + assertFalse(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + // condaEnvironmentExists — all four line-matching patterns + + @Test fun `validate recognises env by name-space prefix`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + } + assertTrue(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate recognises active env with asterisk prefix`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "* my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + } + assertTrue(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate recognises env by unix path segment`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "/opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + } + assertTrue(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + @Test fun `validate recognises env by windows path segment`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "C:\\Users\\user\\.conda\\envs\\my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + } + assertTrue(CondaEnvironmentHandler(mock).validate(ref(name = "my-env"))) + } + + // Dependency name extraction edge cases + + @Test fun `validate strips version specifier before import check`() { + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import numpy") + } + assertTrue( + CondaEnvironmentHandler(mock).validate( + ref( + name = "my-env", dependencies = listOf("numpy=1.24.0") + ) + ) + ) + } + + @Test fun `validate uses part before slash as module name`() { + // "conda-forge/scipy" → split("/")[0] = "conda-forge" is the module name passed to import + val mock = MockCommandRunner().apply { + on("conda env list", stdout = "my-env /opt/conda/envs/my-env") + on("conda run -n my-env python --version", stdout = "Python 3.11.0") + on("conda run -n my-env python -c import conda-forge") + } + assertTrue( + CondaEnvironmentHandler(mock).validate( + ref( + name = "my-env", dependencies = listOf("conda-forge/scipy") + ) + ) + ) + } + + // ── CondaCommandExecutionException ──────────────────────────────────────── + + @Test fun `CondaCommandExecutionException preserves message`() { + val msg = "conda create failed:\nERROR: environment already exists" + assertEquals(msg, CondaCommandExecutionException(msg).message) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandlerTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandlerTest.kt new file mode 100644 index 0000000..496d2a2 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/PixiEnvironmentHandlerTest.kt @@ -0,0 +1,145 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PixiEnvironmentHandlerTest { + + private val handler = PixiEnvironmentHandler() + + @Test + fun `can handle PixiEnvironmentRef`() { + val ref = PixiEnvironmentRef( + id = "test-001", + dependencies = emptyList() + ) + + assertTrue(handler.canHandle(ref)) + } + + @Test + fun `cannot handle other environment refs`() { + val ref = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + assertFalse(handler.canHandle(ref)) + } + + @Test + fun `generates execution command`() { + val ref = PixiEnvironmentRef( + id = "test-001", + dependencies = emptyList() + ) + + val command = handler.generateExecutionCommand(ref, "python script.py") + + val expected = "pixi run python script.py" + kotlin.test.assertEquals(expected, command) + } + + @Test + fun `generates execution command with args`() { + val ref = PixiEnvironmentRef( + id = "test-001", + dependencies = listOf("numpy", "pandas") + ) + + val command = handler.generateExecutionCommand( + ref, + "python analysis.py input.csv output.json" + ) + + assertTrue(command.startsWith("pixi run")) + assertTrue(command.contains("analysis.py")) + } + + @Test + fun `validate returns false for nonexistent project`() { + val ref = PixiEnvironmentRef( + id = "definitely-does-not-exist", + dependencies = emptyList() + ) + + val result = handler.validate(ref) + assertFalse(result) + } + + @Test + fun `teardown returns true for nonexistent project`() { + val ref = PixiEnvironmentRef( + id = "missing-pixi-project", + dependencies = emptyList() + ) + + val result = handler.teardown(ref) + + assertTrue(result) + } + + @Test + fun `setup fails when pixi not installed`() { + val ref = PixiEnvironmentRef( + id = "pixi-missing", + dependencies = emptyList() + ) + + val exception = kotlin.runCatching { handler.setup(ref) }.exceptionOrNull() + + assertTrue(exception is EnvironmentProvisioningException) + } + + @Test + fun `validate false when project exists but no python`() { + val originalHome = System.getProperty("user.home") + val tempHome = createTempDirectory("pixi-validate-no-python") + val envId = "no-python-env" + try { + System.setProperty("user.home", tempHome.toString()) + projectDir(envId, tempHome).createDirectories() + + val ref = PixiEnvironmentRef(id = envId, dependencies = emptyList()) + val result = handler.validate(ref) + + assertFalse(result) + } finally { + System.setProperty("user.home", originalHome) + tempHome.toFile().deleteRecursively() + } + } + + @Test + fun `validate false when python executable is invalid`() { + val originalHome = System.getProperty("user.home") + val tempHome = createTempDirectory("pixi-validate-bad-python") + val envId = "bad-python-env" + try { + System.setProperty("user.home", tempHome.toString()) + val projectDir = projectDir(envId, tempHome) + val pythonPath = projectDir.resolve(".pixi/envs/default/bin/python") + pythonPath.parent.createDirectories() + pythonPath.writeText("echo not a real python") + + val ref = PixiEnvironmentRef(id = envId, dependencies = emptyList()) + val result = handler.validate(ref) + + assertFalse(result) + } finally { + System.setProperty("user.home", originalHome) + tempHome.toFile().deleteRecursively() + } + } + + private fun projectDir(envId: String, home: Path): Path = + home.resolve(".carp-dsp/envs/pixi/$envId") +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandlerTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandlerTest.kt new file mode 100644 index 0000000..c063fa3 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/REnvironmentHandlerTest.kt @@ -0,0 +1,357 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import carp.dsp.core.application.environment.REnvironmentDefinition +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.REnvironmentRef +import dk.cachet.carp.common.application.UUID +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class REnvironmentHandlerTest { + + private val handler = REnvironmentHandler() + private val json = Json + + @Test + fun `can handle REnvironmentRef`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + assertTrue(handler.canHandle(ref)) + } + + @Test + fun `cannot handle other environment refs`() { + val ref = CondaEnvironmentRef( + id = "conda-001", + name = "env", + dependencies = emptyList() + ) + + assertFalse(handler.canHandle(ref)) + } + + @Test + fun `generates execution command`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val command = handler.generateExecutionCommand(ref, "script.R") + + assertTrue(command.contains("Rscript")) + assertTrue(command.contains("script.R")) + } + + @Test + fun `generates command with --vanilla for renv`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + renvLockFile = "/path/to/renv.lock" + ) + + val command = handler.generateExecutionCommand(ref, "script.R") + + assertTrue(command.contains("--vanilla")) + } + + @Test + fun `generates command without --vanilla for system R`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val command = handler.generateExecutionCommand(ref, "script.R") + + assertFalse(command.contains("--vanilla")) + assertTrue(command.contains("Rscript")) + } + + @Test + fun `generates execution command with args`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val command = handler.generateExecutionCommand(ref, "script.R arg1 arg2") + + assertTrue(command.contains("script.R arg1 arg2")) + } + + @Test + fun `validate returns false for nonexistent env`() { + val ref = REnvironmentRef( + id = "nonexistent", + rVersion = "4.3.0", + rPackages = listOf("nonexistent-package-xyz") + ) + + // If R is not installed, this will fail; if it is, it will fail on package + val result = handler.validate(ref) + assertFalse(result) + } + + @Test + fun `teardown returns boolean for nonexistent env`() { + val ref = REnvironmentRef( + id = "nonexistent", + rVersion = "4.3.0", + rPackages = listOf("ggplot2") + ) + + val result = handler.teardown(ref) + assertTrue(result) + } + + @Test + fun `supports dependencies`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2"), + dependencies = listOf("pandoc", "ghostscript") + ) + + assertEquals(2, ref.dependencies.size) + } + + @Test + fun `supports environment variables`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2"), + environmentVariables = mapOf( + "R_LIBS" to "/usr/local/lib/R", + "R_HOME" to "/opt/R/4.3.0" + ) + ) + + assertEquals(2, ref.environmentVariables.size) + } + + @Test + fun `supports renv lock file`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + renvLockFile = "/path/to/renv.lock" + ) + + assertTrue(handler.canHandle(ref)) + } + + @Test + fun `setup fails when renv lock missing`() { + val ref = REnvironmentRef( + id = "r-env-missing-lock", + rVersion = "4.3.0", + renvLockFile = "/path/does/not/exist/renv.lock" + ) + + val exception = kotlin.runCatching { handler.setup(ref) }.exceptionOrNull() + + assertTrue(exception is EnvironmentProvisioningException) + } + + @Test + fun `setup succeeds with stub rscript`() { + withFakeRscript(failToken = null) { + val ref = REnvironmentRef( + id = "r-env-stub", + rVersion = "4.3.0", + rPackages = listOf("okpkg") + ) + + val result = handler.setup(ref) + + assertTrue(result) + } + } + + @Test + fun `setup fails when package install fails`() { + withFakeRscript(failToken = "failpkg") { + val ref = REnvironmentRef( + id = "r-env-fail", + rVersion = "4.3.0", + rPackages = listOf("failpkg") + ) + + val exception = kotlin.runCatching { handler.setup(ref) }.exceptionOrNull() + + assertTrue(exception is EnvironmentProvisioningException) + } + } + + @Test + fun `supports installation path`() { + val ref = REnvironmentRef( + id = "r-env-001", + rVersion = "4.3.0", + rPackages = listOf("ggplot2"), + installationPath = "/opt/R/4.3.0" + ) + + assertEquals("/opt/R/4.3.0", ref.installationPath) + } + + private fun withFakeRscript(failToken: String?, block: () -> Unit) { + val tempDir = createTempDirectory("fake-rscript") + val originalProp = System.getProperty("carp.rscript") + try { + val stub = createStubRscript(tempDir, failToken) + System.setProperty("carp.rscript", stub.toString()) + block() + } finally { + if (originalProp == null) System.clearProperty("carp.rscript") else System.setProperty("carp.rscript", originalProp) + tempDir.toFile().deleteRecursively() + } + } + + private fun createStubRscript(dir: Path, failToken: String?): Path { + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val name = if (isWindows) "Rscript.bat" else "Rscript" + val script = dir.resolve(name) + val content = + if (isWindows) + """@echo off + set args=%* + echo %args% | findstr /C:"${failToken ?: "__no_fail__"}" >nul + if %errorlevel%==0 ( + exit /b 1 + ) + if "%1"=="--version" ( + echo R version 4.3.0 + exit /b 0 + ) + exit /b 0 + """.trimIndent() + else + """#!/bin/sh + args="$@" + if [ -n "${failToken ?: ""}" ] && echo "${'$'}args" | grep -q "${failToken ?: ""}"; then + exit 1 + fi + if [ "$1" = "--version" ]; then + echo "R version 4.3.0" + exit 0 + fi + exit 0 + """.trimIndent() + + script.writeText(content) + if (!isWindows) { + script.toFile().setExecutable(true) + } + return script + } + + + @Test + fun `valid definition with packages passes validation`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "4.3.0", + rPackages = listOf("dplyr", "ggplot2") + ) + + val errors = def.validate() + + assertTrue(errors.isEmpty()) + } + + @Test + fun `blank version yields error`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "", + rPackages = listOf("dplyr") + ) + + val errors = def.validate() + + assertTrue(errors.any { it.contains("R version cannot be blank") }) + } + + @Test + fun `missing packages and lock yields error`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "4.3.0", + rPackages = emptyList(), + renvLockFile = null + ) + + val errors = def.validate() + + assertTrue(errors.any { it.contains("Either renvLockFile or rPackages must be specified") }) + } + + @Test + fun `invalid version format yields error`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "4", + rPackages = listOf("dplyr") + ) + + val errors = def.validate() + + assertTrue(errors.any { it.contains("Invalid R version format") }) + } + + @Test + fun `renv lock without packages passes validation`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "4.3.0", + renvLockFile = "/path/to/renv.lock" + ) + + val errors = def.validate() + + assertTrue(errors.isEmpty()) + } + + @Test + fun `serializes and deserializes`() { + val def = REnvironmentDefinition( + id = UUID.randomUUID(), + name = "r-env", + rVersion = "4.3.0", + rPackages = listOf("dplyr"), + renvLockFile = null, + installationPath = "/opt/R/4.3.0", + dependencies = listOf("pandoc"), + environmentVariables = mapOf("R_LIBS" to "/usr/local/lib/R") + ) + + val encoded = json.encodeToString(def) + val decoded = json.decodeFromString(encoded) + + assertEquals(def, decoded) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandlerTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandlerTest.kt new file mode 100644 index 0000000..1dd2273 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/infrastructure/execution/handlers/SystemEnvironmentHandlerTest.kt @@ -0,0 +1,111 @@ +package carp.dsp.core.infrastructure.execution.handlers + +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SystemEnvironmentHandlerTest { + + private val handler = SystemEnvironmentHandler() + + @Test + fun `can handle SystemEnvironmentRef`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + assertTrue(handler.canHandle(ref)) + } + + @Test + fun `cannot handle other environment refs`() { + val ref = CondaEnvironmentRef( + id = "conda-001", + name = "env", + dependencies = emptyList() + ) + + assertFalse(handler.canHandle(ref)) + } + + @Test + fun `setup is no-op`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val result = handler.setup(ref) + + assertTrue(result) + } + + @Test + fun `generate execution command returns unmodified command`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val command = handler.generateExecutionCommand(ref, "python script.py arg1") + + assertEquals("python script.py arg1", command) + } + + @Test + fun `generate execution command preserves complex commands`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val inputCommand = "python -m mymodule --option=value file1.txt file2.txt" + val command = handler.generateExecutionCommand(ref, inputCommand) + + assertEquals(inputCommand, command) + } + + @Test + fun `teardown is no-op`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val result = handler.teardown(ref) + + assertTrue(result) + } + + @Test + fun `validate always returns true`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val result = handler.validate(ref) + + assertTrue(result) + } + + @Test + fun `all methods are no-ops`() { + val ref = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + assertTrue(handler.canHandle(ref)) + assertTrue(handler.setup(ref)) + assertTrue(handler.teardown(ref)) + assertTrue(handler.validate(ref)) + + val command = handler.generateExecutionCommand(ref, "test command") + assertEquals("test command", command) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/integration/EnvironmentOrchestrationIntegrationTest.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/integration/EnvironmentOrchestrationIntegrationTest.kt new file mode 100644 index 0000000..14cf6b5 --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/integration/EnvironmentOrchestrationIntegrationTest.kt @@ -0,0 +1,120 @@ +package carp.dsp.core.integration + + +import carp.dsp.core.infrastructure.execution.DefaultEnvironmentOrchestrator +import carp.dsp.core.infrastructure.execution.DefaultEnvironmentRegistry +import carp.dsp.core.infrastructure.execution.EnvironmentHandlerRegistry +import carp.dsp.core.infrastructure.execution.handlers.CondaEnvironmentHandler +import dk.cachet.carp.analytics.application.plan.CondaEnvironmentRef +import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef +import dk.cachet.carp.analytics.infrastructure.execution.* +import kotlinx.datetime.Clock +import java.nio.file.Files +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively +import kotlin.test.* + +class EnvironmentOrchestrationIntegrationTest { + + private lateinit var tmpRegistry: java.nio.file.Path + private lateinit var registry: EnvironmentRegistry + private lateinit var orchestrator: EnvironmentOrchestrator + + @BeforeTest + fun setup() { + tmpRegistry = Files.createTempDirectory("env-registry-test") + registry = DefaultEnvironmentRegistry(tmpRegistry.resolve("environments.json")) + orchestrator = DefaultEnvironmentOrchestrator(registry) + } + + @OptIn(ExperimentalPathApi::class) + @AfterTest + fun cleanup() { + tmpRegistry.deleteRecursively() + } + + @Test + fun orchestratorRoutesToCorrectHandler() { + val conda = CondaEnvironmentRef( + id = "test-001", + name = "test-env", + dependencies = emptyList() + ) + + val handler = EnvironmentHandlerRegistry.getHandler(conda) + assertTrue(handler is CondaEnvironmentHandler || handler::class.simpleName?.contains("Conda") == true) + } + + @Test + fun orchestratorGeneratesExecutionCommand() { + val system = SystemEnvironmentRef( + id = "system-001", + dependencies = emptyList() + ) + + val command = orchestrator.generateExecutionCommand( + system, + "python script.py arg1" + ) + + assertEquals("python script.py arg1", command) + } + + @Test + fun registryPersistsMetadata() { + val ref = SystemEnvironmentRef( + id = "test-001", + dependencies = emptyList() + ) + + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test", + kind = "system", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 0L + ) + + registry.register(ref, metadata) + assertTrue(registry.exists(ref.id)) + + assertEquals(metadata.name, registry.getMetadata(ref.id)?.name) + } + + @Test + fun cleanupPolicyReuse() { + val config = EnvironmentConfig(cleanupPolicy = CleanupPolicy.REUSE) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + val ref = SystemEnvironmentRef(id = "test-001", dependencies = emptyList()) + val teardownSuccess = orch.teardown(ref) + + assertTrue(teardownSuccess) + } + + @Test + fun cleanupPolicyPurge() { + val config = EnvironmentConfig(cleanupPolicy = CleanupPolicy.PURGE) + val orch = DefaultEnvironmentOrchestrator(registry, config) + + // Register an environment + val ref = SystemEnvironmentRef(id = "test-001", dependencies = emptyList()) + val metadata = EnvironmentMetadata( + id = ref.id, + name = "test", + kind = "system", + runId = "run-001", + createdAt = Clock.System.now(), + lastUsedAt = Clock.System.now(), + sizeBytes = 0L + ) + registry.register(ref, metadata) + + assertTrue(registry.exists(ref.id)) + + // Teardown with PURGE + orch.teardown(ref) + } +} diff --git a/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/testing/MockCommandRunner.kt b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/testing/MockCommandRunner.kt new file mode 100644 index 0000000..b8754cc --- /dev/null +++ b/carp.dsp.core/src/jvmTest/kotlin/carp/dsp/core/testing/MockCommandRunner.kt @@ -0,0 +1,117 @@ +package carp.dsp.core.testing + +import dk.cachet.carp.analytics.application.execution.RunPolicy +import dk.cachet.carp.analytics.application.plan.CommandSpec +import dk.cachet.carp.analytics.application.plan.ExpandedArg +import dk.cachet.carp.analytics.application.runtime.CommandResult +import dk.cachet.carp.analytics.application.runtime.CommandRunner + +/** + * Test implementation of the core [CommandRunner] interface. + * + * Register scripted responses with [on] or [onThrow] before exercising a handler. + * Commands are matched by joining `executable + args` with spaces and calling + * [String.startsWith], so register a prefix rather than the full command to handle + * dynamic parts such as environment names or file paths. + * + * Any command not matched by a registered prefix throws [IllegalStateException] + * immediately, surfacing unexpected invocations rather than silently swallowing them. + * + * ## Usage + * ```kotlin + * val mock = MockCommandRunner().apply { + * on("conda --version") // exit 0, empty output + * on("conda create", exitCode = 1, stderr = "SolverError") + * onThrow("conda env list", IOException("not found")) + * } + * val handler = CondaEnvironmentHandler(mock) + * ``` + * + * ## Working directory + * The `workingDir` passed by handlers that use [carp.dsp.core.infrastructure.runtime.JvmCommandRunner]'s workspace-root + * overload is NOT visible through [CommandRunner.run]. If you need to assert that + * a specific working directory was used (e.g. for `pixi install`), use [capturedSpecs] + * to inspect the [CommandSpec] and verify the executable + args, then confirm the + * side effect separately (e.g. check that a file was written to the expected directory). + */ +class MockCommandRunner : CommandRunner { + + private sealed class Action { + data class Return(val result: CommandResult) : Action() + data class Throw(val ex: Exception) : Action() + } + + private val responses = mutableListOf>() + + /** All [CommandSpec]s received in invocation order. */ + val capturedSpecs = mutableListOf() + + /** Convenience view: each captured call as `"executable arg1 arg2 ..."`. */ + val capturedCommands: List + get() = capturedSpecs.map { spec -> + listOf(spec.executable) + .plus( + spec.args.map { arg -> + when (arg) { + is ExpandedArg.Literal -> arg.value + is ExpandedArg.DataReference -> arg.dataRefId.toString() + is ExpandedArg.PathSubstitution -> arg.template + } + } + ) + .joinToString(" ") + } + + /** + * Registers a scripted [CommandResult] for any command whose string representation + * starts with [cmdPrefix]. + */ + fun on( + cmdPrefix: String, + exitCode: Int = 0, + stdout: String = "", + stderr: String = "", + ) { + responses.add( + cmdPrefix to Action.Return( + CommandResult(exitCode = exitCode, stdout = stdout, stderr = stderr, durationMs = 0, timedOut = false) + ) + ) + } + + /** + * Registers an exception to be thrown for any command whose string representation + * starts with [cmdPrefix]. Use this to exercise `catch(IOException)` and similar + * branches that are unreachable when a command merely returns a non-zero exit code. + */ + fun onThrow(cmdPrefix: String, ex: Exception) { + responses.add(cmdPrefix to Action.Throw(ex)) + } + + override fun run(command: CommandSpec, policy: RunPolicy): CommandResult { + capturedSpecs.add(command) + + val commandStr = listOf(command.executable) + .plus( + command.args.map { arg -> + when (arg) { + is ExpandedArg.Literal -> arg.value + is ExpandedArg.DataReference -> arg.dataRefId.toString() + is ExpandedArg.PathSubstitution -> arg.template + } + } + ) + .joinToString(" ") + + val action = responses.firstOrNull { (prefix, _) -> commandStr.startsWith(prefix) }?.second + ?: error( + "MockCommandRunner: unexpected command: \"$commandStr\"\n" + + "Registered prefixes: ${responses.map { "\"${it.first}\"" }}" + ) + + return when (action) { + is Action.Return -> action.result + is Action.Throw -> throw action.ex + } + } +} diff --git a/carp.dsp.demo/src/commonMain/kotlin/carp/dsp/demo/utils/PlanDisplayUtils.kt b/carp.dsp.demo/src/commonMain/kotlin/carp/dsp/demo/utils/PlanDisplayUtils.kt index 7b884ff..62e2832 100644 --- a/carp.dsp.demo/src/commonMain/kotlin/carp/dsp/demo/utils/PlanDisplayUtils.kt +++ b/carp.dsp.demo/src/commonMain/kotlin/carp/dsp/demo/utils/PlanDisplayUtils.kt @@ -6,6 +6,7 @@ import dk.cachet.carp.analytics.application.plan.ExecutionPlan import dk.cachet.carp.analytics.application.plan.PlanIssue import dk.cachet.carp.analytics.application.plan.PlanIssueSeverity import dk.cachet.carp.analytics.application.plan.PixiEnvironmentRef +import dk.cachet.carp.analytics.application.plan.REnvironmentRef import dk.cachet.carp.analytics.application.plan.SystemEnvironmentRef import dk.cachet.carp.analytics.domain.workflow.WorkflowDefinition @@ -29,6 +30,7 @@ object PlanDisplayUtils { fun getEnvironmentType(envRef: EnvironmentRef): String = when (envRef) { is CondaEnvironmentRef -> "conda" is PixiEnvironmentRef -> "pixi" + is REnvironmentRef -> "R" is SystemEnvironmentRef -> "system" }