Skip to content

Commit 4cd7070

Browse files
committed
feat(load-task): implement interactive=False to execute tasks without pausing
By default, we load the task with `run-task` then pause execution so the user can mess around before executing the task. This introduces a way to support non run-task based tasks by setting interactive=False. This simply runs the task right away without pausing execution.
1 parent 727252c commit 4cd7070

File tree

2 files changed

+123
-46
lines changed

2 files changed

+123
-46
lines changed

src/taskgraph/docker.py

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import shlex
88
import subprocess
9+
import sys
910
import tarfile
1011
import tempfile
1112
from io import BytesIO
@@ -378,6 +379,7 @@ def load_task(
378379
remove: bool = True,
379380
user: Optional[str] = None,
380381
custom_image: Optional[str] = None,
382+
interactive: Optional[bool] = True,
381383
) -> int:
382384
"""Load and run a task interactively in a Docker container.
383385
@@ -392,14 +394,12 @@ def load_task(
392394
remove: Whether to remove the container after exit (default True).
393395
user: The user to switch to in the container (default 'worker').
394396
custom_image: A custom image to use instead of the task's image.
397+
interactive: If True, execution of the task will be paused and user
398+
will be dropped into a shell. They can run `exec-task` to resume
399+
it (default: True).
395400
396401
Returns:
397402
int: The exit code from the Docker container.
398-
399-
Note:
400-
Only supports tasks that use 'run-task' and have a payload.image.
401-
The task's actual command is made available via an 'exec-task' function
402-
in the interactive shell.
403403
"""
404404
user = user or "worker"
405405
if isinstance(task, str):
@@ -419,9 +419,9 @@ def load_task(
419419

420420
return 1
421421

422-
command = task_def["payload"].get("command") # type: ignore
423-
if not command or not command[0].endswith("run-task"):
424-
logger.error("Only tasks using `run-task` are supported!")
422+
task_command = task_def["payload"].get("command") # type: ignore
423+
if interactive and (not task_command or not task_command[0].endswith("run-task")):
424+
logger.error("Only tasks using `run-task` are supported with interactive!")
425425
return 1
426426

427427
try:
@@ -431,33 +431,40 @@ def load_task(
431431
logger.exception(e)
432432
return 1
433433

434-
# Remove the payload section of the task's command. This way run-task will
435-
# set up the task (clone repos, download fetches, etc) but won't actually
436-
# start the core of the task. Instead we'll drop the user into an interactive
437-
# shell and provide the ability to resume the task command.
438-
task_command = None
439-
if index := _index(command, "--"):
440-
task_command = shlex.join(command[index + 1 :])
441-
# I attempted to run the interactive bash shell here, but for some
442-
# reason when executed through `run-task`, the interactive shell
443-
# doesn't work well. There's no shell prompt on newlines and tab
444-
# completion doesn't work. That's why it is executed outside of
445-
# `run-task` below, and why we need to parse `--task-cwd`.
446-
command[index + 1 :] = [
447-
"echo",
448-
"Task setup complete!\nRun `exec-task` to execute the task's command.",
449-
]
450-
451-
# Parse `--task-cwd` so we know where to execute the task's command later.
452-
if index := _index(command, "--task-cwd"):
453-
task_cwd = command[index + 1]
454-
else:
455-
for arg in command:
456-
if arg.startswith("--task-cwd="):
457-
task_cwd = arg.split("=", 1)[1]
458-
break
434+
exec_command = task_cwd = None
435+
if interactive:
436+
# Remove the payload section of the task's command. This way run-task will
437+
# set up the task (clone repos, download fetches, etc) but won't actually
438+
# start the core of the task. Instead we'll drop the user into an interactive
439+
# shell and provide the ability to resume the task command.
440+
if index := _index(task_command, "--"):
441+
exec_command = shlex.join(task_command[index + 1 :])
442+
# I attempted to run the interactive bash shell here, but for some
443+
# reason when executed through `run-task`, the interactive shell
444+
# doesn't work well. There's no shell prompt on newlines and tab
445+
# completion doesn't work. That's why it is executed outside of
446+
# `run-task` below, and why we need to parse `--task-cwd`.
447+
task_command[index + 1 :] = [
448+
"echo",
449+
"Task setup complete!\nRun `exec-task` to execute the task's command.",
450+
]
451+
452+
# Parse `--task-cwd` so we know where to execute the task's command later.
453+
if index := _index(task_command, "--task-cwd"):
454+
task_cwd = task_command[index + 1]
459455
else:
460-
task_cwd = "$TASK_WORKDIR"
456+
for arg in task_command:
457+
if arg.startswith("--task-cwd="):
458+
task_cwd = arg.split("=", 1)[1]
459+
break
460+
else:
461+
task_cwd = "$TASK_WORKDIR"
462+
463+
task_command = [
464+
"bash",
465+
"-c",
466+
f"{shlex.join(task_command)} && cd $TASK_WORKDIR && su -p {user}",
467+
]
461468

462469
# Set some env vars the worker would normally set.
463470
env = {
@@ -476,17 +483,21 @@ def load_task(
476483

477484
envfile = None
478485
initfile = None
486+
isatty = os.isatty(sys.stdin.fileno())
479487
try:
480488
command = [
481489
"docker",
482490
"run",
483-
"-it",
484-
image_tag,
485-
"bash",
486-
"-c",
487-
f"{shlex.join(command)} && cd $TASK_WORKDIR && su -p {user}",
491+
"-i",
488492
]
489493

494+
if isatty:
495+
command.append("-t")
496+
497+
command.append(image_tag)
498+
499+
if task_command:
500+
command.extend(task_command)
490501
if remove:
491502
command.insert(2, "--rm")
492503

@@ -497,16 +508,16 @@ def load_task(
497508

498509
command.insert(2, f"--env-file={envfile.name}")
499510

500-
if task_command:
511+
if exec_command:
501512
initfile = tempfile.NamedTemporaryFile("w+", delete=False)
502513
os.fchmod(initfile.fileno(), 0o644)
503514
initfile.write(
504515
dedent(
505516
f"""
506517
function exec-task() {{
507-
echo Starting task: {shlex.quote(task_command)}
518+
echo Starting task: {shlex.quote(exec_command)}
508519
pushd {task_cwd}
509-
{task_command}
520+
{exec_command}
510521
popd
511522
}}
512523
"""
@@ -516,6 +527,7 @@ def load_task(
516527

517528
command[2:2] = ["-v", f"{initfile.name}:/builds/worker/.bashrc"]
518529

530+
logger.info(f"Running: {' '.join(command)}")
519531
proc = subprocess.run(command)
520532
finally:
521533
if envfile:

test/test_docker.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ def mock_run(*popenargs, check=False, **kwargs):
110110

111111
@pytest.fixture
112112
def run_load_task(mocker):
113-
def inner(task, remove=False, custom_image=None, pass_task_def=False):
113+
def inner(
114+
task, remove=False, custom_image=None, pass_task_def=False, interactive=True
115+
):
114116
proc = mocker.MagicMock()
115117
proc.returncode = 0
116118

@@ -134,6 +136,12 @@ def inner(task, remove=False, custom_image=None, pass_task_def=False):
134136
),
135137
}
136138

139+
# Mock sys.stdin.fileno() to avoid issues in pytest
140+
mock_stdin = mocker.MagicMock()
141+
mock_stdin.fileno.return_value = 0
142+
mocker.patch.object(docker.sys, "stdin", mock_stdin)
143+
mocker.patch.object(docker.os, "isatty", return_value=True)
144+
137145
# If testing with task ID, mock get_task_definition
138146
if not pass_task_def:
139147
task_id = "abc"
@@ -146,7 +154,11 @@ def inner(task, remove=False, custom_image=None, pass_task_def=False):
146154
input_arg = task
147155

148156
ret = docker.load_task(
149-
graph_config, input_arg, remove=remove, custom_image=custom_image
157+
graph_config,
158+
input_arg,
159+
remove=remove,
160+
custom_image=custom_image,
161+
interactive=interactive,
150162
)
151163
return ret, mocks
152164

@@ -199,7 +211,8 @@ def test_load_task(run_load_task):
199211
"-v",
200212
re.compile(f"{tempfile.gettempdir()}/tmp.*:/builds/worker/.bashrc"),
201213
re.compile(f"--env-file={tempfile.gettempdir()}/tmp.*"),
202-
"-it",
214+
"-i",
215+
"-t",
203216
"image/tag",
204217
"bash",
205218
"-c",
@@ -389,6 +402,43 @@ def test_load_task_with_task_definition(run_load_task, caplog):
389402
assert "Loading 'test-task-direct' from provided definition" in caplog.text
390403

391404

405+
def test_load_task_with_interactive_false(run_load_task):
406+
# Test non-interactive mode that doesn't require run-task
407+
# Task that doesn't use run-task (would fail in interactive mode)
408+
task = {
409+
"metadata": {"name": "test-task-non-interactive"},
410+
"payload": {
411+
"command": ["echo", "hello world"],
412+
"image": {"taskId": "def", "type": "task-image"},
413+
},
414+
}
415+
416+
# Test with interactive=False - should succeed
417+
ret, mocks = run_load_task(task, pass_task_def=True, interactive=False)
418+
assert ret == 0
419+
420+
# Verify subprocess was called
421+
mocks["subprocess_run"].assert_called_once()
422+
command = mocks["subprocess_run"].call_args[0][0]
423+
424+
# Should run the task command directly
425+
# Find and remove --env-file arg as it contains a tempdir
426+
for i, arg in enumerate(command):
427+
if arg.startswith("--env-file="):
428+
del command[i]
429+
break
430+
431+
assert command == [
432+
"docker",
433+
"run",
434+
"-i",
435+
"-t",
436+
"image/tag",
437+
"echo",
438+
"hello world",
439+
]
440+
441+
392442
@pytest.fixture
393443
def task():
394444
return {
@@ -413,7 +463,22 @@ def test_load_task_with_custom_image_in_tree(run_load_task, task):
413463

414464
mocks["build_image"].assert_called_once()
415465
args = mocks["subprocess_run"].call_args[0][0]
416-
tag = args[args.index("-it") + 1]
466+
# Find the image tag - it should be after all docker options and before the command
467+
# Structure: ['docker', 'run', ...options..., 'image:tag', ...command...]
468+
image_index = None
469+
for i, arg in enumerate(args):
470+
if (
471+
not arg.startswith("-")
472+
and not arg.startswith("/")
473+
and arg != "docker"
474+
and arg != "run"
475+
and ":" in arg
476+
and not arg.startswith("/tmp")
477+
):
478+
image_index = i
479+
break
480+
assert image_index is not None, f"Could not find image tag in {args}"
481+
tag = args[image_index]
417482
assert tag == f"taskcluster/{image}:latest"
418483

419484

0 commit comments

Comments
 (0)