Skip to content

Commit 359d027

Browse files
committed
feat: implement new 'load-task' command to debug tasks locally
This command is an extension of the `load-image` command. Given a taskId, it will: 1. Find and download the docker image from the parent task. 2. Spin up a docker container using this image. 3. Ensure environment variables in the task's definition are set. 4. Run the "setup" portion of `run-task` (checkouts, fetches, etc) 5. Drop the user into a bash shell. This will make it easier to debug tasks locally. Note for now only tasks using run-task with a docker-worker (or D2G) payload are supported.
1 parent 03c6d78 commit 359d027

File tree

3 files changed

+251
-4
lines changed

3 files changed

+251
-4
lines changed

src/taskgraph/docker.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55

66
import json
77
import os
8+
import shlex
89
import subprocess
910
import tarfile
11+
import tempfile
1012
from io import BytesIO
1113
from textwrap import dedent
14+
from typing import List, Optional
1215

1316
try:
1417
import zstandard as zstd
@@ -19,6 +22,7 @@
1922
from taskgraph.util.taskcluster import (
2023
get_artifact_url,
2124
get_session,
25+
get_task_definition,
2226
)
2327

2428
DEPLOY_WARNING = """
@@ -90,7 +94,7 @@ def load_image_by_task_id(task_id, tag=None):
9094
else:
9195
tag = "{}:{}".format(result["image"], result["tag"])
9296
print(f"Try: docker run -ti --rm {tag} bash")
93-
return True
97+
return tag
9498

9599

96100
def build_context(name, outputFile, args=None):
@@ -237,3 +241,109 @@ def download_and_modify_image():
237241
raise Exception("No repositories file found!")
238242

239243
return info
244+
245+
246+
def _index(l: List, s: str) -> Optional[int]:
247+
try:
248+
return l.index(s)
249+
except ValueError:
250+
pass
251+
252+
253+
def load_task(task_id, remove=True):
254+
task_def = get_task_definition(task_id)
255+
256+
if (
257+
impl := task_def.get("tags", {}).get("worker-implementation")
258+
) != "docker-worker":
259+
print(f"Tasks with worker-implementation '{impl}' are not supported!")
260+
return 1
261+
262+
command = task_def["payload"].get("command")
263+
if not command or not command[0].endswith("run-task"):
264+
print("Only tasks using `run-task` are supported!")
265+
return 1
266+
267+
# Remove the payload section of the task's command. This way run-task will
268+
# set up the task (clone repos, download fetches, etc) but won't actually
269+
# start the core of the task. Instead we'll drop the user into an interactive
270+
# shell and provide the ability to resume the task command.
271+
task_command = None
272+
if index := _index(command, "--"):
273+
task_command = shlex.join(command[index + 1 :])
274+
# I attempted to run the interactive bash shell here, but for some
275+
# reason when executed through `run-task`, the interactive shell
276+
# doesn't work well. There's no shell prompt on newlines and tab
277+
# completion doesn't work. That's why it is executed outside of
278+
# `run-task` below, and why we need to parse `--task-cwd`.
279+
command[index + 1 :] = [
280+
"echo",
281+
"Task setup complete!\nRun `exec-task` to execute the task's command.",
282+
]
283+
284+
# Parse `--task-cwd` so we know where to execute the task's command later.
285+
if index := _index(command, "--task-cwd"):
286+
task_cwd = command[index + 1]
287+
else:
288+
for arg in command:
289+
if arg.startswith("--task-cwd="):
290+
task_cwd = arg.split("=", 1)[1]
291+
break
292+
else:
293+
task_cwd = "$TASK_WORKDIR"
294+
295+
image_task_id = task_def["payload"]["image"]["taskId"]
296+
image_tag = load_image_by_task_id(image_task_id)
297+
298+
env = task_def["payload"].get("env")
299+
300+
envfile = None
301+
initfile = None
302+
try:
303+
command = [
304+
"docker",
305+
"run",
306+
"-it",
307+
image_tag,
308+
"bash",
309+
"-c",
310+
f"{shlex.join(command)} && cd $TASK_WORKDIR && bash",
311+
]
312+
313+
if remove:
314+
command.insert(2, "--rm")
315+
316+
if env:
317+
envfile = tempfile.NamedTemporaryFile("w+", delete=False)
318+
envfile.write("\n".join([f"{k}={v}" for k, v in env.items()]))
319+
envfile.close()
320+
321+
command.insert(2, f"--env-file={envfile.name}")
322+
323+
if task_command:
324+
initfile = tempfile.NamedTemporaryFile("w+", delete=False)
325+
initfile.write(
326+
dedent(
327+
f"""
328+
function exec-task() {{
329+
echo "Starting task: {task_command}";
330+
pushd {task_cwd};
331+
{task_command};
332+
popd
333+
}}
334+
"""
335+
).lstrip()
336+
)
337+
initfile.close()
338+
339+
command[2:2] = ["-v", f"{initfile.name}:/builds/worker/.bashrc"]
340+
341+
proc = subprocess.run(command)
342+
finally:
343+
if envfile:
344+
os.remove(envfile.name)
345+
346+
if initfile:
347+
os.remove(initfile.name)
348+
349+
return proc.returncode

src/taskgraph/main.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,10 +618,10 @@ def load_image(args):
618618
validate_docker()
619619
try:
620620
if args["task_id"]:
621-
ok = load_image_by_task_id(args["task_id"], args.get("tag"))
621+
tag = load_image_by_task_id(args["task_id"], args.get("tag"))
622622
else:
623-
ok = load_image_by_name(args["image_name"], args.get("tag"))
624-
if not ok:
623+
tag = load_image_by_name(args["image_name"], args.get("tag"))
624+
if not tag:
625625
sys.exit(1)
626626
except Exception:
627627
traceback.print_exc()
@@ -652,6 +652,28 @@ def image_digest(args):
652652
sys.exit(1)
653653

654654

655+
@command(
656+
"load-task",
657+
help="Loads a pre-built Docker image and drops you into a container with "
658+
"the same environment variables and run-task setup as the specified task. "
659+
"The task's payload.command will be replaced with 'bash'. You need to have "
660+
"docker installed and running for this to work.",
661+
)
662+
@argument("task_id", help="The task id to load into a docker container.")
663+
@argument(
664+
"--keep",
665+
dest="remove",
666+
action="store_false",
667+
default=True,
668+
help="Keep the docker container after exiting.",
669+
)
670+
def load_task(args):
671+
from taskgraph.docker import load_task
672+
673+
validate_docker()
674+
return load_task(args["task_id"], remove=args["remove"])
675+
676+
655677
@command("decision", help="Run the decision task")
656678
@argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
657679
@argument(

test/test_docker.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import re
2+
import tempfile
3+
14
import pytest
25

36
from taskgraph import docker
@@ -79,3 +82,115 @@ def mock_run(*popenargs, check=False, **kwargs):
7982

8083
out, _ = capsys.readouterr()
8184
assert f"Successfully built {image}" not in out
85+
86+
87+
@pytest.fixture
88+
def run_load_task(mocker):
89+
task_id = "abc"
90+
91+
def inner(task, remove=False):
92+
proc = mocker.MagicMock()
93+
proc.returncode = 0
94+
95+
mocks = {
96+
"get_task_definition": mocker.patch.object(
97+
docker, "get_task_definition", return_value=task
98+
),
99+
"load_image_by_task_id": mocker.patch.object(
100+
docker, "load_image_by_task_id", return_value="image/tag"
101+
),
102+
"subprocess_run": mocker.patch.object(
103+
docker.subprocess, "run", return_value=proc
104+
),
105+
}
106+
107+
ret = docker.load_task(task_id, remove=remove)
108+
return ret, mocks
109+
110+
return inner
111+
112+
113+
def test_load_task_invalid_task(run_load_task):
114+
task = {}
115+
assert run_load_task(task)[0] == 1
116+
117+
task["tags"] = {"worker-implementation": "generic-worker"}
118+
assert run_load_task(task)[0] == 1
119+
120+
task["tags"]["worker-implementation"] = "docker-worker"
121+
task["payload"] = {"command": []}
122+
assert run_load_task(task)[0] == 1
123+
124+
task["payload"]["command"] = ["echo", "foo"]
125+
assert run_load_task(task)[0] == 1
126+
127+
128+
def test_load_task(run_load_task):
129+
image_task_id = "def"
130+
task = {
131+
"payload": {
132+
"command": [
133+
"/usr/bin/run-task",
134+
"--repo-checkout=/builds/worker/vcs/repo",
135+
"--task-cwd=/builds/worker/vcs/repo",
136+
"--",
137+
"echo foo",
138+
],
139+
"image": {"taskId": image_task_id},
140+
},
141+
"tags": {"worker-implementation": "docker-worker"},
142+
}
143+
ret, mocks = run_load_task(task)
144+
assert ret == 0
145+
146+
mocks["get_task_definition"].assert_called_once_with("abc")
147+
mocks["load_image_by_task_id"].assert_called_once_with(image_task_id)
148+
149+
expected = [
150+
"docker",
151+
"run",
152+
"-v",
153+
re.compile(f"{tempfile.gettempdir()}/tmp.*:/builds/worker/.bashrc"),
154+
"-it",
155+
"image/tag",
156+
"bash",
157+
"-c",
158+
"/usr/bin/run-task --repo-checkout=/builds/worker/vcs/repo "
159+
"--task-cwd=/builds/worker/vcs/repo -- echo 'Task setup complete!\n"
160+
"Run `exec-task` to execute the task'\"'\"'s command.' && cd $TASK_WORKDIR && bash",
161+
]
162+
163+
mocks["subprocess_run"].assert_called_once()
164+
actual = mocks["subprocess_run"].call_args[0][0]
165+
166+
assert len(expected) == len(actual)
167+
for i, exp in enumerate(expected):
168+
if isinstance(exp, re.Pattern):
169+
assert exp.match(actual[i])
170+
else:
171+
assert exp == actual[i]
172+
173+
174+
def test_load_task_env_and_remove(run_load_task):
175+
image_task_id = "def"
176+
task = {
177+
"payload": {
178+
"command": [
179+
"/usr/bin/run-task",
180+
"--repo-checkout=/builds/worker/vcs/repo",
181+
"--task-cwd=/builds/worker/vcs/repo",
182+
"--",
183+
"echo foo",
184+
],
185+
"env": {"FOO": "BAR", "BAZ": 1},
186+
"image": {"taskId": image_task_id},
187+
},
188+
"tags": {"worker-implementation": "docker-worker"},
189+
}
190+
ret, mocks = run_load_task(task, remove=True)
191+
assert ret == 0
192+
193+
mocks["subprocess_run"].assert_called_once()
194+
actual = mocks["subprocess_run"].call_args[0][0]
195+
assert re.match(r"--env-file=/tmp/tmp.*", actual[4])
196+
assert actual[5] == "--rm"

0 commit comments

Comments
 (0)