• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

chanzuckerberg / miniwdl / 27864182280

20 Jun 2026 07:24AM UTC coverage: 95.838% (+0.003%) from 95.835%
27864182280

Pull #890

github

web-flow
Merge bf229735f into 846ff3e20
Pull Request #890: [WDL 1.2.1] make task.return_code available in output section only

15 of 15 new or added lines in 4 files covered. (100.0%)

53 existing lines in 3 files now uncovered.

8727 of 9106 relevant lines covered (95.84%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.03
/WDL/runtime/task_container.py
1
"""
2
Abstract interface for task container runtime
3
"""
4

5
import os
1✔
6
import logging
1✔
7
import shutil
1✔
8
import threading
1✔
9
import typing
1✔
10
import math
1✔
11
from typing import (
1✔
12
    Callable,
13
    Iterable,
14
    Any,
15
    Dict,
16
    Optional,
17
    ContextManager,
18
    Set,
19
    Tuple,
20
    TYPE_CHECKING,
21
)
22
from abc import ABC, abstractmethod
1✔
23
from contextlib import suppress
1✔
24
from .. import Error, Value, Type, Expr
1✔
25
from .._util import (
1✔
26
    TerminationSignalFlag,
27
    path_really_within,
28
    rmtree_atomic,
29
    PygtailLogger,
30
    parse_byte_size,
31
)
32
from .._util import StructuredLogMessage as _
1✔
33
from . import config, _statusbar
1✔
34
from .error import OutputError, Terminated, CommandFailed
1✔
35

36
if TYPE_CHECKING:
1✔
37
    from .. import Tree
×
38

39

40
class TaskContainer(ABC):
1✔
41
    """
42
    Base class for task containers, subclassed by runtime-specific backends (e.g. Docker).
43
    """
44

45
    # class stuff
46

47
    @classmethod
1✔
48
    def global_init(cls, cfg: config.Loader, logger: logging.Logger) -> None:
1✔
49
        """
50
        Perform any necessary one-time initialization of the underlying container backend. To be
51
        invoked once per process prior to any instantiation of the class.
52
        """
53
        raise NotImplementedError()
×
54

55
    @classmethod
1✔
56
    def detect_resource_limits(cls, cfg: config.Loader, logger: logging.Logger) -> Dict[str, int]:
1✔
57
        """
58
        Detect the maximum resources ("cpu" and "mem_bytes") that the underlying container backend
59
        will be able to provision for any one task.
60

61
        If determining this is at all costly, then backend should memoize (thread-safely and
62
        perhaps front-loaded in global_init).
63
        """
64
        raise NotImplementedError()
×
65

66
    # instance stuff
67

68
    run_id: str
1✔
69

70
    host_dir: str
1✔
71
    """
×
72
    :type: str
73

74
    The run directory (on the host)
75
    """
76

77
    container_dir: str
1✔
78
    """
×
79
    :type: str
80

81
    The scratch directory inside the container. The task command's working directory will be
82
    ``{container_dir}/work/``.
83
    """
84

85
    input_path_map: Dict[str, str]
1✔
86
    """
×
87
    :type: Dict[str,str]
88

89
    A mapping of host input file/directory paths to in-container mounted paths, maintained by
90
    ``add_paths``. Directory paths are distinguished by trailing slashes on both keys and values;
91
    the slashes often should be trimmed for use elsewhere.
92
    """
93

94
    input_path_map_rev: Dict[str, str]
1✔
95
    """
×
96
    Inverse of ``input_path_map`` (also maintained by ``add_paths``)
97
    """
98

99
    last_exit_code: Optional[int]
1✔
100
    """
×
101
    Exit code from the most recent task command attempt, once known.
102
    """
103

104
    try_counter: int
1✔
105
    """
×
106
    :type: int
107

108
    Counter for number of retries; starts at 1 on the first attempt. On subsequent attempts, the
109
    names (on the host) of the working directory, stdout.txt, and stderr.txt may incorporate the
110
    count, to ensure their uniqueness.
111
    """
112

113
    runtime_values: Dict[str, Any]
1✔
114
    """
×
115
    Evaluted task runtime{} section, to be populated by process_runtime(). Typically the
116
    TaskContainer backend needs to honor cpu, memory_limit, memory_reservation, docker, env.
117
    Retry logic (maxRetries, preemptible) is handled externally.
118
    """
119

120
    task_runtime_info_struct: Optional[Value.Struct]
1✔
121
    """
×
122
    WDL 1.2 task-scoped runtime info struct (task.*), to be populated before command evaluation.
123
    The core task runner invokes build_task_runtime_info_struct() to initialize it from task
124
    metadata and effective runtime_values.
125
    The core task runner replaces it with checked copies when attempt/return_code become known.
126
    """
127

128
    stderr_callback: Optional[Callable[[str], None]]
1✔
129
    """
×
130
    A function called line-by-line for the task's standard error stream, iff verbose logging is
131
    enabled. If provided by a plugin then it overrides the default standard error logging, which
132
    writes each line to the 'stderr' child of the task logger.
133
    """
134

135
    failure_info: Optional[Dict[str, Any]]
1✔
136
    """
×
137
    Upon run failure, the implementation may provide additional structured information about what
138
    went wrong (beyond the exit code and log messages).
139
    """
140

141
    _running: bool
1✔
142

143
    def __init__(self, cfg: config.Loader, run_id: str, host_dir: str) -> None:
1✔
144
        self.cfg = cfg
1✔
145
        self.run_id = run_id
1✔
146
        self.host_dir = host_dir
1✔
147
        self.container_dir = "/mnt/miniwdl_task_container"
1✔
148
        self.input_path_map = {}
1✔
149
        self.input_path_map_rev = {}
1✔
150
        self.stderr_callback = None
1✔
151
        self.try_counter = 1
1✔
152
        self._running = False
1✔
153
        self.runtime_values = {}
1✔
154
        self.task_runtime_info_struct = None
1✔
155
        self.failure_info = None
1✔
156
        self.last_exit_code = None
1✔
157
        os.makedirs(self.host_work_dir())
1✔
158

159
    def add_paths(self, host_paths: Iterable[str]) -> None:
1✔
160
        """
161
        Use before running the container to add a list of host paths to mount inside the container
162
        as inputs. Directory paths should have a trailing slash. The host-to-container path mapping
163
        is maintained in ``input_path_map``.
164

165
        Although ``add_paths`` can be used multiple times, paths should be added together where
166
        possible, as this allows heuristics for dealing with any name collisions among them.
167
        """
168
        assert not self._running
1✔
169

170
        # partition the files by host directory
171
        host_paths_by_dir: Dict[str, Set[str]] = {}
1✔
172
        for host_path in host_paths:
1✔
173
            host_path_strip = host_path.rstrip("/")
1✔
174
            if host_path not in self.input_path_map and host_path_strip not in self.input_path_map:
1✔
175
                if not os.path.exists(host_path_strip):
1✔
176
                    raise Error.InputError("input path not found: " + host_path)
1✔
177
                host_paths_by_dir.setdefault(os.path.dirname(host_path_strip), set()).add(host_path)
1✔
178

179
        # for each such partition of files
180
        # - if there are no basename collisions under input subdirectory 0, then mount them there.
181
        # - otherwise, mount them in a fresh subdirectory
182
        for paths in host_paths_by_dir.values():
1✔
183
            based = os.path.join(self.container_dir, "work/_miniwdl_inputs")
1✔
184
            subd = "0"
1✔
185
            for host_path in paths:
1✔
186
                container_path = os.path.join(based, subd, os.path.basename(host_path.rstrip("/")))
1✔
187
                if host_path.endswith("/"):
1✔
188
                    container_path += "/"
1✔
189
                if container_path in self.input_path_map_rev:
1✔
190
                    assert subd == "0"
1✔
191
                    subd = str(len(self.input_path_map) + 1)
1✔
192
            for host_path in paths:
1✔
193
                container_path = os.path.join(based, subd, os.path.basename(host_path.rstrip("/")))
1✔
194
                if host_path.endswith("/"):
1✔
195
                    container_path += "/"
1✔
196
                assert container_path not in self.input_path_map_rev
1✔
197
                self.input_path_map[host_path] = container_path
1✔
198
                self.input_path_map_rev[container_path] = host_path
1✔
199

200
    def copy_input_files(self, logger: logging.Logger) -> None:
1✔
201
        # After add_paths has been used as needed, copy the input files from their original
202
        # locations to the appropriate subdirectories of the container working directory. This may
203
        # not be necessary e.g. if the container backend supports bind-mounting the input
204
        # files from their original host paths.
205
        # called once per task run (attempt)
206
        for host_path, container_path in self.input_path_map.items():
1✔
207
            assert container_path.startswith(self.container_dir)
1✔
208
            host_copy_path = os.path.join(
1✔
209
                self.host_dir, os.path.relpath(container_path.rstrip("/"), self.container_dir)
210
            )
211

212
            logger.info(_("copy host input file", input=host_path, copy=host_copy_path))
1✔
213
            os.makedirs(os.path.dirname(host_copy_path), exist_ok=True)
1✔
214
            if host_path.endswith("/"):
1✔
215
                shutil.copytree(host_path.rstrip("/"), host_copy_path, symlinks=False)
1✔
216
            else:
217
                shutil.copy(host_path, host_copy_path)
1✔
218

219
    def process_runtime(self, logger: logging.Logger, runtime_eval: Dict[str, Value.Base]) -> None:
1✔
220
        """
221
        Given the evaluated WDL expressions from the task runtime{} section, populate
222
        self.runtime_values with validated/postprocessed values that will be needed to configure
223
        the container properly.
224

225
        Subclasses may override this to process custom runtime entries (before or after invoking
226
        this base version).
227
        """
228

229
        ans = self.runtime_values
1✔
230

231
        if "inlineDockerfile" in runtime_eval:
1✔
232
            # join Array[String]
233
            dockerfile = runtime_eval["inlineDockerfile"]
1✔
234
            if not isinstance(dockerfile, Value.Array):
1✔
235
                dockerfile = Value.Array(dockerfile.type, [dockerfile])
1✔
236
            ans["inlineDockerfile"] = "\n".join(
1✔
237
                elt.coerce(Type.String()).value for elt in dockerfile.value
238
            )
239
        elif "docker" in runtime_eval or "container" in runtime_eval:
1✔
240
            docker_value = runtime_eval["container" if "container" in runtime_eval else "docker"]
1✔
241
            if isinstance(docker_value, Value.Array) and len(docker_value.value):
1✔
242
                # TODO: choose a preferred candidate
243
                docker_value = docker_value.value[0]
1✔
244
            ans["docker"] = docker_value.coerce(Type.String()).value
1✔
245
        if "docker_network" in runtime_eval:
1✔
246
            network_value = runtime_eval["docker_network"]
1✔
247
            ans["docker_network"] = network_value.coerce(Type.String()).value
1✔
248

249
        if (
1✔
250
            isinstance(runtime_eval.get("privileged", None), Value.Boolean)
251
            and runtime_eval["privileged"].value is True
252
        ):
253
            if self.cfg.get_bool("task_runtime", "allow_privileged"):
1✔
254
                ans["privileged"] = True
1✔
255
            else:
256
                logger.warning(
1✔
257
                    "runtime.privileged ignored; to enable, set configuration"
258
                    " [task_runtime] allow_privileged = true (security+portability warning)"
259
                )
260

261
        host_limits = self.detect_resource_limits(self.cfg, logger)
1✔
262
        if "cpu" in runtime_eval:
1✔
263
            cpu_value = math.ceil(runtime_eval["cpu"].coerce(Type.Float()).value)
1✔
264
            cpu_max = self.cfg["task_runtime"].get_int("cpu_max")
1✔
265
            if cpu_max == 0:
1✔
266
                cpu_max = host_limits["cpu"]
1✔
267
            cpu = max(1, cpu_value if cpu_value <= cpu_max or cpu_max < 0 else cpu_max)
1✔
268
            if cpu != cpu_value:
1✔
269
                logger.warning(
1✔
270
                    _("runtime.cpu adjusted to host limit", original=cpu_value, adjusted=cpu)
271
                )
272
            ans["cpu"] = cpu
1✔
273

274
        if "memory" in runtime_eval:
1✔
275
            memory_str = runtime_eval["memory"].coerce(Type.String()).value
1✔
276
            assert isinstance(memory_str, str)
1✔
277
            try:
1✔
278
                memory_bytes = parse_byte_size(memory_str)
1✔
279
            except ValueError:
1✔
280
                raise Error.RuntimeError("invalid setting of runtime.memory, " + memory_str)
1✔
281

282
            memory_max_str = self.cfg["task_runtime"]["memory_max"].strip()
1✔
283
            memory_max = -1 if memory_max_str == "-1" else parse_byte_size(memory_max_str)
1✔
284
            if memory_max == 0:
1✔
285
                memory_max = host_limits["mem_bytes"]
1✔
286
            if memory_max > 0 and memory_bytes > memory_max:
1✔
287
                logger.warning(
1✔
288
                    _(
289
                        "runtime.memory adjusted to host limit",
290
                        original=memory_bytes,
291
                        adjusted=memory_max,
292
                    )
293
                )
294
                memory_bytes = memory_max
1✔
295
            ans["memory_reservation"] = memory_bytes
1✔
296

297
            memory_limit_multiplier = self.cfg["task_runtime"].get_float("memory_limit_multiplier")
1✔
298
            if memory_limit_multiplier > 0.0:
1✔
299
                ans["memory_limit"] = int(memory_limit_multiplier * memory_bytes)
1✔
300

301
        if "maxRetries" in runtime_eval:
1✔
302
            ans["maxRetries"] = max(0, runtime_eval["maxRetries"].coerce(Type.Int()).value)
1✔
303
        if "preemptible" in runtime_eval:
1✔
304
            ans["preemptible"] = max(0, runtime_eval["preemptible"].coerce(Type.Int()).value)
1✔
305
        if "returnCodes" in runtime_eval:
1✔
306
            rcv = runtime_eval["returnCodes"]
1✔
307
            if isinstance(rcv, Value.String) and rcv.value == "*":
1✔
308
                ans["returnCodes"] = "*"
1✔
309
            elif isinstance(rcv, Value.Int):
1✔
310
                ans["returnCodes"] = rcv.value
1✔
311
            elif isinstance(rcv, Value.Array):
1✔
312
                try:
1✔
313
                    ans["returnCodes"] = [v.coerce(Type.Int()).value for v in rcv.value]
1✔
314
                except Exception:
×
315
                    pass
×
316
            if "returnCodes" not in ans:
1✔
317
                raise Error.RuntimeError("invalid setting of runtime.returnCodes")
×
318

319
        if "gpu" in runtime_eval:
1✔
320
            if not isinstance(runtime_eval["gpu"], Value.Boolean):
1✔
321
                raise Error.RuntimeError("invalid setting of runtime.gpu")
×
322
            ans["gpu"] = runtime_eval["gpu"].value
1✔
323

324
    def build_task_runtime_info_struct(
1✔
325
        self, logger: logging.Logger, run_id: str, task: "Tree.Task"
326
    ) -> None:
327
        """
328
        Populate self.task_runtime_info_struct from task metadata, resource limits, and
329
        runtime_values. Subclasses may override this to add/override backend-specific task runtime
330
        information after invoking this base version. Must leave self.task_runtime_info_struct
331
        matching task.task_runtime_info_struct_type().
332

333
        The core task runner updates ``attempt`` before each attempt and ``return_code`` after the
334
        successful command attempt.
335
        """
336
        task_type = task.task_runtime_info_struct_type()
1✔
337
        host_limits = self.detect_resource_limits(self.cfg, logger)
1✔
338

339
        if "cpu" in self.runtime_values:
1✔
340
            cpu_value = float(self.runtime_values["cpu"])
1✔
341
        else:
342
            cpu_value = float(max(1, host_limits.get("cpu", 1)))
1✔
343

344
        if "memory_reservation" in self.runtime_values:
1✔
345
            memory_value = int(self.runtime_values["memory_reservation"])
1✔
346
        else:
347
            memory_value = int(max(1, host_limits.get("mem_bytes", 1)))
1✔
348

349
        container_value = None
1✔
350
        if "docker" in self.runtime_values:
1✔
351
            container_value = self.runtime_values["docker"]
1✔
352
        # NOTE: spec says to fall back to requested/default if actual not available; we're using
353
        # host limits for cpu/memory and None for container when missing.
354

355
        task_info = {
1✔
356
            "name": task.name,
357
            "id": run_id,
358
            "container": container_value,
359
            "cpu": cpu_value,
360
            "memory": memory_value,
361
            "gpu": [],
362
            "fpga": [],
363
            "disks": {},
364
            # TODO: populate gpu/fpga/disks from actual/requested runtime info when available.
365
            "attempt": max(0, self.try_counter - 1),
366
            # NOTE: end_time is always None; spec distinguishes None vs 0 vs positive deadline.
367
            "end_time": None,
368
            "meta": Expr._meta_value_to_json(task.meta),
369
            "parameter_meta": Expr._meta_value_to_json(task.parameter_meta),
370
        }
371
        task_value = Value.from_json(task_type, task_info)
1✔
372
        assert isinstance(task_value, Value.Struct)
1✔
373
        self.task_runtime_info_struct = task_value
1✔
374
        self.check_task_runtime_info_struct()
1✔
375

376
    def check_task_runtime_info_struct(self) -> None:
1✔
377
        """
378
        Assert task_runtime_info_struct member values match its struct type.
379
        """
380
        assert self.task_runtime_info_struct is not None
1✔
381
        task_value = self.task_runtime_info_struct
1✔
382
        assert isinstance(task_value.type, Type.StructInstance)
1✔
383
        task_type = task_value.type
1✔
384
        assert task_type.members is not None
1✔
385
        assert not task_value.extra
1✔
386
        assert set(task_value.value) == set(task_type.members)
1✔
387
        for key, value in task_value.value.items():
1✔
388
            member_type = task_type.members[key]
1✔
389
            if isinstance(value, Value.Null):
1✔
390
                assert member_type.optional
1✔
391
            else:
392
                value.type.check(member_type.copy(optional=False))
1✔
393

394
    def update_task_runtime_info_struct(
1✔
395
        self, task_type: Optional[Type.StructInstance] = None, **updates: Value.Base
396
    ) -> None:
397
        """
398
        Replace the task-scoped runtime info struct with a checked copy containing updates.
399
        """
400
        assert self.task_runtime_info_struct is not None
1✔
401
        task_value = self.task_runtime_info_struct
1✔
402
        assert isinstance(task_value.type, Type.StructInstance)
1✔
403
        task_type = task_type or task_value.type
1✔
404
        values = dict(task_value.value)
1✔
405
        values.update(updates)
1✔
406
        self.task_runtime_info_struct = Value.Struct(
1✔
407
            task_type,
408
            values,
409
            extra=set(task_value.extra),
410
        )
411
        self.check_task_runtime_info_struct()
1✔
412

413
    def run(self, logger: logging.Logger, command: str) -> None:
1✔
414
        """
415
        1. Container is instantiated with the configured mounts and resources
416
        2. The mounted directory and all subdirectories have u+rwx,g+rwx permission bits; all files
417
           within have u+rw,g+rw permission bits.
418
        3. Command is executed in host_work_dir() which is mounted to {container_dir}/work inside
419
           the container.
420
        4. Standard output is written to host_stdout_txt()
421
        5. Standard error is written to host_stderr_txt() and logged at VERBOSE level
422
        6. Raises CommandFailed for nonzero exit code
423
        7. Raises Terminated if TerminationSignalFlag detected, or Interrupted if the backend
424
           cancels on us for some reason that isn't our fault.
425

426
        The container is torn down in any case, including SIGTERM/SIGHUP signal which is trapped.
427
        """
428
        # container-specific logic should be in _run(). this wrapper traps signals
429

430
        assert not self._running
1✔
431
        self.last_exit_code = None
1✔
432
        if command.strip():  # if the command is empty then don't bother with any of this
1✔
433
            preamble = self.cfg.get("task_runtime", "command_preamble")
1✔
434
            if preamble.strip():
1✔
UNCOV
435
                command = preamble + "\n" + command
×
436
            with TerminationSignalFlag(logger) as terminating:
1✔
437
                if terminating():
1✔
UNCOV
438
                    raise Terminated(quiet=True)
×
439
                self._running = True
1✔
440
                try:
1✔
441
                    exit_code = self._run(logger, terminating, command)
1✔
442
                    self.last_exit_code = exit_code
1✔
443
                finally:
444
                    self._running = False
1✔
445

446
                if not self.success_exit_code(exit_code):
1✔
447
                    raise (
1✔
448
                        CommandFailed(
449
                            exit_code,
450
                            self.host_stderr_txt(),
451
                            self.host_stdout_txt(),
452
                            more_info=self.failure_info,
453
                        )
454
                        if not terminating()
455
                        else Terminated()
456
                    )
457
        else:
458
            self.last_exit_code = 0
1✔
459

460
    @abstractmethod
1✔
461
    def _run(self, logger: logging.Logger, terminating: Callable[[], bool], command: str) -> int:
1✔
462
        """
463
        Implementation-specific: run command in container & return exit status.
464

465
        Take care to write informative log messages for any backend-specific errors. Miniwdl's
466
        outer exception handler will only emit a brief, generic log message about the run failing.
467
        """
468
        # run command in container & return exit status
UNCOV
469
        raise NotImplementedError()
×
470

471
    def success_exit_code(self, exit_code: int) -> bool:
1✔
472
        if "returnCodes" not in self.runtime_values:
1✔
473
            return exit_code == 0
1✔
474
        rcv = self.runtime_values["returnCodes"]
1✔
475
        if isinstance(rcv, str) and rcv == "*":
1✔
476
            return True
1✔
477
        return exit_code in (rcv if isinstance(rcv, list) else [rcv])
1✔
478

479
    def delete_work(self, logger: logging.Logger, delete_streams: bool = False) -> None:
1✔
480
        """
481
        After the container exits, delete all filesystem traces of it except for task.log. That
482
        includes successful output files!
483

484
        delete_streams: if True, delete stdout.txt and stderr.txt as well
485
        """
486
        to_delete = [self.host_work_dir(), os.path.join(self.host_dir, "write_")]
1✔
487
        to_delete.append(os.path.join(self.host_dir, "command"))
1✔
488
        if delete_streams:
1✔
489
            to_delete.append(self.host_stdout_txt())
1✔
490
            to_delete.append(self.host_stderr_txt())
1✔
491
            to_delete.append(self.host_stderr_txt() + ".offset")
1✔
492
        deleted = []
1✔
493
        for p in to_delete:
1✔
494
            if os.path.isdir(p):
1✔
495
                rmtree_atomic(p)
1✔
496
                deleted.append(p)
1✔
497
            elif os.path.isfile(p):
1✔
498
                with suppress(FileNotFoundError):
1✔
499
                    os.unlink(p)
1✔
500
                deleted.append(p)
1✔
501
        if deleted:
1✔
502
            logger.info(_("deleted task work artifacts", artifacts=deleted))
1✔
503

504
    def reset(self, logger: logging.Logger) -> None:
1✔
505
        """
506
        After a container/command failure, reset the working directory state so that
507
        copy_input_files() and run() can be retried.
508
        """
509
        self.try_counter += 1
1✔
510
        os.makedirs(self.host_work_dir())
1✔
511

512
    def host_path(self, container_path: str, inputs_only: bool = False) -> Optional[str]:
1✔
513
        """
514
        Map the in-container path of an output File/Directory to a host path under ``host_dir``.
515
        Directory paths should be given a trailing "/". Return None if the path does not exist.
516

517
        SECURITY: except for inputs, this method must only return host paths under ``host_dir``
518
        and prevent any reference to other host files (e.g. /etc/passwd), including via symlinks.
519
        """
520
        if os.path.isabs(container_path):
1✔
521
            # handle output of std{out,err}.txt
522
            if container_path == os.path.join(self.container_dir, "stdout.txt"):
1✔
523
                return self.host_stdout_txt()
1✔
524
            if container_path == os.path.join(self.container_dir, "stderr.txt"):
1✔
525
                return self.host_stderr_txt()
1✔
526
            # handle output of an input File or Directory
527
            # or a File/subDirectory found within an input Directory
528
            found_input, input_host_path = self._input_host_path(container_path)
1✔
529
            if found_input:
1✔
530
                isdir = container_path.endswith("/")
1✔
531
                if input_host_path is None or not (
1✔
532
                    (isdir and os.path.isdir(input_host_path))
533
                    or (not isdir and os.path.isfile(input_host_path))
534
                ):
UNCOV
535
                    return None
×
536
                return input_host_path
1✔
537
            if inputs_only:
1✔
538
                raise Error.InputError(
1✔
539
                    "task inputs attempted to use a non-input or non-existent path "
540
                    + container_path
541
                )
542
            # relativize the path to the provisioned working directory
543
            container_relpath = os.path.relpath(
1✔
544
                container_path, os.path.join(self.container_dir, "work")
545
            )
546
            if container_path.endswith("/") and not container_relpath.endswith("/"):
1✔
547
                container_relpath += "/"
1✔
548
            if container_relpath.startswith("../"):
1✔
549
                # see issue #214
550
                raise OutputError(
1✔
551
                    "task outputs attempted to use a path outside its working directory: "
552
                    + container_path
553
                )
554
            container_path = container_relpath
1✔
555

556
        ans = os.path.join(self.host_work_dir(), container_path)
1✔
557
        if container_path.endswith("/") and not ans.endswith("/"):
1✔
UNCOV
558
            ans += "/"
×
559
        if not (
1✔
560
            (container_path.endswith("/") and os.path.isdir(ans))
561
            or (not container_path.endswith("/") and os.path.isfile(ans))
562
        ):
563
            return None
1✔
564
        if not path_really_within(ans, self.host_work_dir()):
1✔
565
            # fail-safe guard against some weird symlink to host file
566
            raise OutputError(
1✔
567
                "task outputs attempted to use a path outside its working directory: "
568
                + container_path
569
            )
570
        if (
1✔
571
            ans.endswith("/")
572
            and self.input_path_map
573
            and (
574
                path_really_within(self.host_work_dir(), ans[:-1])
575
                or path_really_within(
576
                    ans[:-1], os.path.join(self.host_work_dir(), "_miniwdl_inputs")
577
                )
578
            )
579
        ):
580
            # prevent output of an input mount point
581
            raise OutputError("unusable output directory: " + container_path)
1✔
582
        return ans
1✔
583

584
    def _input_host_path(self, container_path: str) -> Tuple[bool, Optional[str]]:
1✔
585
        """
586
        Map an in-container path to its host input path, if it is an input or input Directory child.
587

588
        Return ``(False, None)`` when the path is outside input mounts. The mapped host path may be
589
        missing; callers decide whether that is allowed.
590
        """
591
        if not os.path.isabs(container_path):
1✔
592
            return False, None
1✔
593

594
        if container_path in self.input_path_map_rev:
1✔
595
            return True, self.input_path_map_rev[container_path]
1✔
596

597
        container_path_components = container_path.strip("/").split("/")
1✔
598
        for i in range(len(container_path_components) - 1, 5, -1):
1✔
599
            # 5 == len(['mnt', 'miniwdl_task_container', 'work', '_miniwdl_inputs', '0'])
600
            container_path_prefix = "/" + "/".join(container_path_components[:i]) + "/"
1✔
601
            if container_path_prefix not in self.input_path_map_rev:
1✔
602
                continue
1✔
603
            host_parent = self.input_path_map_rev[container_path_prefix]
1✔
604
            host_path = os.path.join(host_parent.rstrip("/"), *container_path_components[i:])
1✔
605
            if container_path.endswith("/"):
1✔
606
                host_path += "/"
1✔
607
            if not path_really_within(host_path, host_parent):
1✔
UNCOV
608
                raise Error.InputError(
×
609
                    "task inputs attempted to use a non-input or non-existent path "
610
                    + container_path
611
                )
612
            return True, host_path
1✔
613

614
        return False, None
1✔
615

616
    def host_work_dir(self):
1✔
617
        return os.path.join(
1✔
618
            self.host_dir, f"work{self.try_counter if self.try_counter > 1 else ''}"
619
        )
620

621
    def host_stdout_txt(self):
1✔
622
        return os.path.join(
1✔
623
            self.host_dir, f"stdout{self.try_counter if self.try_counter > 1 else ''}.txt"
624
        )
625

626
    def host_stderr_txt(self):
1✔
627
        return os.path.join(
1✔
628
            self.host_dir, f"stderr{self.try_counter if self.try_counter > 1 else ''}.txt"
629
        )
630

631
    def touch_mount_point(self, host_path: str) -> None:
1✔
632
        """
633
        Implementation helper: touch a File or Directory mount point that might not already exist
634
        in the host directory. This ensures ownership by the invoking user:group.
635
        """
636
        assert host_path.startswith(self.host_dir + "/")
1✔
637
        if host_path.endswith("/"):  # Directory mount point
1✔
638
            os.makedirs(host_path, exist_ok=True)
1✔
639
        else:  # File mount point
640
            os.makedirs(os.path.dirname(host_path), exist_ok=True)
1✔
641
            with open(host_path, "x") as _:
1✔
642
                pass
1✔
643

644
    def poll_stderr_context(self, logger: logging.Logger) -> ContextManager[Callable[[], None]]:
1✔
645
        """
646
        Implementation helper: open a context yielding a function to poll stderr.txt and log each
647
        each line (to either logger or self.stderr_callback if set). _run() implementation should
648
        call the function periodically while container is running, and close the context once
649
        done/failed.
650
        """
651
        return PygtailLogger(
1✔
652
            logger,
653
            self.host_stderr_txt(),
654
            callback=self.stderr_callback,
655
        )
656

657
    def task_running_context(self) -> ContextManager[None]:
1✔
658
        """
659
        Implementation helper: open a context which counts the task, and its CPU and memory
660
        reservations, in the CLI status bar's "running" ticker. _run() implementation should open
661
        this context once the container is truly running (not while e.g. still queued), and close
662
        it once done/failed.
663
        """
664
        return _statusbar.task_running(
1✔
665
            self.runtime_values.get("cpu", 0),
666
            self.runtime_values.get("memory_reservation", 0),
667
        )
668

669

670
_backends: Dict[str, typing.Type[TaskContainer]] = dict()
1✔
671
_backends_lock: threading.Lock = threading.Lock()
1✔
672

673

674
def new(cfg: config.Loader, logger: logging.Logger, run_id: str, host_dir: str) -> TaskContainer:
1✔
675
    """
676
    Instantiate a TaskContainer from the configured backend, including any necessary global
677
    initialization.
678
    """
679
    global _backends
680
    with _backends_lock:
1✔
681
        if not _backends:
1✔
682
            for plugin_name, plugin_cls in config.load_plugins(cfg, "container_backend"):
1✔
683
                _backends[plugin_name] = plugin_cls  # type: ignore
1✔
684
        backend_cls = _backends[cfg["scheduler"]["container_backend"]]
1✔
685
        if not getattr(backend_cls, "_global_init", False):
1✔
686
            backend_cls.global_init(cfg, logger)
1✔
687
            setattr(backend_cls, "_global_init", True)
1✔
688
        ans = backend_cls(cfg, run_id, host_dir)
1✔
689
        assert isinstance(ans, TaskContainer)
1✔
690
        return ans
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc