Skip to content

Commit ce8e7f3

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 ce8e7f3

File tree

3 files changed

+246
-4
lines changed

3 files changed

+246
-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+
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 0

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

0 commit comments

Comments
 (0)