Skip to content

Commit 70e68df

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 70e68df

File tree

3 files changed

+185
-4
lines changed

3 files changed

+185
-4
lines changed

src/taskgraph/docker.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import subprocess
99
import tarfile
10+
import tempfile
1011
from io import BytesIO
1112
from textwrap import dedent
1213

@@ -19,6 +20,7 @@
1920
from taskgraph.util.taskcluster import (
2021
get_artifact_url,
2122
get_session,
23+
get_task_definition,
2224
)
2325

2426
DEPLOY_WARNING = """
@@ -90,7 +92,7 @@ def load_image_by_task_id(task_id, tag=None):
9092
else:
9193
tag = "{}:{}".format(result["image"], result["tag"])
9294
print(f"Try: docker run -ti --rm {tag} bash")
93-
return True
95+
return tag
9496

9597

9698
def build_context(name, outputFile, args=None):
@@ -237,3 +239,59 @@ def download_and_modify_image():
237239
raise Exception("No repositories file found!")
238240

239241
return info
242+
243+
244+
def load_task(task_id, remove=False):
245+
task_def = get_task_definition(task_id)
246+
247+
if (
248+
impl := task_def.get("tags", {}).get("worker-implementation")
249+
) != "docker-worker":
250+
print(f"Tasks with worker-implementation '{impl}' are not supported!")
251+
return 1
252+
253+
command = task_def["payload"].get("command")
254+
if not command or not command[0].endswith("run-task"):
255+
print("Only tasks using `run-task` are supported!")
256+
return 1
257+
258+
# Remove the payload section of the task's command. This way run-task will
259+
# set up the task (clone repos, download fetches, etc) but won't actually
260+
# start the core of the task. Instead we'll drop the user into a command
261+
# shell.
262+
if (index := command.index("--")) > -1:
263+
command[index + 1 :] = ["echo 'Task setup complete!'"]
264+
265+
image_task_id = task_def["payload"]["image"]["taskId"]
266+
image_tag = load_image_by_task_id(image_task_id, None)
267+
268+
env = task_def["payload"].get("env")
269+
270+
tmpfile = None
271+
try:
272+
command = [
273+
"docker",
274+
"run",
275+
"-it",
276+
image_tag,
277+
"bash",
278+
"-c",
279+
f"{' '.join(command)} && bash",
280+
]
281+
282+
if remove:
283+
command.insert(2, "--rm")
284+
285+
if env:
286+
tmpfile = tempfile.NamedTemporaryFile("w+", delete=False)
287+
tmpfile.write("\n".join([f"{k}={v}" for k, v in env.items()]))
288+
tmpfile.close()
289+
290+
command.insert(2, f"--env-file={tmpfile.name}")
291+
292+
subprocess.run(command)
293+
finally:
294+
if tmpfile:
295+
os.remove(tmpfile.name)
296+
297+
return 0

src/taskgraph/main.py

Lines changed: 28 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,31 @@ 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(
663+
"task_id",
664+
help="Load the image at public/image.tar.zst in this task, "
665+
"rather than searching the index",
666+
)
667+
@argument(
668+
"--rm",
669+
action="store_true",
670+
default=False,
671+
help="Delete the docker container after exiting.",
672+
)
673+
def load_task(args):
674+
from taskgraph.docker import load_task
675+
676+
validate_docker()
677+
return load_task(args["task_id"], remove=args["rm"])
678+
679+
655680
@command("decision", help="Run the decision task")
656681
@argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
657682
@argument(

test/test_docker.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
import pytest
24

35
from taskgraph import docker
@@ -79,3 +81,99 @@ def mock_run(*popenargs, check=False, **kwargs):
7981

8082
out, _ = capsys.readouterr()
8183
assert f"Successfully built {image}" not in out
84+
85+
86+
@pytest.fixture
87+
def run_load_task(mocker):
88+
task_id = "abc"
89+
90+
def inner(task, remove=False):
91+
mocks = {
92+
"get_task_definition": mocker.patch.object(
93+
docker, "get_task_definition", return_value=task
94+
),
95+
"load_image_by_task_id": mocker.patch.object(
96+
docker, "load_image_by_task_id", return_value="image/tag"
97+
),
98+
"subprocess_run": mocker.patch.object(docker.subprocess, "run"),
99+
}
100+
101+
ret = docker.load_task(task_id, remove=remove)
102+
return ret, mocks
103+
104+
return inner
105+
106+
107+
def test_load_task_invalid_task(run_load_task):
108+
task = {}
109+
assert run_load_task(task)[0] == 1
110+
111+
task["tags"] = {"worker-implementation": "generic-worker"}
112+
assert run_load_task(task)[0] == 1
113+
114+
task["tags"]["worker-implementation"] = "docker-worker"
115+
task["payload"] = {"command": []}
116+
assert run_load_task(task)[0] == 1
117+
118+
task["payload"]["command"] = ["echo", "foo"]
119+
assert run_load_task(task)[0] == 1
120+
121+
122+
def test_load_task(run_load_task):
123+
image_task_id = "def"
124+
task = {
125+
"payload": {
126+
"command": [
127+
"/usr/bin/run-task",
128+
"--repo-checkout=/builds/worker/vcs/repo",
129+
"--task-cwd=/builds/worker/vcs/repo",
130+
"--",
131+
"echo foo",
132+
],
133+
"image": {"taskId": image_task_id},
134+
},
135+
"tags": {"worker-implementation": "docker-worker"},
136+
}
137+
ret, mocks = run_load_task(task)
138+
assert ret == 0
139+
140+
mocks["get_task_definition"].assert_called_once_with("abc")
141+
mocks["load_image_by_task_id"].assert_called_once_with(image_task_id, None)
142+
mocks["subprocess_run"].assert_called_once_with(
143+
[
144+
"docker",
145+
"run",
146+
"-it",
147+
"image/tag",
148+
"bash",
149+
"-c",
150+
"/usr/bin/run-task --repo-checkout=/builds/worker/vcs/repo "
151+
"--task-cwd=/builds/worker/vcs/repo -- echo 'Task setup complete!' && "
152+
"bash",
153+
]
154+
)
155+
156+
157+
def test_load_task_env_and_remove(run_load_task):
158+
image_task_id = "def"
159+
task = {
160+
"payload": {
161+
"command": [
162+
"/usr/bin/run-task",
163+
"--repo-checkout=/builds/worker/vcs/repo",
164+
"--task-cwd=/builds/worker/vcs/repo",
165+
"--",
166+
"echo foo",
167+
],
168+
"env": {"FOO": "BAR", "BAZ": 1},
169+
"image": {"taskId": image_task_id},
170+
},
171+
"tags": {"worker-implementation": "docker-worker"},
172+
}
173+
ret, mocks = run_load_task(task, remove=True)
174+
assert ret == 0
175+
176+
mocks["subprocess_run"].assert_called_once()
177+
args = mocks["subprocess_run"].call_args[0][0]
178+
assert re.match(r"--env-file=/tmp/tmp.*", args[2])
179+
assert args[3] == "--rm"

0 commit comments

Comments
 (0)