Repository: FNNDSC/pman

Issue:
Cromwell backend support for resources_dict
The `CromwellManager` should render `resources_dict` into the `runtime` section of the generated WDL and pass these to the backend compute environment.

Discussion:

Patch:
diff --git a/pman/config.py b/pman/config.py
index a3f9f67..06deeab 100755
--- a/pman/config.py
+++ b/pman/config.py
@@ -44,6 +44,7 @@ class Config:
 
         if self.CONTAINER_ENV == 'cromwell':
             self.CROMWELL_URL = env('CROMWELL_URL')
+            self.TIMELIMIT_MINUTES = env.int('TIMELIMIT_MINUTES')
 
         self.env = env
 
diff --git a/pman/cromwell/slurm/__init__.py b/pman/cromwell/slurm/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pman/cromwell/slurm/wdl.py b/pman/cromwell/slurm/wdl.py
new file mode 100644
index 0000000..803a972
--- /dev/null
+++ b/pman/cromwell/slurm/wdl.py
@@ -0,0 +1,177 @@
+"""
+WDL template for running a *ChRIS* plugin on SLURM.
+
+Maybe it would be nice to set a workflow name instead of just "ChrisPlugin"
+but it doesn't really matter.
+"""
+
+from typing import Optional, Tuple
+
+from serde import from_dict, deserialize
+from jinja2 import Environment
+from pman.abstractmgr import Image, Resources
+from pman.cromwell.models import StrWdl, RuntimeAttributes
+from dataclasses import dataclass
+
+
+# Some of these runtime attributes come from the convention here:
+# https://cromwell.readthedocs.io/en/stable/RuntimeAttributes/
+template = Environment().from_string(r"""
+version 1.0
+
+task plugin_instance {
+    command {
+        {{ cmd }}
+    } #ENDCOMMAND
+    runtime {
+        docker: '{{ docker }}'
+        sharedir: '{{ sharedir }}'
+        cpu: '{{ (cpu_limit / 1000)|round(method='ceil')|int }}'
+        memory: '{{ (memory_limit * 1.048576)|round(method='ceil')|int }}M'
+        gpus_per_task: '{{ gpu_limit }}'
+        number_of_workers: '{{ number_of_workers }}'
+        timelimit: '{{ timelimit }}'
+        {%- if partition %}
+        slurm_partition: '{{ partition }}'
+        {%- endif %}
+    }
+}
+
+workflow ChRISJob {
+    call plugin_instance
+}
+""")
+
+
+@dataclass
+class SlurmJob:
+    """
+    Represents a ChRIS plugin instance which runs on E2.
+
+    Conversion to WDL is lossy because CPU needs to be converted from
+    milicores to cores, and memory needs to be converted from MiB to MB.
+    """
+    image: Image
+    command: str
+    sharedir: str
+    resources_dict: Resources
+    timelimit: int
+    partition: Optional[str] = None
+    """https://slurm.schedmd.com/sbatch.html#OPT_partition"""
+
+    def to_wdl(self) -> StrWdl:
+        """
+        :return: a WDL wrapper for a *ChRIS* plugin instance
+        """
+        return StrWdl(template.render(
+            cmd=self.command, docker=self.image,
+            partition=self.partition, sharedir=self.sharedir,
+            timelimit=self.timelimit,
+            **self.resources_dict
+        ))
+
+    @classmethod
+    def from_wdl(cls, wdl: StrWdl) -> 'SlurmJob':
+        """
+        Parses a WDL created by :meth:`SlurmJob.to_wdl`. The format + whitespace
+        must be exact.
+        """
+        command, end = cls._get_between(wdl, 'command {\n', '    } #ENDCOMMAND\n', 35)
+        image, end = cls._get_resource(wdl, 'docker', end)
+        sharedir, end = cls._get_resource(wdl, 'sharedir', end)
+        cpu, end = cls._get_resource(wdl, 'cpu', end)
+        memory, end = cls._get_resource(wdl, 'memory', end)
+        gpus_per_task, end = cls._get_resource(wdl, 'gpus_per_task', end)
+        number_of_workers, end = cls._get_resource(wdl, 'number_of_workers', end)
+        timelimit, end = cls._get_resource(wdl, 'timelimit', end)
+        partition, _ = cls._find_between(wdl, "slurm_partition: '", "'\n", end)
+        r = Resources(
+            number_of_workers=int(number_of_workers),
+            cpu_limit=cls.__serialize_cpu(cpu),
+            memory_limit=cls.__serialize_mem(memory),
+            gpu_limit=int(gpus_per_task)
+        )
+        return cls(Image(image), command.strip(), sharedir, r, int(timelimit), partition)
+
+    @staticmethod
+    def __serialize_cpu(_c: str) -> int:
+        """
+        Cores to milicores
+        """
+        return int(_c) * 1000
+
+    @staticmethod
+    def __serialize_mem(_m: str) -> int:
+        """
+        MB to MiB
+        """
+        if not _m.endswith('M'):
+            raise ValueError('Memory value must end with "M"')
+        return int(int(_m[:-1]) * 0.95367432)
+
+    @classmethod
+    def _get_resource(cls, wdl: StrWdl, name: str, end: int) -> Tuple[str, int]:
+        return cls._get_between(wdl, f"{name}: '", "'\n", end)
+
+    @classmethod
+    def _get_between(cls, data: str, lookahead: str, lookbehind: str, start: int = 0) -> Tuple[str, int]:
+        value, end = cls._find_between(data, lookahead, lookbehind, start)
+        if not value:
+            raise ValueError(f'"{lookahead}" not found in: {data}')
+        return value, end
+
+    @staticmethod
+    def _find_between(data: str, lookahead: str, lookbehind: str, start: int = 0) -> Tuple[Optional[str], int]:
+        """
+        Some light parsing because miniwdl is not mini at all, and regex is ugly.
+        """
+        beginning = data.find(lookahead, start)
+        if beginning == -1:
+            return None, start
+        beginning += len(lookahead)
+        end = data.index(lookbehind, beginning)
+        return data[beginning:end], end
+
+
+@deserialize
+class SlurmRuntimeAttributes:
+    """
+    These fields are custom to how Cromwell is configured to speak with BCH E2 SLURM.
+    """
+    timelimit: int
+    """
+    Execution time limit in minutes
+
+    https://slurm.schedmd.com/sbatch.html#OPT_time
+    """
+    memory: int
+    """
+    Memory request in MB
+
+    https://slurm.schedmd.com/sbatch.html#OPT_mem
+    """
+    cpu: int
+    """
+    Number of CPUs.
+
+    https://slurm.schedmd.com/sbatch.html#OPT_cpus-per-task
+    """
+    slurm_partition: str
+    """
+    SLURM partition name
+    
+    https://slurm.schedmd.com/sbatch.html#OPT_partition
+    """
+
+    # pman-specific
+    sharedir: str
+    docker: Image
+
+    # Cromwell-specific
+    failOnStderr: bool
+    continueOnReturnCode: int
+    maxRetries: int
+
+    @classmethod
+    def deserialize(cls, _a: RuntimeAttributes) -> 'SlurmRuntimeAttributes':
+        return from_dict(cls, _a)
diff --git a/pman/cromwellmgr.py b/pman/cromwellmgr.py
index 3576dd9..6c1b792 100755
--- a/pman/cromwellmgr.py
+++ b/pman/cromwellmgr.py
@@ -1,5 +1,5 @@
 """
-TODO: another microservice to fill functionality not provided by Cromwell
+TODO: get_job_logs and remove_job
 
 - manager.get_job_logs --> return stdout
 - manager.remove_job --> remove files
@@ -17,7 +17,7 @@ from .cromwell.models import (
     WorkflowMetadataResponse
 )
 from .cromwell.client import CromwellAuth, CromwellClient
-from .e2_wdl import ChRISJob, SlurmRuntimeAttributes
+from pman.cromwell.slurm.wdl import SlurmJob, SlurmRuntimeAttributes
 
 
 STATUS_MAP = {
@@ -56,12 +56,13 @@ class CromwellManager(AbstractManager[WorkflowId]):
 
     def __init__(self, config_dict=None):
         super().__init__(config_dict)
+        self.__timelimit = config_dict['TIMELIMIT_MINUTES']
         auth = CromwellAuth(config_dict['CROMWELL_URL'])
         self.__client = CromwellClient(auth)
 
     def schedule_job(self, image: Image, command: str, name: JobName,
                      resources_dict: Resources, mountdir: Optional[str] = None) -> WorkflowId:
-        wdl = ChRISJob(image, command, mountdir, resources_dict).to_wdl()
+        wdl = SlurmJob(image, command, mountdir, resources_dict, self.__timelimit).to_wdl()
         res = self.__submit(wdl, name)
         # Submission does not appear in Cromwell immediately, but pman wants to
         # get job info, so we need to wait for Cromwell to catch up.
@@ -179,7 +180,7 @@ class CromwellManager(AbstractManager[WorkflowId]):
         """
         Get info from a workflow by parsing its submittedFiles.
         """
-        job_details = ChRISJob.from_wdl(res.submittedFiles.workflow)
+        job_details = SlurmJob.from_wdl(res.submittedFiles.workflow)
         labels = json.loads(res.submittedFiles.labels)
 
         message = 'Waiting to be picked up by Cromwell'
diff --git a/pman/e2_wdl.py b/pman/e2_wdl.py
deleted file mode 100644
index 713038a..0000000
--- a/pman/e2_wdl.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-WDL template for running a *ChRIS* plugin on the BCH *E2* SLURM.
-
-TODO pass resources_dict (requested CPU, mem, GPU, ...) into the WDL task runtime
-
-Maybe it would be nice to set a workflow name instead of just "ChrisPlugin"
-but it doesn't really matter.
-"""
-
-from typing import Optional, Tuple
-
-from serde import from_dict, deserialize
-from jinja2 import Environment
-from .abstractmgr import Image, Resources
-from pman.cromwell.models import StrWdl, RuntimeAttributes
-from dataclasses import dataclass
-
-
-template = Environment().from_string(r"""
-version 1.0
-
-task plugin_instance {
-    command {
-        {{ cmd }}
-    } #ENDCOMMAND
-    runtime {
-        docker: '{{ docker }}'
-        sharedir: '{{ sharedir }}'
-    }
-}
-
-workflow ChRISJob {
-    call plugin_instance
-}
-""")
-
-
-@dataclass
-class ChRISJob:
-    """
-    Represents a ChRIS plugin instance which runs on E2.
-    """
-    image: Image
-    command: str
-    sharedir: str
-    resources_dict: Optional[Resources] = None
-
-    def to_wdl(self) -> StrWdl:
-        """
-        :return: a WDL wrapper for a *ChRIS* plugin instance
-        """
-        return StrWdl(template.render(
-            cmd=self.command, docker=self.image, sharedir=self.sharedir
-        ))
-
-    @classmethod
-    def from_wdl(cls, wdl: StrWdl) -> 'ChRISJob':
-        command, end = cls._get_between(wdl, 'command {\n', '    } #ENDCOMMAND\n', 35)
-        image, end = cls._get_between(wdl, "docker: '", "'\n", end)
-        sharedir, _ = cls._get_between(wdl, "sharedir: '", "'\n", end)
-        return cls(Image(image), command.strip(), sharedir)
-
-    @staticmethod
-    def _get_between(data: str, lookahead: str, lookbehind: str, start: int = 0) -> Tuple[str, int]:
-        """
-        Some light parsing because miniwdl is not mini at all, and regex is ugly.
-        """
-        beginning = data.index(lookahead, start) + len(lookahead)
-        end = data.index(lookbehind, beginning)
-        return data[beginning:end], end
-
-
-@deserialize
-class SlurmRuntimeAttributes:
-    """
-    These fields are custom to how Cromwell is configured to speak with BCH E2 SLURM.
-    """
-    runtime_minutes: int
-    queue: str
-    requested_memory_mb_per_core: int
-    failOnStderr: bool
-    sharedir: str
-    continueOnReturnCode: int
-    docker: Image
-    maxRetries: int
-    cpus: int
-    account: str
-
-    @classmethod
-    def deserialize(cls, _a: RuntimeAttributes) -> 'SlurmRuntimeAttributes':
-        return from_dict(cls, _a)


Test patch:
diff --git a/tests/cromwell/examples/metadata.py b/tests/cromwell/examples/metadata.py
index d53cc29..07c297b 100644
--- a/tests/cromwell/examples/metadata.py
+++ b/tests/cromwell/examples/metadata.py
@@ -1,9 +1,23 @@
 from pman.cromwell.models import WorkflowId, WorkflowStatus
-from pman.abstractmgr import JobInfo, JobStatus, Image, TimeStamp, JobName
-from pman.e2_wdl import ChRISJob
+from pman.abstractmgr import JobInfo, JobStatus, Image, TimeStamp, JobName, Resources
+from pman.cromwell.slurm.wdl import SlurmJob
 
 workflow_uuid = WorkflowId('4165ed81-c121-4a8d-b284-a6dda9ef0aa8')
 
+job_running = SlurmJob(
+    image=Image('docker.io/fnndsc/pl-office-convert:0.0.1'),
+    command='office_convert /share/incoming /share/outgoing',
+    sharedir='/mounted/storebase/example-jid-1234',
+    timelimit=5,
+    partition='my-slurm-partition',
+    resources_dict=Resources(
+        number_of_workers=1,
+        cpu_limit=2000,
+        memory_limit=4000,
+        gpu_limit=0
+    )
+)
+
 expected_running = JobInfo(
     name=JobName('example-jid-1234'),
     image=Image('docker.io/fnndsc/pl-office-convert:0.0.1'),
@@ -26,7 +40,7 @@ response_running = r"""
   ],
   "actualWorkflowLanguageVersion": "1.0",
   "submittedFiles": {
-    "workflow": "version 1.0\n\ntask plugin_instance {\n    command {\n        office_convert /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'docker.io/fnndsc/pl-office-convert:0.0.1'\n        sharedir: '/mounted/storebase/example-jid-1234'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}\n",
+    "workflow": "\nversion 1.0\n\ntask plugin_instance {\n    command {\n        office_convert /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'docker.io/fnndsc/pl-office-convert:0.0.1'\n        sharedir: '/mounted/storebase/example-jid-1234'\n        cpu: '2'\n        memory: '4195M'\n        gpus_per_task: '0'\n        number_of_workers: '1'\n        timelimit: '5'\n        slurm_partition: 'my-slurm-partition'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}",
     "workflowType": "WDL",
     "root": "",
     "workflowTypeVersion": "1.0",
@@ -45,16 +59,16 @@ response_running = r"""
         "commandLine": "office_convert /share/incoming /share/outgoing",
         "shardIndex": -1,
         "runtimeAttributes": {
-          "runtime_minutes": "5",
-          "queue": "my-slurm-partition",
-          "requested_memory_mb_per_core": "4000",
+          "timelimit": "5",
+          "slurm_partition": "my-slurm-partition",
+          "memory": "4000M",
           "failOnStderr": "false",
           "sharedir": "/mounted/storebase/example-jid-1234",
           "continueOnReturnCode": "0",
           "docker": "docker.io/fnndsc/pl-office-convert:0.0.1",
           "maxRetries": "0",
-          "cpus": "2",
-          "account": "fnndsc"
+          "cpu": "2",
+          "number_of_workers": "1"
         },
         "callCaching": {
           "allowResultReuse": false,
@@ -132,16 +146,15 @@ response_failed = r"""
         "commandLine": "/usr/local/bin/python /usr/local/bin/office_convert  /share/incoming /share/outgoing",
         "shardIndex": -1,
         "runtimeAttributes": {
-          "runtime_minutes": "5",
-          "queue": "soloq",
-          "requested_memory_mb_per_core": "4000",
+          "timelimit": "5",
+          "slurm_partition": "soloq",
+          "memory": "4000",
           "failOnStderr": "false",
           "sharedir": "/mounted/storebase/key-wont-work",
           "continueOnReturnCode": "0",
           "docker": "ghcr.io/fnndsc/pl-office-convert:0.0.2",
           "maxRetries": "0",
-          "cpus": "2",
-          "account": "faker"
+          "cpu": "2"
         },
         "callCaching": {
           "allowResultReuse": false,
@@ -261,16 +274,15 @@ response_done = r"""
         "shardIndex": -1,
         "outputs": {},
         "runtimeAttributes": {
-          "runtime_minutes": "5",
-          "queue": "toplane",
-          "requested_memory_mb_per_core": "4000",
+          "timelimit": "5",
+          "slurm_partition": "toplane",
+          "memory": "4000",
           "failOnStderr": "false",
           "sharedir": "/mounted/storebase/key-done-and-dusted",
           "continueOnReturnCode": "0",
           "docker": "ghcr.io/fnndsc/pl-office-convert:0.0.2",
           "maxRetries": "0",
-          "cpus": "2",
-          "account": "kled"
+          "cpu": "2"
         },
         "callCaching": {
           "allowResultReuse": false,
@@ -338,16 +350,23 @@ response_done = r"""
 """
 
 
-expected_notstarted = ChRISJob(
+expected_notstarted = SlurmJob(
     image=Image('ghcr.io/fnndsc/pl-salute-the-sun:latest'),
     command='/usr/local/bin/python /usr/local/bin/whatsgood --day sunny  /share/incoming /share/outgoing',
-    sharedir='/storebase/key-vitamin-d'
+    sharedir='/storebase/key-vitamin-d',
+    timelimit=50,
+    resources_dict=Resources(
+        number_of_workers=1,
+        cpu_limit=2000,
+        memory_limit=5678,
+        gpu_limit=2
+    )
 )
 
 response_notstarted = r"""
 {
   "submittedFiles": {
-    "workflow": "\nversion 1.0\n\ntask plugin_instance {\n    command {\n        /usr/local/bin/python /usr/local/bin/whatsgood --day sunny  /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'ghcr.io/fnndsc/pl-salute-the-sun:latest'\n        sharedir: '/storebase/key-vitamin-d'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}",
+    "workflow": "\nversion 1.0\n\ntask plugin_instance {\n    command {\n        /usr/local/bin/python /usr/local/bin/whatsgood --day sunny  /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'ghcr.io/fnndsc/pl-salute-the-sun:latest'\n        sharedir: '/storebase/key-vitamin-d'\n        cpu: '2'\n        memory: '5954M'\n        gpus_per_task: '2'\n        number_of_workers: '1'\n        timelimit: '50'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}",
     "root": "",
     "options": "{\n\n}",
     "inputs": "{}",
@@ -367,10 +386,17 @@ response_notstarted = r"""
 }
 """
 
-expected_queued = ChRISJob(
+expected_queued = SlurmJob(
     image=Image('internal.gitlab:5678/fnndsc/pl-fruit:1.2.3'),
     command='/usr/local/bin/python /usr/local/bin/fruit_machine --salad orange  /share/incoming /share/outgoing',
-    sharedir='/storebase/key-fruity-fruit'
+    sharedir='/storebase/key-fruity-fruit',
+    timelimit=70,
+    resources_dict=Resources(
+        number_of_workers=3,
+        cpu_limit=1000,
+        memory_limit=789,
+        gpu_limit=0
+    )
 )
 
 response_queued = r"""
@@ -386,7 +412,7 @@ response_queued = r"""
   ],
   "actualWorkflowLanguageVersion": "1.0",
   "submittedFiles": {
-    "workflow": "\nversion 1.0\n\ntask plugin_instance {\n    command {\n        /usr/local/bin/python /usr/local/bin/fruit_machine --salad orange  /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'internal.gitlab:5678/fnndsc/pl-fruit:1.2.3'\n        sharedir: '/storebase/key-fruity-fruit'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}",
+    "workflow": "\nversion 1.0\n\ntask plugin_instance {\n    command {\n        /usr/local/bin/python /usr/local/bin/fruit_machine --salad orange  /share/incoming /share/outgoing\n    } #ENDCOMMAND\n    runtime {\n        docker: 'internal.gitlab:5678/fnndsc/pl-fruit:1.2.3'\n        sharedir: '/storebase/key-fruity-fruit'\n        cpu: '1'\n        memory: '828M'\n        gpus_per_task: '0'\n        number_of_workers: '3'\n        timelimit: '70'\n    }\n}\n\nworkflow ChRISJob {\n    call plugin_instance\n}",
     "root": "",
     "options": "{\n\n}",
     "inputs": "{}",
diff --git a/tests/cromwell/examples/wdl.py b/tests/cromwell/examples/wdl.py
new file mode 100644
index 0000000..9768a31
--- /dev/null
+++ b/tests/cromwell/examples/wdl.py
@@ -0,0 +1,110 @@
+from collections import namedtuple
+from pman.abstractmgr import Resources
+from pman.cromwell.slurm.wdl import SlurmJob, Image, StrWdl
+
+# Since conversion to WDL is lossy, we need to define the
+# expected WDL, actual source info, and WDL-converted info (lossy_info)
+Example = namedtuple('Example', ['wdl', 'info', 'lossy_info'])
+
+basic = Example(
+    wdl=StrWdl(r"""
+version 1.0
+
+task plugin_instance {
+    command {
+        whatsup /share/mr /share/president
+    } #ENDCOMMAND
+    runtime {
+        docker: 'quay.io/fedora/fedora:36'
+        sharedir: '/location/of/bribe'
+        cpu: '2'
+        memory: '5954M'
+        gpus_per_task: '0'
+        number_of_workers: '9'
+        timelimit: '12'
+    }
+}
+
+workflow ChRISJob {
+    call plugin_instance
+}
+"""),
+    info=SlurmJob(
+        command='whatsup /share/mr /share/president',
+        image=Image('quay.io/fedora/fedora:36'),
+        sharedir='/location/of/bribe',
+        partition=None,
+        timelimit=12,
+        resources_dict=Resources(
+            cpu_limit=1234,
+            memory_limit=5678,
+            number_of_workers=9,
+            gpu_limit=0
+        )
+    ),
+    lossy_info=SlurmJob(
+        command='whatsup /share/mr /share/president',
+        image=Image('quay.io/fedora/fedora:36'),
+        sharedir='/location/of/bribe',
+        partition=None,
+        timelimit=12,
+        resources_dict=Resources(
+            cpu_limit=2000,
+            memory_limit=5678,
+            number_of_workers=9,
+            gpu_limit=0
+        )
+    )
+)
+
+fastsurfer = Example(
+    wdl=StrWdl(r"""
+version 1.0
+
+task plugin_instance {
+    command {
+        /usr/local/bin/python fastsurfer_inference.py /share/incoming /share/outgoing
+    } #ENDCOMMAND
+    runtime {
+        docker: 'ghcr.io/fnndsc/pl-fastsurfer_inference:1.2.0'
+        sharedir: '/neuroimaging/data'
+        cpu: '7'
+        memory: '10356M'
+        gpus_per_task: '6'
+        number_of_workers: '5'
+        timelimit: '300'
+        slurm_partition: 'has-gpu'
+    }
+}
+
+workflow ChRISJob {
+    call plugin_instance
+}
+"""),
+    info=SlurmJob(
+        command='/usr/local/bin/python fastsurfer_inference.py /share/incoming /share/outgoing',
+        image=Image('ghcr.io/fnndsc/pl-fastsurfer_inference:1.2.0'),
+        sharedir='/neuroimaging/data',
+        partition='has-gpu',
+        timelimit=300,
+        resources_dict=Resources(
+            number_of_workers=5,
+            cpu_limit=7000,
+            memory_limit=9876,
+            gpu_limit=6
+        )
+    ),
+    lossy_info=SlurmJob(
+        command='/usr/local/bin/python fastsurfer_inference.py /share/incoming /share/outgoing',
+        image=Image('ghcr.io/fnndsc/pl-fastsurfer_inference:1.2.0'),
+        sharedir='/neuroimaging/data',
+        partition='has-gpu',
+        timelimit=300,
+        resources_dict=Resources(
+            number_of_workers=5,
+            cpu_limit=7000,
+            memory_limit=9876,
+            gpu_limit=6
+        )
+    )
+)
diff --git a/tests/cromwell/test_cromwellmgr.py b/tests/cromwell/test_cromwellmgr.py
index 062d6e7..20b552f 100644
--- a/tests/cromwell/test_cromwellmgr.py
+++ b/tests/cromwell/test_cromwellmgr.py
@@ -13,10 +13,15 @@ class CromwellTestCase(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls) -> None:
-        cls.manager = CromwellManager({'CROMWELL_URL': 'https://example.com/'})
+        cls.manager = CromwellManager({
+            'CROMWELL_URL': 'https://example.com/',
+            'TIMELIMIT_MINUTES': 30
+        })
+
+        cls.example_resources = metadata_example.job_running.resources_dict
 
     @patch('time.sleep')
-    @patch('pman.e2_wdl.ChRISJob.to_wdl')
+    @patch('pman.cromwell.slurm.wdl.SlurmJob.to_wdl')
     @patch('cromwell_tools.cromwell_api.CromwellAPI.metadata')
     @patch('cromwell_tools.cromwell_api.CromwellAPI.submit')
     def test_submit(self, mock_submit: Mock, mock_metadata: Mock,
@@ -43,7 +48,9 @@ class CromwellTestCase(unittest.TestCase):
 
         self.manager.schedule_job(
             Image('fnndsc/pl-simpledsapp'), 'simpledsapp /in /out',
-            JobName('example-jid-4567'), {}, '/storeBase/whatever'
+            JobName('example-jid-4567'),
+            self.example_resources,
+            '/storeBase/whatever'
         )
 
         # assert submitted with correct data
@@ -76,7 +83,8 @@ class CromwellTestCase(unittest.TestCase):
         with self.assertRaises(CromwellException):
             self.manager.schedule_job(
                 Image('fnndsc/pl-simpledsapp'), 'simpledsapp /in /out',
-                JobName('example-jid-4567'), {}, '/storeBase/whatever'
+                JobName('example-jid-4567'), self.example_resources,
+                '/storeBase/whatever'
             )
 
     @patch_cromwell_api('metadata', metadata_example.response_running)
diff --git a/tests/cromwell/test_wdl.py b/tests/cromwell/test_wdl.py
index b75ad00..0b16489 100644
--- a/tests/cromwell/test_wdl.py
+++ b/tests/cromwell/test_wdl.py
@@ -1,7 +1,8 @@
 import unittest
-from pman.e2_wdl import ChRISJob
+from pman.cromwell.slurm.wdl import SlurmJob
 from pman.cromwell.models import WorkflowMetadataResponse
-import tests.cromwell.examples.metadata as examples
+import tests.cromwell.examples.metadata as mexamples
+import tests.cromwell.examples.wdl as wexamples
 from serde.json import from_json
 
 
@@ -9,12 +10,30 @@ class WdlTestCase(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls) -> None:
-        cls.example1: WorkflowMetadataResponse = from_json(WorkflowMetadataResponse, examples.response_notstarted)
-        cls.example2: WorkflowMetadataResponse = from_json(WorkflowMetadataResponse, examples.response_queued)
+        cls.example1: WorkflowMetadataResponse = from_json(WorkflowMetadataResponse, mexamples.response_notstarted)
+        cls.example2: WorkflowMetadataResponse = from_json(WorkflowMetadataResponse, mexamples.response_queued)
 
-    def test_parse_wdl(self):
-        self.assertEqual(examples.expected_notstarted, ChRISJob.from_wdl(self.example1.submittedFiles.workflow))
-        self.assertEqual(examples.expected_queued, ChRISJob.from_wdl(self.example2.submittedFiles.workflow))
+    def test_find_between_helper(self):
+        self.assertEqual(
+            (' three ', 14),
+            SlurmJob._find_between('one two three four five', 'two', 'four')
+        )
+        self.assertEqual(
+            (None, 16),
+            SlurmJob._find_between('one two three four five', 'two', 'four', 16)
+        )
+
+    def test_render(self):
+        self.assertEqual(wexamples.basic.wdl.strip(), wexamples.basic.info.to_wdl().strip())
+        self.assertEqual(wexamples.fastsurfer.wdl.strip(), wexamples.fastsurfer.info.to_wdl().strip())
+
+    def test_from_wdl1(self):
+        self.assertEqual(wexamples.basic.lossy_info, SlurmJob.from_wdl(wexamples.basic.wdl))
+        self.assertEqual(wexamples.fastsurfer.lossy_info, SlurmJob.from_wdl(wexamples.fastsurfer.wdl))
+
+    def test_from_wdl2(self):
+        self.assertEqual(mexamples.expected_notstarted, SlurmJob.from_wdl(self.example1.submittedFiles.workflow))
+        self.assertEqual(mexamples.expected_queued, SlurmJob.from_wdl(self.example2.submittedFiles.workflow))
 
 
 if __name__ == '__main__':