Skip to content

Commit 27d7188

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 27d7188

File tree

3 files changed

+184
-4
lines changed

3 files changed

+184
-4
lines changed

src/taskgraph/docker.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import tarfile
1010
from io import BytesIO
11+
import tempfile
1112
from textwrap import dedent
1213

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

34
from taskgraph import docker
@@ -79,3 +80,101 @@ def mock_run(*popenargs, check=False, **kwargs):
7980

8081
out, _ = capsys.readouterr()
8182
assert f"Successfully built {image}" not in out
83+
84+
85+
@pytest.fixture
86+
def run_load_task(mocker):
87+
task_id = "abc"
88+
89+
def inner(
90+
task, remove=False
91+
):
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, None)
143+
mocks["subprocess_run"].assert_called_once_with(
144+
[
145+
"docker",
146+
"run",
147+
"-it",
148+
"image/tag",
149+
"bash",
150+
"-c",
151+
"/usr/bin/run-task --repo-checkout=/builds/worker/vcs/repo "
152+
"--task-cwd=/builds/worker/vcs/repo -- echo 'Task setup complete!' && "
153+
"bash",
154+
]
155+
)
156+
157+
158+
def test_load_task_env_and_remove(run_load_task):
159+
image_task_id = "def"
160+
task = {
161+
"payload": {
162+
"command": [
163+
"/usr/bin/run-task",
164+
"--repo-checkout=/builds/worker/vcs/repo",
165+
"--task-cwd=/builds/worker/vcs/repo",
166+
"--",
167+
"echo foo",
168+
],
169+
"env": {"FOO": "BAR", "BAZ": 1},
170+
"image": {"taskId": image_task_id},
171+
},
172+
"tags": {"worker-implementation": "docker-worker"},
173+
}
174+
ret, mocks = run_load_task(task, remove=True)
175+
assert ret == 0
176+
177+
mocks["subprocess_run"].assert_called_once()
178+
args = mocks["subprocess_run"].call_args[0][0]
179+
assert re.match(r"--env-file=/tmp/tmp.*", args[2])
180+
assert args[3] == "--rm"

0 commit comments

Comments
 (0)