Skip to content

Commit e0b3f33

Browse files
committed
feat: add --develop flag to taskgraph load-task
This flag will setup the current local checkout as a volume to the task's checkout and avoid all VCS operations. This allows developers to make changes to the source and immediately see it in action in the task.
1 parent d3de58e commit e0b3f33

File tree

6 files changed

+166
-13
lines changed

6 files changed

+166
-13
lines changed

docs/howto/load-task-locally.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,32 @@ by combining passing in a custom image locally, and piping a task definition via
105105
106106
taskgraph morphed -J --tasks test-unit-py | jq -r 'to_entries | first | .value.task' | taskgraph load-task --image python -
107107
108+
Developing in the Container
109+
---------------------------
110+
111+
The ``taskgraph load-task`` command also accepts a ``--develop`` flag. This mounts your
112+
local repository as a volume in the task's expected checkout directory, allowing you to
113+
add print debugging or test fixes directly in a local container.
114+
115+
Run:
116+
117+
.. code-block:: shell
118+
119+
taskgraph load-task --develop <task-id>
120+
121+
.. warning::
122+
123+
Tasks can do all sorts of strange things. Running in this mode has the
124+
potential to modify your local source checkout in unexpected ways, up to and
125+
including data loss. Use with caution and consider using a dedicated Git
126+
worktree when running with ``--develop``.
127+
128+
.. warning::
129+
130+
Only tasks that use the ``run-task`` script to bootstrap their commands
131+
are currently supported.
132+
133+
Using ``--develop`` in conjunction with ``--interactive`` can be a powerful way
134+
to iterate quickly in a task's environment.
135+
108136
.. _docker-worker payload format: https://docs.taskcluster.net/docs/reference/workers/docker-worker/payload

src/taskgraph/docker.py

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
get_task_definition,
3737
status_task,
3838
)
39+
from taskgraph.util.vcs import get_repository
3940

4041
logger = logging.getLogger(__name__)
4142
RUN_TASK_RE = re.compile(r"run-task(-(git|hg))?$")
@@ -386,6 +387,27 @@ def _index(l: list, s: str) -> Optional[int]:
386387
pass
387388

388389

390+
def _extract_arg(cmd: list[str], arg: str) -> Optional[str]:
391+
if index := _index(cmd, arg):
392+
return cmd[index + 1]
393+
394+
for item in cmd:
395+
if item.startswith(f"{arg}="):
396+
return item.split("=", 1)[1]
397+
398+
399+
def _delete_arg(cmd: list[str], arg:str) -> bool:
400+
if index := _index(cmd, arg):
401+
del cmd[index: index + 2]
402+
return True
403+
404+
for i, item in enumerate(cmd):
405+
if item.startswith(f"{arg}="):
406+
del cmd[i]
407+
return True
408+
return False
409+
410+
389411
def _resolve_image(image: Union[str, dict[str, str]], graph_config: GraphConfig) -> str:
390412
image_task_id = None
391413

@@ -432,6 +454,7 @@ def load_task(
432454
custom_image: Optional[str] = None,
433455
interactive: Optional[bool] = False,
434456
volumes: Optional[list[tuple[str, str]]] = None,
457+
develop: bool = False,
435458
) -> int:
436459
"""Load and run a task interactively in a Docker container.
437460
@@ -449,6 +472,8 @@ def load_task(
449472
interactive: If True, execution of the task will be paused and user
450473
will be dropped into a shell. They can run `exec-task` to resume
451474
it (default: False).
475+
develop: If True, the task will be configured to use the current
476+
local checkout at the current revision (default: False).
452477
453478
Returns:
454479
int: The exit code from the Docker container.
@@ -476,6 +501,10 @@ def load_task(
476501
logger.error("Only tasks using `run-task` are supported with --interactive!")
477502
return 1
478503

504+
if develop and not is_run_task:
505+
logger.error("Only tasks using `run-task` are supported with --develop!")
506+
return 1
507+
479508
try:
480509
image = custom_image or image
481510
image_tag = _resolve_image(image, graph_config)
@@ -484,6 +513,37 @@ def load_task(
484513
return 1
485514

486515
task_command = task_def["payload"].get("command") # type: ignore
516+
task_env = task_def["payload"].get("env", {})
517+
518+
if develop:
519+
repositories = json.loads(task_env.get("REPOSITORIES", "{}"))
520+
if not repositories:
521+
logger.error("Can't use --develop with task that doesn't define any "
522+
"$REPOSITORIES!")
523+
return 1
524+
525+
try:
526+
repo = get_repository(os.getcwd())
527+
except RuntimeError:
528+
logger.error("Can't use --develop from outside a source repository!")
529+
return 1
530+
531+
checkout_name = list(repositories.keys())[0]
532+
checkout_arg = f"--{checkout_name}-checkout"
533+
checkout_dir = _extract_arg(task_command, checkout_arg)
534+
if not checkout_dir:
535+
logger.error(f"Can't use --develop with task that doesn't use {checkout_arg}")
536+
return 1
537+
volumes = volumes or []
538+
volumes.append((repo.path, checkout_dir))
539+
540+
# Delete environment and arguments related to this repo so that
541+
# `run-task` doesn't attempt to fetch or checkout a new revision.
542+
del repositories[checkout_name]
543+
task_env["REPOSITORIES"] = json.dumps(repositories)
544+
for arg in ("checkout", "sparse-profile", "shallow-clone"):
545+
_delete_arg(task_command, f"--{checkout_name}-{arg}")
546+
487547
exec_command = task_cwd = None
488548
if interactive:
489549
# Remove the payload section of the task's command. This way run-task will
@@ -503,16 +563,7 @@ def load_task(
503563
]
504564

505565
# Parse `--task-cwd` so we know where to execute the task's command later.
506-
if index := _index(task_command, "--task-cwd"):
507-
task_cwd = task_command[index + 1]
508-
else:
509-
for arg in task_command:
510-
if arg.startswith("--task-cwd="):
511-
task_cwd = arg.split("=", 1)[1]
512-
break
513-
else:
514-
task_cwd = "$TASK_WORKDIR"
515-
566+
task_cwd = _extract_arg(task_command, "--task-cwd") or "$TASK_WORKDIR"
516567
task_command = [
517568
"bash",
518569
"-c",
@@ -527,7 +578,7 @@ def load_task(
527578
"TASKCLUSTER_ROOT_URL": get_root_url(),
528579
}
529580
# Add the task's environment variables.
530-
env.update(task_def["payload"].get("env", {})) # type: ignore
581+
env.update(task_env) # type: ignore
531582

532583
# run-task expects the worker to mount a volume for each path defined in
533584
# TASKCLUSTER_CACHES; delete them to avoid needing to do the same, unless
@@ -545,6 +596,11 @@ def load_task(
545596
else:
546597
del env["TASKCLUSTER_CACHES"]
547598

599+
# run-task expects volumes listed under `TASKCLUSTER_VOLUMES` to be empty.
600+
# This can interfere with load-task when using custom volumes.
601+
if volumes and "TASKCLUSTER_VOLUMES" in env:
602+
del env["TASKCLUSTER_VOLUMES"]
603+
548604
envfile = None
549605
initfile = None
550606
isatty = os.isatty(sys.stdin.fileno())

src/taskgraph/main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,18 @@ def image_digest(args):
767767
action="store_true",
768768
default=False,
769769
help="Setup the task but pause execution before executing its command. "
770-
"Repositories will be cloned, environment variables will be set and an"
770+
"Repositories will be cloned, environment variables will be set and an "
771771
"executable script named `exec-task` will be provided to resume task "
772772
"execution. Only supported for `run-task` based tasks.",
773773
)
774+
@argument(
775+
"--develop",
776+
action="store_true",
777+
default=False,
778+
help="Configure the task to use the local source checkout at the current "
779+
"revision instead of cloning and using the revision from CI."
780+
781+
)
774782
@argument(
775783
"--keep",
776784
dest="remove",
@@ -837,6 +845,7 @@ def load_task(args):
837845
user=args["user"],
838846
custom_image=args["image"],
839847
volumes=volumes,
848+
develop=args["develop"],
840849
)
841850

842851

src/taskgraph/util/vcs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ def does_revision_exist_locally(self, revision):
582582
raise
583583

584584

585-
def get_repository(path):
585+
def get_repository(path: str):
586586
"""Get a repository object for the repository at `path`.
587587
If `path` is not a known VCS repository, raise an exception.
588588
"""

test/test_docker.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from taskgraph import docker
99
from taskgraph.config import GraphConfig
1010
from taskgraph.transforms.docker_image import IMAGE_BUILDER_IMAGE
11+
from taskgraph.util.vcs import get_repository
1112

1213

1314
@pytest.fixture
@@ -477,6 +478,45 @@ def test_load_task_with_custom_image_registry(mocker, run_load_task, task):
477478
assert not mocks["build_image"].called
478479

479480

481+
def test_load_task_with_develop(mocker, run_load_task, task):
482+
repo_name = "foo"
483+
repo_path = "/workdir/vcs"
484+
repo = get_repository(os.getcwd())
485+
486+
# No REPOSITORIES env
487+
ret, _ = run_load_task(task, develop=True)
488+
assert ret == 1
489+
490+
# No --checkout flag
491+
task["payload"]["env"] = {"REPOSITORIES": f'{{"{repo_name}": "{repo_path}"}}'}
492+
ret, mocks = run_load_task(task, develop=True)
493+
assert ret == 1
494+
495+
env_file = None
496+
task["payload"]["command"].insert(1, f"--{repo_name}-checkout={repo_path}")
497+
m = mocker.patch("os.remove")
498+
try:
499+
ret, mocks = run_load_task(task, develop=True)
500+
assert ret == 0
501+
cmd = mocks["subprocess_run"].call_args[0][0]
502+
cmdstr = " ".join(cmd)
503+
assert f"-v {repo.path}:{repo_path}" in cmdstr
504+
assert f"--{repo_name}-checkout" not in cmdstr
505+
506+
env_file = docker._extract_arg(cmd, "--env-file")
507+
assert env_file
508+
with open(env_file) as fh:
509+
contents = fh.read()
510+
511+
assert "TASKCLUSTER_VOLUMES" not in contents
512+
assert "REPOSITORIES" in contents
513+
assert "foo" not in contents
514+
finally:
515+
if env_file:
516+
m.reset_mock()
517+
os.remove(env_file)
518+
519+
480520
@pytest.fixture
481521
def run_build_image(mocker):
482522
def inner(image_name, save_image=None, context_file=None, image_task=None):

test/test_main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ def test_load_task_command(run_load_task):
467467
user=None,
468468
custom_image=None,
469469
volumes=[],
470+
develop=False,
470471
)
471472

472473
# Test with interactive flag
@@ -481,6 +482,22 @@ def test_load_task_command(run_load_task):
481482
user=None,
482483
custom_image=None,
483484
volumes=[],
485+
develop=False,
486+
)
487+
488+
# Test with develop flag
489+
result, mocks = run_load_task(["load-task", "--develop", "task-id-456"])
490+
491+
assert result == 0
492+
mocks["docker_load_task"].assert_called_once_with(
493+
mocks["graph_config"],
494+
"task-id-456",
495+
interactive=False,
496+
remove=True,
497+
user=None,
498+
custom_image=None,
499+
volumes=[],
500+
develop=True,
484501
)
485502

486503

@@ -501,6 +518,7 @@ def test_load_task_command_with_volume(run_load_task):
501518
user=None,
502519
custom_image=None,
503520
volumes=[("/host/path", "/builds/worker/checkouts")],
521+
develop=False
504522
)
505523

506524
# Test with no colon
@@ -542,6 +560,7 @@ def test_load_task_command_with_stdin(run_load_task):
542560
user=None,
543561
custom_image=None,
544562
volumes=[],
563+
develop=False,
545564
)
546565

547566

@@ -560,6 +579,7 @@ def test_load_task_command_with_task_id(run_load_task):
560579
user=None,
561580
custom_image=None,
562581
volumes=[],
582+
develop=False,
563583
)
564584

565585

0 commit comments

Comments
 (0)