Skip to content

Commit 71f6cf7

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 c5eaf2d commit 71f6cf7

File tree

6 files changed

+243
-19
lines changed

6 files changed

+243
-19
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: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from taskgraph.generator import load_tasks_for_kind
2929
from taskgraph.transforms import docker_image
3030
from taskgraph.util import docker, json
31+
from taskgraph.util.caches import CACHES
3132
from taskgraph.util.taskcluster import (
3233
find_task_id,
3334
get_artifact_url,
@@ -36,6 +37,7 @@
3637
get_task_definition,
3738
status_task,
3839
)
40+
from taskgraph.util.vcs import get_repository
3941

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

388390

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

@@ -432,6 +455,7 @@ def load_task(
432455
custom_image: Optional[str] = None,
433456
interactive: Optional[bool] = False,
434457
volumes: Optional[list[tuple[str, str]]] = None,
458+
develop: bool = False,
435459
) -> int:
436460
"""Load and run a task interactively in a Docker container.
437461
@@ -449,6 +473,8 @@ def load_task(
449473
interactive: If True, execution of the task will be paused and user
450474
will be dropped into a shell. They can run `exec-task` to resume
451475
it (default: False).
476+
develop: If True, the task will be configured to use the current
477+
local checkout at the current revision (default: False).
452478
453479
Returns:
454480
int: The exit code from the Docker container.
@@ -476,6 +502,10 @@ def load_task(
476502
logger.error("Only tasks using `run-task` are supported with --interactive!")
477503
return 1
478504

505+
if develop and not is_run_task:
506+
logger.error("Only tasks using `run-task` are supported with --develop!")
507+
return 1
508+
479509
try:
480510
image = custom_image or image
481511
image_tag = _resolve_image(image, graph_config)
@@ -484,6 +514,48 @@ def load_task(
484514
return 1
485515

486516
task_command = task_def["payload"].get("command") # type: ignore
517+
task_env = task_def["payload"].get("env", {})
518+
519+
if develop:
520+
repositories = json.loads(task_env.get("REPOSITORIES", "{}"))
521+
if not repositories:
522+
logger.error(
523+
"Can't use --develop with task that doesn't define any $REPOSITORIES!"
524+
)
525+
return 1
526+
527+
try:
528+
repo = get_repository(os.getcwd())
529+
except RuntimeError:
530+
logger.error("Can't use --develop from outside a source repository!")
531+
return 1
532+
533+
checkout_name = list(repositories.keys())[0]
534+
checkout_arg = f"--{checkout_name}-checkout"
535+
checkout_dir = _extract_arg(task_command, checkout_arg)
536+
if not checkout_dir:
537+
logger.error(
538+
f"Can't use --develop with task that doesn't use {checkout_arg}"
539+
)
540+
return 1
541+
volumes = volumes or []
542+
volumes.append((repo.path, checkout_dir))
543+
544+
# Delete cache environment variables for cache directories that aren't mounted.
545+
# This prevents tools from trying to write to inaccessible cache paths.
546+
mount_paths = {v[1] for v in volumes}
547+
for cache in CACHES.values():
548+
var = cache.get("env")
549+
if var in task_env and task_env[var] not in mount_paths:
550+
del task_env[var]
551+
552+
# Delete environment and arguments related to this repo so that
553+
# `run-task` doesn't attempt to fetch or checkout a new revision.
554+
del repositories[checkout_name]
555+
task_env["REPOSITORIES"] = json.dumps(repositories)
556+
for arg in ("checkout", "sparse-profile", "shallow-clone"):
557+
_delete_arg(task_command, f"--{checkout_name}-{arg}")
558+
487559
exec_command = task_cwd = None
488560
if interactive:
489561
# Remove the payload section of the task's command. This way run-task will
@@ -503,16 +575,7 @@ def load_task(
503575
]
504576

505577
# 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-
578+
task_cwd = _extract_arg(task_command, "--task-cwd") or "$TASK_WORKDIR"
516579
task_command = [
517580
"bash",
518581
"-c",
@@ -527,7 +590,7 @@ def load_task(
527590
"TASKCLUSTER_ROOT_URL": get_root_url(),
528591
}
529592
# Add the task's environment variables.
530-
env.update(task_def["payload"].get("env", {})) # type: ignore
593+
env.update(task_env) # type: ignore
531594

532595
# run-task expects the worker to mount a volume for each path defined in
533596
# TASKCLUSTER_CACHES; delete them to avoid needing to do the same, unless
@@ -545,6 +608,11 @@ def load_task(
545608
else:
546609
del env["TASKCLUSTER_CACHES"]
547610

611+
# run-task expects volumes listed under `TASKCLUSTER_VOLUMES` to be empty.
612+
# This can interfere with load-task when using custom volumes.
613+
if volumes and "TASKCLUSTER_VOLUMES" in env:
614+
del env["TASKCLUSTER_VOLUMES"]
615+
548616
envfile = None
549617
initfile = None
550618
isatty = os.isatty(sys.stdin.fileno())

src/taskgraph/main.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,19 @@ 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+
"--use-local-checkout",
777+
dest="develop",
778+
action="store_true",
779+
default=False,
780+
help="Configure the task to use the local source checkout at the current "
781+
"revision instead of cloning and using the revision from CI.",
782+
)
774783
@argument(
775784
"--keep",
776785
dest="remove",
@@ -805,6 +814,29 @@ def load_task(args):
805814
from taskgraph.docker import load_task # noqa: PLC0415
806815
from taskgraph.util import json # noqa: PLC0415
807816

817+
no_warn = "TASKGRAPH_LOAD_TASK_NO_WARN"
818+
if args["develop"] and not os.environ.get(no_warn):
819+
print(
820+
dedent(
821+
f"""
822+
warning: Using --develop can cause data loss.
823+
824+
Running `taskgraph load-task --develop` means the task will operate
825+
on your actual source repository. Make sure you verify the task
826+
doesn't perform any destructive operations against your repository.
827+
828+
Set {no_warn}=1 to disable this warning.
829+
"""
830+
).lstrip()
831+
)
832+
while True:
833+
proceed = input("Proceed? [y/N]: ").lower().strip()
834+
if proceed == "y":
835+
break
836+
if not proceed or proceed == "n":
837+
return 1
838+
print(f"invalid option: {proceed}")
839+
808840
validate_docker()
809841

810842
if args["task"] == "-":
@@ -837,6 +869,7 @@ def load_task(args):
837869
user=args["user"],
838870
custom_image=args["image"],
839871
volumes=volumes,
872+
develop=args["develop"],
840873
)
841874

842875

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: 56 additions & 6 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
@@ -87,11 +88,7 @@ def inner(
8788
# Testing with task definition directly
8889
input_arg = task
8990

90-
ret = docker.load_task(
91-
graph_config,
92-
input_arg,
93-
**kwargs
94-
)
91+
ret = docker.load_task(graph_config, input_arg, **kwargs)
9592
return ret, mocks
9693

9794
return inner
@@ -217,7 +214,9 @@ def test_load_task_env_init_and_remove(mocker, run_load_task):
217214
"image": {"taskId": image_task_id, "type": "task-image"},
218215
},
219216
}
220-
ret, mocks = run_load_task(task, interactive=True, volumes=[("/host/path", "/cache")])
217+
ret, mocks = run_load_task(
218+
task, interactive=True, volumes=[("/host/path", "/cache")]
219+
)
221220
assert ret == 0
222221

223222
# NamedTemporaryFile was called twice (once for env, once for init)
@@ -477,6 +476,57 @@ def test_load_task_with_custom_image_registry(mocker, run_load_task, task):
477476
assert not mocks["build_image"].called
478477

479478

479+
def test_load_task_with_develop(mocker, run_load_task, task):
480+
repo_name = "foo"
481+
repo_path = "/workdir/vcs"
482+
repo = get_repository(os.getcwd())
483+
484+
# No REPOSITORIES env
485+
ret, _ = run_load_task(task, develop=True)
486+
assert ret == 1
487+
488+
# No --checkout flag
489+
task["payload"]["env"] = {
490+
"REPOSITORIES": f'{{"{repo_name}": "{repo_path}"}}',
491+
"CARGO_HOME": "/cache/cargo",
492+
"PIP_CACHE_DIR": "/unmounted/pip",
493+
"UV_CACHE_DIR": "/unmounted/uv",
494+
}
495+
ret, mocks = run_load_task(task, develop=True)
496+
assert ret == 1
497+
498+
env_file = None
499+
task["payload"]["command"].insert(1, f"--{repo_name}-checkout={repo_path}")
500+
m = mocker.patch("os.remove")
501+
try:
502+
ret, mocks = run_load_task(
503+
task, develop=True, volumes=[("/host/cache", "/cache/cargo")]
504+
)
505+
assert ret == 0
506+
cmd = mocks["subprocess_run"].call_args[0][0]
507+
cmdstr = " ".join(cmd)
508+
assert f"-v {repo.path}:{repo_path}" in cmdstr
509+
assert "-v /host/cache:/cache/cargo" in cmdstr
510+
assert f"--{repo_name}-checkout" not in cmdstr
511+
512+
env_file = docker._extract_arg(cmd, "--env-file")
513+
assert env_file
514+
with open(env_file) as fh:
515+
contents = fh.read()
516+
517+
assert "TASKCLUSTER_VOLUMES" not in contents
518+
assert "REPOSITORIES" in contents
519+
assert "foo" not in contents
520+
# Verify cache env vars: mounted cache should be kept, unmounted should be removed
521+
assert "CARGO_HOME=/cache/cargo" in contents
522+
assert "PIP_CACHE_DIR" not in contents
523+
assert "UV_CACHE_DIR" not in contents
524+
finally:
525+
if env_file:
526+
m.reset_mock()
527+
os.remove(env_file)
528+
529+
480530
@pytest.fixture
481531
def run_build_image(mocker):
482532
def inner(image_name, save_image=None, context_file=None, image_task=None):

0 commit comments

Comments
 (0)