Skip to content

Commit ac4a5c3

Browse files
committed
feat(load-task): support using custom images
This implements `taskgraph load-task --image <image>`, where <image> can be one of: * task-id=<task id> - Downloads image.tar from specified task id * image=<image> - Downloads image.tar from specified index path * <image name> - Builds image that lives under `taskcluster/docker/<image name>` * <image reference> - Uses specified image from a registry
1 parent 24b8711 commit ac4a5c3

File tree

3 files changed

+147
-19
lines changed

3 files changed

+147
-19
lines changed

src/taskgraph/docker.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import os
77
import shlex
88
import subprocess
9+
import sys
910
import tarfile
1011
import tempfile
1112
from io import BytesIO
13+
from pathlib import Path
1214
from textwrap import dedent
13-
from typing import Dict, Generator, List, Mapping, Optional
15+
from typing import Dict, Generator, List, Mapping, Optional, Union
1416

1517
try:
1618
import zstandard as zstd
@@ -354,7 +356,48 @@ def _index(l: List, s: str) -> Optional[int]:
354356
pass
355357

356358

357-
def load_task(task_id: str, remove: bool = True, user: Optional[str] = None) -> int:
359+
def _resolve_image(image: Union[str, Dict[str, str]], graph_config: GraphConfig) -> str:
360+
image_task_id = None
361+
362+
# Standard case, image comes from the task definition.
363+
if isinstance(image, dict):
364+
assert "type" in image
365+
366+
if image["type"] == "task-image":
367+
image_task_id = image["taskId"]
368+
elif image["type"] == "indexed-image":
369+
image_task_id = find_task_id(image["namespace"])
370+
else:
371+
raise Exception(f"Tasks with {image['type']} images are not supported!")
372+
else:
373+
# Check if image refers to an in-tree image under taskcluster/docker,
374+
# if so build it.
375+
image_dir = docker.image_path(image, graph_config)
376+
if Path(image_dir).is_dir():
377+
tag = f"taskcluster/{image}:latest"
378+
build_image(image, tag, graph_config, os.environ)
379+
return tag
380+
381+
# Check if we're referencing a task or index.
382+
if image.startswith("task-id="):
383+
image_task_id = image.split("=", 1)[1]
384+
elif image.startswith("index="):
385+
index = image.split("=", 1)[1]
386+
image_task_id = find_task_id(index)
387+
else:
388+
# Assume the string references an image from a registry.
389+
return image
390+
391+
return load_image_by_task_id(image_task_id)
392+
393+
394+
def load_task(
395+
graph_config: GraphConfig,
396+
task_id: str,
397+
remove: bool = True,
398+
user: Optional[str] = None,
399+
custom_image: Optional[str] = None,
400+
) -> int:
358401
"""Load and run a task interactively in a Docker container.
359402
360403
Downloads the docker image from a task's definition and runs it in an
@@ -363,9 +406,11 @@ def load_task(task_id: str, remove: bool = True, user: Optional[str] = None) ->
363406
the 'exec-task' function provided in the shell.
364407
365408
Args:
409+
graph_config: The graph configuration object.
366410
task_id: The ID of the task to load.
367411
remove: Whether to remove the container after exit (default True).
368412
user: The user to switch to in the container (default 'worker').
413+
custom_image: A custom image to use instead of the task's image.
369414
370415
Returns:
371416
int: The exit code from the Docker container.
@@ -387,6 +432,13 @@ def load_task(task_id: str, remove: bool = True, user: Optional[str] = None) ->
387432
print("Only tasks using `run-task` are supported!")
388433
return 1
389434

435+
try:
436+
image = custom_image or image
437+
image_tag = _resolve_image(image, graph_config)
438+
except Exception as e:
439+
print(e, file=sys.stderr)
440+
return 1
441+
390442
# Remove the payload section of the task's command. This way run-task will
391443
# set up the task (clone repos, download fetches, etc) but won't actually
392444
# start the core of the task. Instead we'll drop the user into an interactive
@@ -415,16 +467,6 @@ def load_task(task_id: str, remove: bool = True, user: Optional[str] = None) ->
415467
else:
416468
task_cwd = "$TASK_WORKDIR"
417469

418-
if image["type"] == "task-image":
419-
image_task_id = image["taskId"]
420-
elif image["type"] == "indexed-image":
421-
image_task_id = find_task_id(image["namespace"])
422-
else:
423-
print(f"Tasks with {image['type']} images are not supported!")
424-
return 1
425-
426-
image_tag = load_image_by_task_id(image_task_id)
427-
428470
# Set some env vars the worker would normally set.
429471
env = {
430472
"RUN_ID": "0",

src/taskgraph/main.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ def show_taskgraph(options):
569569
"--root",
570570
"-r",
571571
default="taskcluster",
572-
help="relative path for the root of the taskgraph definition",
572+
help="Relative path to the root of the Taskgraph definition.",
573573
)
574574
@argument(
575575
"-t", "--tag", help="tag that the image should be built as.", metavar="name:tag"
@@ -683,11 +683,34 @@ def image_digest(args):
683683
help="Keep the docker container after exiting.",
684684
)
685685
@argument("--user", default=None, help="Container user to start shell with.")
686+
@argument(
687+
"--image",
688+
default=None,
689+
help="Use a custom image instead of the task's image. Can be the name of "
690+
"an image under `taskcluster/docker`, `task-id=<task id>`, or "
691+
"`index=<index path>`.",
692+
)
693+
@argument(
694+
"--root",
695+
"-r",
696+
default="taskcluster",
697+
help="Relative path to the root of the Taskgraph definition.",
698+
)
686699
def load_task(args):
700+
from taskgraph.config import load_graph_config # noqa: PLC0415
687701
from taskgraph.docker import load_task # noqa: PLC0415
688702

689703
validate_docker()
690-
return load_task(args["task_id"], remove=args["remove"], user=args["user"])
704+
705+
root = args["root"]
706+
graph_config = load_graph_config(root)
707+
return load_task(
708+
graph_config,
709+
args["task_id"],
710+
remove=args["remove"],
711+
user=args["user"],
712+
custom_image=args["image"],
713+
)
691714

692715

693716
@command("decision", help="Run the decision task")

test/test_docker.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,22 @@ def mock_run(*popenargs, check=False, **kwargs):
113113
def run_load_task(mocker):
114114
task_id = "abc"
115115

116-
def inner(task, remove=False):
116+
def inner(task, remove=False, custom_image=None):
117117
proc = mocker.MagicMock()
118118
proc.returncode = 0
119119

120+
graph_config = GraphConfig(
121+
{
122+
"trust-domain": "test-domain",
123+
"docker-image-kind": "docker-image",
124+
},
125+
"test/data/taskcluster",
126+
)
127+
120128
mocks = {
129+
"build_image": mocker.patch.object(
130+
docker, "build_image", return_value=None
131+
),
121132
"get_task_definition": mocker.patch.object(
122133
docker, "get_task_definition", return_value=task
123134
),
@@ -129,7 +140,9 @@ def inner(task, remove=False):
129140
),
130141
}
131142

132-
ret = docker.load_task(task_id, remove=remove)
143+
ret = docker.load_task(
144+
graph_config, task_id, remove=remove, custom_image=custom_image
145+
)
133146
return ret, mocks
134147

135148
return inner
@@ -330,8 +343,58 @@ def test_load_task_with_unsupported_image_type(capsys, run_load_task):
330343
},
331344
}
332345

333-
ret, mocks = run_load_task(task)
346+
ret, _ = run_load_task(task)
334347
assert ret == 1
335348

336-
out, _ = capsys.readouterr()
337-
assert "Tasks with unsupported-type images are not supported!" in out
349+
_, err = capsys.readouterr()
350+
assert "Tasks with unsupported-type images are not supported!" in err
351+
352+
353+
@pytest.fixture
354+
def task():
355+
return {
356+
"payload": {
357+
"command": [
358+
"/usr/bin/run-task",
359+
"--task-cwd=/builds/worker",
360+
"--",
361+
"echo",
362+
"test",
363+
],
364+
"image": {"type": "task-image", "taskId": "abc"},
365+
},
366+
}
367+
368+
369+
def test_load_task_with_custom_image_in_tree(run_load_task, task):
370+
image = "hello-world"
371+
ret, mocks = run_load_task(task, custom_image=image)
372+
assert ret == 0
373+
374+
mocks["build_image"].assert_called_once()
375+
args = mocks["subprocess_run"].call_args[0][0]
376+
tag = args[args.index("-it") + 1]
377+
assert tag == f"taskcluster/{image}:latest"
378+
379+
380+
def test_load_task_with_custom_image_task_id(run_load_task, task):
381+
image = "task-id=abc"
382+
ret, mocks = run_load_task(task, custom_image=image)
383+
assert ret == 0
384+
mocks["load_image_by_task_id"].assert_called_once_with("abc")
385+
386+
387+
def test_load_task_with_custom_image_index(mocker, run_load_task, task):
388+
image = "index=abc"
389+
mocker.patch.object(docker, "find_task_id", return_value="abc")
390+
ret, mocks = run_load_task(task, custom_image=image)
391+
assert ret == 0
392+
mocks["load_image_by_task_id"].assert_called_once_with("abc")
393+
394+
395+
def test_load_task_with_custom_image_registry(mocker, run_load_task, task):
396+
image = "ubuntu:latest"
397+
ret, mocks = run_load_task(task, custom_image=image)
398+
assert ret == 0
399+
assert not mocks["load_image_by_task_id"].called
400+
assert not mocks["build_image"].called

0 commit comments

Comments
 (0)