diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1ca3f6..387dddb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - java_version: [8, 11] + java_version: [19] steps: - name: Environment diff --git a/buildSrc/src/main/groovy/nextflow/gradle/plugins/SourcesMatcher.groovy b/buildSrc/src/main/groovy/nextflow/gradle/plugins/SourcesMatcher.groovy index 7e9260f..1865b93 100644 --- a/buildSrc/src/main/groovy/nextflow/gradle/plugins/SourcesMatcher.groovy +++ b/buildSrc/src/main/groovy/nextflow/gradle/plugins/SourcesMatcher.groovy @@ -41,7 +41,7 @@ class SourcesMatcher { } matcher.collect { file -> def source = file.toString() - "$root.absolutePath/src/main/" - return source.split('\\.').dropRight(1).join().split(File.separator).drop(1).join('.') + return source.split('\\.').dropRight(1).join().split(File.separator).join('.') } } diff --git a/gradle.properties b/gradle.properties index 8766f31..c623543 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=0.0.2-rc1 \ No newline at end of file +version=0.0.2 \ No newline at end of file diff --git a/plugins/build.gradle b/plugins/build.gradle index 57c5bcc..6b08277 100644 --- a/plugins/build.gradle +++ b/plugins/build.gradle @@ -32,6 +32,21 @@ subprojects { mavenCentral() } + java { + toolchain { + languageVersion = JavaLanguageVersion.of(19) + } + } + + compileJava { + options.release.set(11) + } + + tasks.withType(GroovyCompile) { + sourceCompatibility = '11' + targetCompatibility = '11' + } + tasks.withType(Jar) { duplicatesStrategy = DuplicatesStrategy.INCLUDE } diff --git a/plugins/nf-nomad/build.gradle b/plugins/nf-nomad/build.gradle index c4badfd..2e04bb1 100644 --- a/plugins/nf-nomad/build.gradle +++ b/plugins/nf-nomad/build.gradle @@ -95,5 +95,18 @@ dependencies { // use JUnit 5 platform test { useJUnitPlatform() + jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED' } +jar { + manifest { + attributes( + 'Manifest-Version':'1.0', + 'Plugin-Id': project.name, + 'Plugin-Version': archiveVersion, + 'Plugin-Class': "nextflow.nomad.NomadPlugin", + 'Plugin-Provider': 'nextflow', + 'Plugin-Requires': '>=23.10.0', + ) + } +} diff --git a/plugins/nf-nomad/src/main/nextflow/nomad/NomadConfig.groovy b/plugins/nf-nomad/src/main/nextflow/nomad/NomadConfig.groovy index 5c3cb2e..624efc2 100644 --- a/plugins/nf-nomad/src/main/nextflow/nomad/NomadConfig.groovy +++ b/plugins/nf-nomad/src/main/nextflow/nomad/NomadConfig.groovy @@ -28,13 +28,22 @@ import groovy.util.logging.Slf4j @Slf4j @CompileStatic class NomadConfig { + final static protected API_VERSION = "v1" + + final static public String VOLUME_DOCKER_TYPE = "docker" + final static public String VOLUME_CSI_TYPE = "csi" + final static public String VOLUME_HOST_TYPE = "host" + + final static protected String[] VOLUME_TYPES = [ + VOLUME_CSI_TYPE, VOLUME_DOCKER_TYPE, VOLUME_HOST_TYPE + ] final NomadClientOpts clientOpts final NomadJobOpts jobOpts NomadConfig(Map nomadConfigMap) { - clientOpts = new NomadClientOpts((nomadConfigMap.client ?: Collections.emptyMap()) as Map) - jobOpts = new NomadJobOpts((nomadConfigMap.jobs ?: Collections.emptyMap()) as Map) + clientOpts = new NomadClientOpts((nomadConfigMap?.client ?: Collections.emptyMap()) as Map) + jobOpts = new NomadJobOpts((nomadConfigMap?.jobs ?: Collections.emptyMap()) as Map) } class NomadClientOpts{ @@ -42,7 +51,10 @@ class NomadConfig { final String token NomadClientOpts(Map nomadClientOpts){ - address = (nomadClientOpts.address?.toString() ?: "http://127.0.0.1:4646")+"/v1" + def tmp = (nomadClientOpts.address?.toString() ?: "http://127.0.0.1:4646") + if( !tmp.endsWith("/")) + tmp +="/" + this.address = tmp + API_VERSION token = nomadClientOpts.token ?: null } } @@ -53,15 +65,67 @@ class NomadConfig { final String region final String namespace final String dockerVolume + final VolumeSpec volumeSpec NomadJobOpts(Map nomadJobOpts){ deleteOnCompletion = nomadJobOpts.containsKey("deleteOnCompletion") ? nomadJobOpts.deleteOnCompletion : false - datacenters = (nomadJobOpts.containsKey("datacenters") ? - nomadJobOpts.datacenters.toString().split(",") : List.of("dc1")) as List + if( nomadJobOpts.containsKey("datacenters") ) { + datacenters = ((nomadJobOpts.datacenters instanceof List ? + nomadJobOpts.datacenters : nomadJobOpts.datacenters.toString().split(",")) + as List).findAll{it.size()}.unique() + }else{ + datacenters = [] + } region = nomadJobOpts.region ?: null namespace = nomadJobOpts.namespace ?: null dockerVolume = nomadJobOpts.dockerVolume ?: null + if( dockerVolume ){ + log.info "dockerVolume config will be deprecated, use volume type:'docker' name:'name' instead" + } + if( nomadJobOpts.volume && nomadJobOpts.volume instanceof Closure){ + this.volumeSpec = new VolumeSpec() + def closure = (nomadJobOpts.volume as Closure) + def clone = closure.rehydrate(this.volumeSpec, closure.owner, closure.thisObject) + clone.resolveStrategy = Closure.DELEGATE_FIRST + clone() + this.volumeSpec.validate() + }else{ + volumeSpec = null + } + } + } + + class VolumeSpec{ + + private String type + private String name + + String getType() { + return type + } + + String getName() { + return name + } + + VolumeSpec type(String type){ + this.type = type + this + } + + VolumeSpec name(String name){ + this.name = name + this + } + + protected validate(){ + if( !VOLUME_TYPES.contains(type) ) { + throw new IllegalArgumentException("Volume type $type is not supported") + } + if( !this.name ){ + throw new IllegalArgumentException("Volume name is required") + } } } } diff --git a/plugins/nf-nomad/src/main/nextflow/nomad/executor/NomadService.groovy b/plugins/nf-nomad/src/main/nextflow/nomad/executor/NomadService.groovy index 7b5200f..7c1cdd5 100644 --- a/plugins/nf-nomad/src/main/nextflow/nomad/executor/NomadService.groovy +++ b/plugins/nf-nomad/src/main/nextflow/nomad/executor/NomadService.groovy @@ -1,4 +1,5 @@ /* + * Copyright 2024, Evaluacion y Desarrollo de Negocios, Spain * Copyright 2023, Stellenbosch University, South Africa * Copyright 2022, Center for Medical Genetics, Ghent * @@ -28,6 +29,8 @@ import io.nomadproject.client.models.JobSummary import io.nomadproject.client.models.Task import io.nomadproject.client.models.TaskGroup import io.nomadproject.client.models.TaskGroupSummary +import io.nomadproject.client.models.VolumeMount +import io.nomadproject.client.models.VolumeRequest import nextflow.nomad.NomadConfig /** @@ -80,6 +83,15 @@ class NomadService implements Closeable{ name: "group", tasks: [ task ] ) + if( config.jobOpts.volumeSpec){ + taskGroup.volumes = [:] + taskGroup.volumes[config.jobOpts.volumeSpec.name]= new VolumeRequest( + type: config.jobOpts.volumeSpec.type, + source: config.jobOpts.volumeSpec.name, + attachmentMode: "file-system", + accessMode: "multi-node-multi-writer" + ) + } return taskGroup } @@ -105,13 +117,20 @@ class NomadService implements Closeable{ readonly : false ] } + if( config.jobOpts.volumeSpec){ + String destinationDir = workingDir.split(File.separator).dropRight(2).join(File.separator) + task.volumeMounts = [ new VolumeMount( + destination: destinationDir, + volume: config.jobOpts.volumeSpec.name + )] + } task } String state(String jobId){ JobSummary summary = jobsApi.getJobSummary(jobId, config.jobOpts.region, config.jobOpts.namespace, null, null, null, null, null, null, null) - TaskGroupSummary taskGroupSummary = summary.summary.values().first() + TaskGroupSummary taskGroupSummary = summary?.summary?.values()?.first() switch (taskGroupSummary){ case {taskGroupSummary?.starting }: return TaskGroupSummary.SERIALIZED_NAME_STARTING diff --git a/plugins/nf-nomad/src/resources/META-INF/MANIFEST.MF b/plugins/nf-nomad/src/resources/META-INF/MANIFEST.MF deleted file mode 100644 index ceec83c..0000000 --- a/plugins/nf-nomad/src/resources/META-INF/MANIFEST.MF +++ /dev/null @@ -1,7 +0,0 @@ -Manifest-Version: 1.0 -Plugin-Id: nf-nomad -Plugin-Version: 0.0.1 -Plugin-Class: nextflow.nomad.NomadPlugin -Plugin-Provider: nextflow -Plugin-Requires: >=23.10.0 - diff --git a/plugins/nf-nomad/src/resources/META-INF/extensions.idx b/plugins/nf-nomad/src/resources/META-INF/extensions.idx deleted file mode 100644 index 1d1a1f1..0000000 --- a/plugins/nf-nomad/src/resources/META-INF/extensions.idx +++ /dev/null @@ -1,2 +0,0 @@ -nextflow.nomad.NomadPlugin -nextflow.nomad.executor.NomadExecutor diff --git a/plugins/nf-nomad/src/test/nextflow/nomad/NomadConfigSpec.groovy b/plugins/nf-nomad/src/test/nextflow/nomad/NomadConfigSpec.groovy new file mode 100644 index 0000000..51fa952 --- /dev/null +++ b/plugins/nf-nomad/src/test/nextflow/nomad/NomadConfigSpec.groovy @@ -0,0 +1,162 @@ +/* + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.nomad + +import spock.lang.Specification + +/** + * Unit test for Nomad Config + * + * @author : Jorge Aguilera + */ +class NomadConfigSpec extends Specification { + + void "should create a default config"() { + given: + def config = new NomadConfig() + + expect: + config.jobOpts + config.clientOpts + } + + void "should use localhost as default address"() { + given: + def config = new NomadConfig([:]) + + expect: + config.clientOpts.address == "http://127.0.0.1:4646/v1" + } + + void "should use address if provided"() { + given: + def config = new NomadConfig([ + client: [address: "http://nomad"] + ]) + + expect: + config.clientOpts.address == "http://nomad/v1" + } + + void "should normalize address if provided"() { + given: + def config = new NomadConfig([ + client: [address: "http://nomad/"] + ]) + + expect: + config.clientOpts.address == "http://nomad/v1" + } + + void "should use token if provided"() { + given: + def config = new NomadConfig([ + client: [token: "theToken"] + ]) + + expect: + config.clientOpts.token == "theToken" + } + + void "should use an empty list if no datacenters is provided"() { + given: + def config = new NomadConfig([ + jobs: [:] + ]) + + expect: + !config.jobOpts.datacenters.size() + } + + void "should use datacenters #dc with size #size if provided"() { + given: + def config = new NomadConfig([ + jobs: [datacenters: dc] + ]) + + expect: + config.jobOpts.datacenters.size() == size + + where: + dc | size + [] | 0 + "dc1" | 1 + ['dc1'] | 1 + "dc1,dc2" | 2 + ['dc1', 'dc2'] | 2 + ['dc1', 'dc1'] | 1 + } + + void "should use region if provided"() { + given: + def config = new NomadConfig([ + jobs: [region: "theRegion"] + ]) + + expect: + config.jobOpts.region == "theRegion" + } + + void "should use namespace if provided"() { + given: + def config = new NomadConfig([ + jobs: [namespace: "namespace"] + ]) + + expect: + config.jobOpts.namespace == "namespace" + } + + void "should instantiate a volume spec if specified"() { + when: + def config = new NomadConfig([ + jobs: [volume : { type "docker" name "test" }] + ]) + + then: + config.jobOpts.volumeSpec + config.jobOpts.volumeSpec.type == NomadConfig.VOLUME_DOCKER_TYPE + config.jobOpts.volumeSpec.name == "test" + + when: + def config2 = new NomadConfig([ + jobs: [volume : { type "csi" name "test" }] + ]) + + then: + config2.jobOpts.volumeSpec + config2.jobOpts.volumeSpec.type == NomadConfig.VOLUME_CSI_TYPE + config2.jobOpts.volumeSpec.name == "test" + + when: + def config3 = new NomadConfig([ + jobs: [volume : { type "host" name "test" }] + ]) + + then: + config3.jobOpts.volumeSpec + config3.jobOpts.volumeSpec.type == NomadConfig.VOLUME_HOST_TYPE + config3.jobOpts.volumeSpec.name == "test" + + when: + new NomadConfig([ + jobs: [volume : { type "not-supported" name "test" }] + ]) + + then: + thrown(IllegalArgumentException) + } +} diff --git a/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadServiceSpec.groovy b/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadServiceSpec.groovy new file mode 100644 index 0000000..3e7e69d --- /dev/null +++ b/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadServiceSpec.groovy @@ -0,0 +1,248 @@ +/* + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.nomad.executor + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import nextflow.nomad.NomadConfig +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import spock.lang.Specification + +/** + * Unit test for Nomad Service + * + * Validate requests using a Mock WebServer + * + * @author : Jorge Aguilera + */ +class NomadServiceSpec extends Specification{ + + MockWebServer mockWebServer + + def setup() { + mockWebServer = new MockWebServer() + mockWebServer.start() + } + + def cleanup() { + mockWebServer.shutdown() + } + + void "submit a task"(){ + given: + def config = new NomadConfig( + client:[ + address : "http://${mockWebServer.hostName}:${mockWebServer.port}" + ] + ) + def service = new NomadService(config) + + String id = "theId" + String name = "theName" + String image = "theImage" + List args = ["theCommand", "theArgs"] + String workingDir = "theWorkingDir" + Mapenv = [test:"test"] + + mockWebServer.enqueue(new MockResponse() + .setBody(JsonOutput.toJson(["EvalID":"test"]).toString()) + .addHeader("Content-Type", "application/json")); + when: + + def idJob = service.submitTask(id, name, image, args, workingDir,env) + def recordedRequest = mockWebServer.takeRequest(); + def body = new JsonSlurper().parseText(recordedRequest.body.readUtf8()) + + then: + idJob + + and: + recordedRequest.method == "POST" + recordedRequest.path == "/v1/jobs" + + and: + body.Job + body.Job.ID == id + body.Job.Name == name + body.Job.Datacenters == [] + body.Job.Type == "batch" + body.Job.TaskGroups.size() == 1 + body.Job.TaskGroups[0].Name == "group" + body.Job.TaskGroups[0].Tasks.size() == 1 + body.Job.TaskGroups[0].Tasks[0].Name == "nf-task" + body.Job.TaskGroups[0].Tasks[0].Driver == "docker" + body.Job.TaskGroups[0].Tasks[0].Config.image == image + body.Job.TaskGroups[0].Tasks[0].Config.work_dir == workingDir + body.Job.TaskGroups[0].Tasks[0].Config.command == args[0] + body.Job.TaskGroups[0].Tasks[0].Config.args == args.drop(1) + + !body.Job.TaskGroups[0].Tasks[0].Config.mount + } + + void "submit a task with docker volume"(){ + given: + def config = new NomadConfig( + client:[ + address : "http://${mockWebServer.hostName}:${mockWebServer.port}" + ], + jobs:[ + dockerVolume:'test' + ] + ) + def service = new NomadService(config) + + String id = "theId" + String name = "theName" + String image = "theImage" + List args = ["theCommand", "theArgs"] + String workingDir = "a/b/c" + Mapenv = [test:"test"] + + mockWebServer.enqueue(new MockResponse() + .setBody(JsonOutput.toJson(["EvalID":"test"]).toString()) + .addHeader("Content-Type", "application/json")); + when: + + def idJob = service.submitTask(id, name, image, args, workingDir,env) + def recordedRequest = mockWebServer.takeRequest(); + def body = new JsonSlurper().parseText(recordedRequest.body.readUtf8()) + + then: + idJob + + and: + recordedRequest.method == "POST" + recordedRequest.path == "/v1/jobs" + + and: + body.Job + body.Job.ID == id + body.Job.Name == name + body.Job.Datacenters == [] + body.Job.Type == "batch" + body.Job.TaskGroups.size() == 1 + body.Job.TaskGroups[0].Name == "group" + body.Job.TaskGroups[0].Tasks.size() == 1 + body.Job.TaskGroups[0].Tasks[0].Name == "nf-task" + body.Job.TaskGroups[0].Tasks[0].Driver == "docker" + body.Job.TaskGroups[0].Tasks[0].Config.image == image + body.Job.TaskGroups[0].Tasks[0].Config.work_dir == workingDir + body.Job.TaskGroups[0].Tasks[0].Config.command == args[0] + body.Job.TaskGroups[0].Tasks[0].Config.args == args.drop(1) + + body.Job.TaskGroups[0].Tasks[0].Config.mount == [type:"volume", target:"a", source:"test", readonly:false] + } + + void "should check the state"(){ + given: + def config = new NomadConfig( + client:[ + address : "http://${mockWebServer.hostName}:${mockWebServer.port}" + ] + ) + def service = new NomadService(config) + + when: + mockWebServer.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json")); + + def state = service.state("theId") + def recordedRequest = mockWebServer.takeRequest(); + + then: + recordedRequest.method == "GET" + recordedRequest.path == "/v1/job/theId/summary" + + and: + state == "Unknown" + + when: + mockWebServer.enqueue(new MockResponse() + .setBody(JsonOutput.toJson(["JobID":"test","Summary":[ + test:[Starting:1] + ]]).toString()) + .addHeader("Content-Type", "application/json")); + + state = service.state("theId") + recordedRequest = mockWebServer.takeRequest(); + + then: + recordedRequest.method == "GET" + recordedRequest.path == "/v1/job/theId/summary" + + and: + state == "Starting" + + } + + void "submit a task with a volume"(){ + given: + def config = new NomadConfig( + client:[ + address : "http://${mockWebServer.hostName}:${mockWebServer.port}" + ], + jobs:[ + volume: { type "csi" name "test" } + ] + ) + def service = new NomadService(config) + + String id = "theId" + String name = "theName" + String image = "theImage" + List args = ["theCommand", "theArgs"] + String workingDir = "a/b/c" + Mapenv = [test:"test"] + + mockWebServer.enqueue(new MockResponse() + .setBody(JsonOutput.toJson(["EvalID":"test"]).toString()) + .addHeader("Content-Type", "application/json")); + when: + + def idJob = service.submitTask(id, name, image, args, workingDir,env) + def recordedRequest = mockWebServer.takeRequest(); + def body = new JsonSlurper().parseText(recordedRequest.body.readUtf8()) + + then: + idJob + + and: + recordedRequest.method == "POST" + recordedRequest.path == "/v1/jobs" + + and: + body.Job + body.Job.ID == id + body.Job.Name == name + body.Job.Datacenters == [] + body.Job.Type == "batch" + body.Job.TaskGroups.size() == 1 + body.Job.TaskGroups[0].Name == "group" + body.Job.TaskGroups[0].Tasks.size() == 1 + body.Job.TaskGroups[0].Tasks[0].Name == "nf-task" + body.Job.TaskGroups[0].Tasks[0].Driver == "docker" + body.Job.TaskGroups[0].Tasks[0].Config.image == image + body.Job.TaskGroups[0].Tasks[0].Config.work_dir == workingDir + body.Job.TaskGroups[0].Tasks[0].Config.command == args[0] + body.Job.TaskGroups[0].Tasks[0].Config.args == args.drop(1) + + body.Job.TaskGroups[0].Volumes.size() == 1 + body.Job.TaskGroups[0].Volumes['test'] == [AccessMode:"multi-node-multi-writer", AttachmentMode:"file-system", Source:"test", Type:"csi"] + body.Job.TaskGroups[0].Tasks[0].VolumeMounts.size() == 1 + body.Job.TaskGroups[0].Tasks[0].VolumeMounts[0] == [Destination:"a", Volume:"test"] + + } +} diff --git a/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadTaskHandlerSpec.groovy b/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadTaskHandlerSpec.groovy new file mode 100644 index 0000000..dd5f4af --- /dev/null +++ b/plugins/nf-nomad/src/test/nextflow/nomad/executor/NomadTaskHandlerSpec.groovy @@ -0,0 +1,85 @@ +/* + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.nomad.executor + +import nextflow.exception.ProcessSubmitException +import nextflow.executor.Executor +import nextflow.nomad.NomadConfig +import nextflow.processor.TaskBean +import nextflow.processor.TaskProcessor +import nextflow.processor.TaskRun +import nextflow.processor.TaskStatus +import spock.lang.Specification + +import java.nio.file.Files +import java.nio.file.Path + +/** + * Unit test for Nomad Task Handler + * + * + * @author : Jorge Aguilera + */ +class NomadTaskHandlerSpec extends Specification{ + + void "a task should have a container"(){ + given: + def mockTask = Mock(TaskRun){ + getWorkDir() >> Path.of(".") + getContainer() >> null + getProcessor() >> Mock(TaskProcessor) + } + def mockConfig = Mock(NomadConfig) + def mockService = Mock(NomadService) + def taskHandler = new NomadTaskHandler(mockTask, mockConfig, mockService) + + when: + taskHandler.submitTask() + + then: + thrown(ProcessSubmitException) + } + + void "a task should be created"(){ + given: + def workDir = Files.createTempDirectory("nf") + new File(workDir.toFile(), TaskRun.CMD_INFILE).text = "infile" + + def mockTask = Mock(TaskRun){ + getWorkDir() >> workDir + getContainer() >> "ubuntu" + getProcessor() >> Mock(TaskProcessor){ + getExecutor() >> Mock(Executor){ + isFusionEnabled() >> false + } + } + toTaskBean() >> Mock(TaskBean){ + getWorkDir() >> workDir + getScript() >> "theScript" + getShell() >> ["bash"] + getInputFiles() >> [:] + } + } + def mockConfig = Mock(NomadConfig) + def mockService = Mock(NomadService) + def taskHandler = new NomadTaskHandler(mockTask, mockConfig, mockService) + + when: + def ret = taskHandler.submitTask() + + then: + ret == TaskStatus.SUBMITTED.name() + } +}