1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
16 from collections
import namedtuple
17 from concurrent
.futures
import ProcessPoolExecutor
, as_completed
18 from pathlib
import Path
19 from typing
import Any
, List
24 Command
= namedtuple("Command", ["func", "args", "kwargs", "defaults"])
28 def command(*args
, **kwargs
):
29 defaults
= kwargs
.pop("defaults", {})
32 commands
[args
[0]] = Command(func
, args
, kwargs
, defaults
)
38 def argument(*args
, **kwargs
):
40 if not hasattr(func
, "args"):
42 func
.args
.append((args
, kwargs
))
48 def format_taskgraph_labels(taskgraph
):
51 taskgraph
.tasks
[index
].label
for index
in taskgraph
.graph
.visit_postorder()
56 def format_taskgraph_json(taskgraph
):
58 taskgraph
.to_json(), sort_keys
=True, indent
=2, separators
=(",", ": ")
62 def format_taskgraph_yaml(taskgraph
):
63 return yaml
.safe_dump(taskgraph
.to_json(), default_flow_style
=False)
66 def get_filtered_taskgraph(taskgraph
, tasksregex
):
68 Filter all the tasks on basis of a regular expression
69 and returns a new TaskGraph object
71 from taskgraph
.graph
import Graph
72 from taskgraph
.taskgraph
import TaskGraph
74 # return original taskgraph if no regular expression is passed
77 named_links_dict
= taskgraph
.graph
.named_links_dict()
80 regexprogram
= re
.compile(tasksregex
)
82 for key
in taskgraph
.graph
.visit_postorder():
83 task
= taskgraph
.tasks
[key
]
84 if regexprogram
.match(task
.label
):
85 filteredtasks
[key
] = task
86 for depname
, dep
in named_links_dict
[key
].items():
87 if regexprogram
.match(dep
):
88 filterededges
.add((key
, dep
, depname
))
89 filtered_taskgraph
= TaskGraph(
90 filteredtasks
, Graph(set(filteredtasks
), filterededges
)
92 return filtered_taskgraph
96 "labels": format_taskgraph_labels
,
97 "json": format_taskgraph_json
,
98 "yaml": format_taskgraph_yaml
,
102 def get_taskgraph_generator(root
, parameters
):
103 """Helper function to make testing a little easier."""
104 from taskgraph
.generator
import TaskGraphGenerator
106 return TaskGraphGenerator(root_dir
=root
, parameters
=parameters
)
109 def format_taskgraph(options
, parameters
, logfile
=None):
111 from taskgraph
.parameters
import parameters_loader
114 handler
= logging
.FileHandler(logfile
, mode
="w")
115 if logging
.root
.handlers
:
116 oldhandler
= logging
.root
.handlers
[-1]
117 logging
.root
.removeHandler(oldhandler
)
118 handler
.setFormatter(oldhandler
.formatter
)
119 logging
.root
.addHandler(handler
)
122 taskgraph
.fast
= True
124 if isinstance(parameters
, str):
125 parameters
= parameters_loader(
127 overrides
={"target-kind": options
.get("target_kind")},
131 tgg
= get_taskgraph_generator(options
.get("root"), parameters
)
133 tg
= getattr(tgg
, options
["graph_attr"])
134 tg
= get_filtered_taskgraph(tg
, options
["tasks_regex"])
135 format_method
= FORMAT_METHODS
[options
["format"] or "labels"]
136 return format_method(tg
)
139 def dump_output(out
, path
=None, params_spec
=None):
140 from taskgraph
.parameters
import Parameters
142 params_name
= Parameters
.format_spec(params_spec
)
145 # Substitute params name into file path if necessary
146 if params_spec
and "{params}" not in path
:
147 name
, ext
= os
.path
.splitext(path
)
151 path
= path
.format(params
=params_name
)
155 "Dumping result with parameters from {}:".format(params_name
),
158 print(out
+ "\n", file=fh
)
161 def generate_taskgraph(options
, parameters
, logdir
):
162 from taskgraph
.parameters
import Parameters
165 """Determine logfile given a parameters specification."""
170 "{}_{}.log".format(options
["graph_attr"], Parameters
.format_spec(spec
)),
173 # Don't bother using futures if there's only one parameter. This can make
174 # tracebacks a little more readable and avoids additional process overhead.
175 if len(parameters
) == 1:
177 out
= format_taskgraph(options
, spec
, logfile(spec
))
178 dump_output(out
, options
["output_file"])
182 with
ProcessPoolExecutor() as executor
:
183 for spec
in parameters
:
184 f
= executor
.submit(format_taskgraph
, options
, spec
, logfile(spec
))
187 for future
in as_completed(futures
):
188 output_file
= options
["output_file"]
189 spec
= futures
[future
]
190 e
= future
.exception()
192 out
= "".join(traceback
.format_exception(type(e
), e
, e
.__traceback
__))
194 # Dump to console so we don't accidentally diff the tracebacks.
197 out
= future
.result()
202 params_spec
=spec
if len(parameters
) > 1 else None,
208 help="Show all tasks in the taskgraph.",
209 defaults
={"graph_attr": "full_task_set"},
212 "full", help="Show the full taskgraph.", defaults
={"graph_attr": "full_task_graph"}
216 help="Show the set of target tasks.",
217 defaults
={"graph_attr": "target_task_set"},
221 help="Show the target graph.",
222 defaults
={"graph_attr": "target_task_graph"},
226 help="Show the optimized graph.",
227 defaults
={"graph_attr": "optimized_task_graph"},
231 help="Show the morphed graph.",
232 defaults
={"graph_attr": "morphed_task_graph"},
234 @argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
235 @argument("--quiet", "-q", action
="store_true", help="suppress all logging output")
237 "--verbose", "-v", action
="store_true", help="include debug-level logging output"
242 action
="store_const",
245 help="Output task graph as a JSON object",
250 action
="store_const",
253 help="Output task graph as a YAML object",
258 action
="store_const",
261 help="Output the label for each task in the task graph (default)",
268 help="Parameters to use for the generation. Can be a path to file (.yml or "
269 ".json; see `taskcluster/docs/parameters.rst`), a directory (containing "
270 "parameters files), a url, of the form `project=mozilla-central` to download "
271 "latest parameters file for the specified project from CI, or of the form "
272 "`task-id=<decision task id>` to download parameters from the specified "
273 "decision task. Can be specified multiple times, in which case multiple "
274 "generations will happen from the same invocation (one per parameters "
280 action
="store_false",
282 help="do not remove tasks from the graph that are found in the "
283 "index (a.k.a. optimize the graph)",
289 help="file path to store generated output.",
295 help="only return tasks with labels matching this regular " "expression.",
300 help="only return tasks that are of the given kind, or their dependencies.",
307 help="enable fast task generation for local debugging.",
314 help="Generate and diff the current taskgraph against another revision. "
315 "Without args the base revision will be used. A revision specifier such as "
316 "the hash or `.~1` (hg) or `HEAD~1` (git) can be used as well.",
318 def show_taskgraph(options
):
319 from mozversioncontrol
import get_repository_object
as get_repository
320 from taskgraph
.parameters
import Parameters
, parameters_loader
322 if options
.pop("verbose", False):
323 logging
.root
.setLevel(logging
.DEBUG
)
328 output_file
= options
["output_file"]
331 repo
= get_repository(os
.getcwd())
333 if not repo
.working_directory_clean():
335 "abort: can't diff taskgraph with dirty working directory",
340 # We want to return the working directory to the current state
341 # as best we can after we're done. In all known cases, using
342 # branch or bookmark (which are both available on the VCS object)
343 # as `branch` is preferable to a specific revision.
344 cur_ref
= repo
.branch
or repo
.head_ref
[:12]
346 diffdir
= tempfile
.mkdtemp()
348 shutil
.rmtree
, diffdir
349 ) # make sure the directory gets cleaned up
350 options
["output_file"] = os
.path
.join(
351 diffdir
, f
"{options['graph_attr']}_{cur_ref}"
353 print(f
"Generating {options['graph_attr']} @ {cur_ref}", file=sys
.stderr
)
355 parameters
: List
[Any
[str, Parameters
]] = options
.pop("parameters")
358 "target-kind": options
.get("target_kind"),
361 parameters_loader(None, strict
=False, overrides
=overrides
)
362 ] # will use default values
364 for param
in parameters
[:]:
365 if isinstance(param
, str) and os
.path
.isdir(param
):
366 parameters
.remove(param
)
370 for p
in Path(param
).iterdir()
371 if p
.suffix
in (".yml", ".json")
376 if len(parameters
) > 1:
377 # Log to separate files for each process instead of stderr to
378 # avoid interleaving.
379 basename
= os
.path
.basename(os
.getcwd())
380 logdir
= os
.path
.join(appdirs
.user_log_dir("taskgraph"), basename
)
381 if not os
.path
.isdir(logdir
):
384 # Only setup logging if we have a single parameter spec. Otherwise
385 # logging will go to files. This is also used as a hook for Gecko
386 # to setup its `mach` based logging.
389 generate_taskgraph(options
, parameters
, logdir
)
392 assert diffdir
is not None
393 assert repo
is not None
395 # Reload taskgraph modules to pick up changes and clear global state.
396 for mod
in sys
.modules
.copy():
397 if mod
!= __name__
and mod
.split(".", 1)[0].endswith(
398 ("taskgraph", "mozbuild")
402 # Ensure gecko_taskgraph is ahead of taskcluster_taskgraph in sys.path.
403 # Without this, we may end up validating some things against the wrong
405 import gecko_taskgraph
# noqa
407 if options
["diff"] == "default":
408 base_ref
= repo
.base_ref
410 base_ref
= options
["diff"]
413 repo
.update(base_ref
)
414 base_ref
= repo
.head_ref
[:12]
415 options
["output_file"] = os
.path
.join(
416 diffdir
, f
"{options['graph_attr']}_{base_ref}"
418 print(f
"Generating {options['graph_attr']} @ {base_ref}", file=sys
.stderr
)
419 generate_taskgraph(options
, parameters
, logdir
)
427 "--report-identical-files",
428 f
"--label={options['graph_attr']}@{base_ref}",
429 f
"--label={options['graph_attr']}@{cur_ref}",
432 non_fatal_failures
= []
433 for spec
in parameters
:
434 base_path
= os
.path
.join(diffdir
, f
"{options['graph_attr']}_{base_ref}")
435 cur_path
= os
.path
.join(diffdir
, f
"{options['graph_attr']}_{cur_ref}")
438 if len(parameters
) > 1:
439 params_name
= Parameters
.format_spec(spec
)
440 base_path
+= f
"_{params_name}"
441 cur_path
+= f
"_{params_name}"
443 # If the base or cur files are missing it means that generation
444 # failed. If one of them failed but not the other, the failure is
445 # likely due to the patch making changes to taskgraph in modules
446 # that don't get reloaded (safe to ignore). If both generations
447 # failed, there's likely a real issue.
448 base_missing
= not os
.path
.isfile(base_path
)
449 cur_missing
= not os
.path
.isfile(cur_path
)
450 if base_missing
!= cur_missing
: # != is equivalent to XOR for booleans
451 non_fatal_failures
.append(os
.path
.basename(base_path
))
455 # If the output file(s) are missing, this command will raise
456 # CalledProcessError with a returncode > 1.
457 proc
= subprocess
.run(
458 diffcmd
+ [base_path
, cur_path
],
459 stdout
=subprocess
.PIPE
,
460 stderr
=subprocess
.PIPE
,
461 universal_newlines
=True,
464 diff_output
= proc
.stdout
466 except subprocess
.CalledProcessError
as e
:
467 # returncode 1 simply means diffs were found
468 if e
.returncode
!= 1:
469 print(e
.stderr
, file=sys
.stderr
)
471 diff_output
= e
.output
472 returncode
= e
.returncode
476 # Don't bother saving file if no diffs were found. Log to
477 # console in this case instead.
478 path
=None if returncode
== 0 else output_file
,
479 params_spec
=spec
if len(parameters
) > 1 else None,
482 if non_fatal_failures
:
483 failstr
= "\n ".join(sorted(non_fatal_failures
))
485 "WARNING: Diff skipped for the following generation{s} "
486 "due to failures:\n {failstr}".format(
487 s
="s" if len(non_fatal_failures
) > 1 else "", failstr
=failstr
492 if options
["format"] != "json":
494 "If you were expecting differences in task bodies "
495 'you should pass "-J"\n',
499 if len(parameters
) > 1:
500 print("See '{}' for logs".format(logdir
), file=sys
.stderr
)
503 @command("build-image", help="Build a Docker image")
504 @argument("image_name", help="Name of the image to build")
506 "-t", "--tag", help="tag that the image should be built as.", metavar
="name:tag"
510 help="File name the context tarball should be written to."
511 "with this option it will only build the context.tar.",
512 metavar
="context.tar",
514 def build_image(args
):
515 from gecko_taskgraph
.docker
import build_context
, build_image
517 if args
["context_only"] is None:
518 build_image(args
["image_name"], args
["tag"], os
.environ
)
520 build_context(args
["image_name"], args
["context_only"], os
.environ
)
525 help="Load a pre-built Docker image. Note that you need to "
526 "have docker installed and running for this to work.",
530 help="Load the image at public/image.tar.zst in this task, "
531 "rather than searching the index",
536 help="tag that the image should be loaded as. If not "
537 "image will be loaded with tag from the tarball",
543 help="Load the image of this name based on the current "
544 "contents of the tree (as built for mozilla-central "
545 "or mozilla-inbound)",
547 def load_image(args
):
548 from gecko_taskgraph
.docker
import load_image_by_name
, load_image_by_task_id
550 if not args
.get("image_name") and not args
.get("task_id"):
551 print("Specify either IMAGE-NAME or TASK-ID")
555 ok
= load_image_by_task_id(args
["task_id"], args
.get("tag"))
557 ok
= load_image_by_name(args
["image_name"], args
.get("tag"))
561 traceback
.print_exc()
565 @command("image-digest", help="Print the digest of a docker image.")
568 help="Print the digest of the image of this name based on the current "
569 "contents of the tree.",
571 def image_digest(args
):
572 from gecko_taskgraph
.docker
import get_image_digest
575 digest
= get_image_digest(args
["image_name"])
578 traceback
.print_exc()
582 @command("decision", help="Run the decision task")
583 @argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
587 help=argparse
.SUPPRESS
,
592 help="Project to use for creating task graph. Example: --project=try",
594 @argument("--pushlog-id", dest
="pushlog_id", required
=True, default
="0")
595 @argument("--pushdate", dest
="pushdate", required
=True, type=int, default
=0)
596 @argument("--owner", required
=True, help="email address of who owns this graph")
597 @argument("--level", required
=True, help="SCM level of this repository")
599 "--target-tasks-method", help="method for selecting the target tasks to generate"
604 help='Type of repository, either "hg" or "git"',
606 @argument("--base-repository", required
=True, help='URL for "base" repository to clone')
608 "--base-ref", default
="", help='Reference of the revision in the "base" repository'
613 help="Taskgraph decides what to do based on the revision range between "
614 "`--base-rev` and `--head-rev`. Value is determined automatically if not provided",
619 help='URL for "head" repository to fetch revision from',
622 "--head-ref", required
=True, help="Reference (this is same as rev usually for hg)"
625 "--head-rev", required
=True, help="Commit revision to use from head repository"
627 @argument("--head-tag", help="Tag attached to the revision", default
="")
629 "--tasks-for", required
=True, help="the tasks_for value used to generate this task"
631 @argument("--try-task-config-file", help="path to try task configuration file")
632 def decision(options
):
633 from gecko_taskgraph
.decision
import taskgraph_decision
635 taskgraph_decision(options
)
638 @command("action-callback", description
="Run action callback used by action tasks")
642 default
="taskcluster/ci",
643 help="root of the taskgraph definition relative to topsrcdir",
645 def action_callback(options
):
646 from gecko_taskgraph
.actions
import trigger_action_callback
647 from gecko_taskgraph
.actions
.util
import get_parameters
650 # the target task for this action (or null if it's a group action)
651 task_id
= json
.loads(os
.environ
.get("ACTION_TASK_ID", "null"))
652 # the target task group for this action
653 task_group_id
= os
.environ
.get("ACTION_TASK_GROUP_ID", None)
654 input = json
.loads(os
.environ
.get("ACTION_INPUT", "null"))
655 callback
= os
.environ
.get("ACTION_CALLBACK", None)
656 root
= options
["root"]
658 parameters
= get_parameters(task_group_id
)
660 return trigger_action_callback(
661 task_group_id
=task_group_id
,
665 parameters
=parameters
,
670 traceback
.print_exc()
674 @command("test-action-callback", description
="Run an action callback in a testing mode")
678 default
="taskcluster/ci",
679 help="root of the taskgraph definition relative to topsrcdir",
685 help="parameters file (.yml or .json; see " "`taskcluster/docs/parameters.rst`)`",
687 @argument("--task-id", default
=None, help="TaskId to which the action applies")
689 "--task-group-id", default
=None, help="TaskGroupId to which the action applies"
691 @argument("--input", default
=None, help="Action input (.yml or .json)")
692 @argument("callback", default
=None, help="Action callback name (Python function name)")
693 def test_action_callback(options
):
694 import taskgraph
.parameters
695 from taskgraph
.config
import load_graph_config
696 from taskgraph
.util
import yaml
698 import gecko_taskgraph
.actions
700 def load_data(filename
):
701 with
open(filename
) as f
:
702 if filename
.endswith(".yml"):
703 return yaml
.load_stream(f
)
704 if filename
.endswith(".json"):
706 raise Exception(f
"unknown filename {filename}")
709 task_id
= options
["task_id"]
712 input = load_data(options
["input"])
716 root
= options
["root"]
717 graph_config
= load_graph_config(root
)
718 trust_domain
= graph_config
["trust-domain"]
719 graph_config
.register()
721 parameters
= taskgraph
.parameters
.load_parameters_file(
722 options
["parameters"], strict
=False, trust_domain
=trust_domain
726 return gecko_taskgraph
.actions
.trigger_action_callback(
727 task_group_id
=options
["task_group_id"],
730 callback
=options
["callback"],
731 parameters
=parameters
,
736 traceback
.print_exc()
741 parser
= argparse
.ArgumentParser(description
="Interact with taskgraph")
742 subparsers
= parser
.add_subparsers()
743 for _
, (func
, args
, kwargs
, defaults
) in commands
.items():
744 subparser
= subparsers
.add_parser(*args
, **kwargs
)
745 for arg
in func
.args
:
746 subparser
.add_argument(*arg
[0], **arg
[1])
747 subparser
.set_defaults(command
=func
, **defaults
)
753 format
="%(asctime)s - %(levelname)s - %(message)s", level
=logging
.INFO
757 def main(args
=sys
.argv
[1:]):
759 parser
= create_parser()
760 args
= parser
.parse_args(args
)
762 args
.command(vars(args
))
764 traceback
.print_exc()