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 from gecko_taskgraph
import GECKO
25 from gecko_taskgraph
.files_changed
import get_locally_changed_files
27 Command
= namedtuple("Command", ["func", "args", "kwargs", "defaults"])
31 def command(*args
, **kwargs
):
32 defaults
= kwargs
.pop("defaults", {})
35 commands
[args
[0]] = Command(func
, args
, kwargs
, defaults
)
41 def argument(*args
, **kwargs
):
43 if not hasattr(func
, "args"):
45 func
.args
.append((args
, kwargs
))
51 def format_taskgraph_labels(taskgraph
):
54 taskgraph
.tasks
[index
].label
for index
in taskgraph
.graph
.visit_postorder()
59 def format_taskgraph_json(taskgraph
):
61 taskgraph
.to_json(), sort_keys
=True, indent
=2, separators
=(",", ": ")
65 def format_taskgraph_yaml(taskgraph
):
66 from mozbuild
.util
import ReadOnlyDict
68 class TGDumper(yaml
.SafeDumper
):
69 def ignore_aliases(self
, data
):
72 def represent_ro_dict(self
, data
):
73 return self
.represent_dict(dict(data
))
75 TGDumper
.add_representer(ReadOnlyDict
, TGDumper
.represent_ro_dict
)
77 return yaml
.dump(taskgraph
.to_json(), Dumper
=TGDumper
, default_flow_style
=False)
80 def get_filtered_taskgraph(taskgraph
, tasksregex
, exclude_keys
):
82 Filter all the tasks on basis of a regular expression
83 and returns a new TaskGraph object
85 from taskgraph
.graph
import Graph
86 from taskgraph
.task
import Task
87 from taskgraph
.taskgraph
import TaskGraph
90 named_links_dict
= taskgraph
.graph
.named_links_dict()
93 regexprogram
= re
.compile(tasksregex
)
95 for key
in taskgraph
.graph
.visit_postorder():
96 task
= taskgraph
.tasks
[key
]
97 if regexprogram
.match(task
.label
):
98 filteredtasks
[key
] = task
99 for depname
, dep
in named_links_dict
[key
].items():
100 if regexprogram
.match(dep
):
101 filterededges
.add((key
, dep
, depname
))
103 taskgraph
= TaskGraph(filteredtasks
, Graph(set(filteredtasks
), filterededges
))
106 for label
, task
in taskgraph
.tasks
.items():
107 task_dict
= task
.to_json()
108 for key
in exclude_keys
:
110 attrs
= key
.split(".")
111 while attrs
[0] in obj
:
117 taskgraph
.tasks
[label
] = Task
.from_json(task_dict
)
123 "labels": format_taskgraph_labels
,
124 "json": format_taskgraph_json
,
125 "yaml": format_taskgraph_yaml
,
129 def get_taskgraph_generator(root
, parameters
):
130 """Helper function to make testing a little easier."""
131 from taskgraph
.generator
import TaskGraphGenerator
133 return TaskGraphGenerator(root_dir
=root
, parameters
=parameters
)
136 def format_taskgraph(options
, parameters
, overrides
, logfile
=None):
138 from taskgraph
.parameters
import parameters_loader
141 handler
= logging
.FileHandler(logfile
, mode
="w")
142 if logging
.root
.handlers
:
143 oldhandler
= logging
.root
.handlers
[-1]
144 logging
.root
.removeHandler(oldhandler
)
145 handler
.setFormatter(oldhandler
.formatter
)
146 logging
.root
.addHandler(handler
)
149 taskgraph
.fast
= True
151 if isinstance(parameters
, str):
152 parameters
= parameters_loader(
158 tgg
= get_taskgraph_generator(options
.get("root"), parameters
)
160 tg
= getattr(tgg
, options
["graph_attr"])
161 tg
= get_filtered_taskgraph(tg
, options
["tasks_regex"], options
["exclude_keys"])
162 format_method
= FORMAT_METHODS
[options
["format"] or "labels"]
163 return format_method(tg
)
166 def dump_output(out
, path
=None, params_spec
=None):
167 from taskgraph
.parameters
import Parameters
169 params_name
= Parameters
.format_spec(params_spec
)
172 # Substitute params name into file path if necessary
173 if params_spec
and "{params}" not in path
:
174 name
, ext
= os
.path
.splitext(path
)
178 path
= path
.format(params
=params_name
)
182 "Dumping result with parameters from {}:".format(params_name
),
185 print(out
+ "\n", file=fh
)
188 def generate_taskgraph(options
, parameters
, overrides
, logdir
):
189 from taskgraph
.parameters
import Parameters
192 """Determine logfile given a parameters specification."""
197 "{}_{}.log".format(options
["graph_attr"], Parameters
.format_spec(spec
)),
200 # Don't bother using futures if there's only one parameter. This can make
201 # tracebacks a little more readable and avoids additional process overhead.
202 if len(parameters
) == 1:
204 out
= format_taskgraph(options
, spec
, overrides
, logfile(spec
))
205 dump_output(out
, options
["output_file"])
209 with
ProcessPoolExecutor(max_workers
=options
["max_workers"]) as executor
:
210 for spec
in parameters
:
212 format_taskgraph
, options
, spec
, overrides
, logfile(spec
)
216 for future
in as_completed(futures
):
217 output_file
= options
["output_file"]
218 spec
= futures
[future
]
219 e
= future
.exception()
221 out
= "".join(traceback
.format_exception(type(e
), e
, e
.__traceback
__))
223 # Dump to console so we don't accidentally diff the tracebacks.
226 out
= future
.result()
231 params_spec
=spec
if len(parameters
) > 1 else None,
237 help="Show all tasks in the taskgraph.",
238 defaults
={"graph_attr": "full_task_set"},
241 "full", help="Show the full taskgraph.", defaults
={"graph_attr": "full_task_graph"}
245 help="Show the set of target tasks.",
246 defaults
={"graph_attr": "target_task_set"},
250 help="Show the target graph.",
251 defaults
={"graph_attr": "target_task_graph"},
255 help="Show the optimized graph.",
256 defaults
={"graph_attr": "optimized_task_graph"},
260 help="Show the morphed graph.",
261 defaults
={"graph_attr": "morphed_task_graph"},
263 @argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
264 @argument("--quiet", "-q", action
="store_true", help="suppress all logging output")
266 "--verbose", "-v", action
="store_true", help="include debug-level logging output"
271 action
="store_const",
274 help="Output task graph as a JSON object",
279 action
="store_const",
282 help="Output task graph as a YAML object",
287 action
="store_const",
290 help="Output the label for each task in the task graph (default)",
297 help="Parameters to use for the generation. Can be a path to file (.yml or "
298 ".json; see `taskcluster/docs/parameters.rst`), a directory (containing "
299 "parameters files), a url, of the form `project=mozilla-central` to download "
300 "latest parameters file for the specified project from CI, or of the form "
301 "`task-id=<decision task id>` to download parameters from the specified "
302 "decision task. Can be specified multiple times, in which case multiple "
303 "generations will happen from the same invocation (one per parameters "
307 "--force-local-files-changed",
310 help="Compute the 'files-changed' parameter from local version control, "
311 "even when explicitly using a parameter set that already has it defined. "
312 "Note that this is already the default behaviour when no parameters are "
318 action
="store_false",
320 help="do not remove tasks from the graph that are found in the "
321 "index (a.k.a. optimize the graph)",
327 help="file path to store generated output.",
333 help="only return tasks with labels matching this regular " "expression.",
340 help="Exclude the specified key (using dot notation) from the final result. "
341 "This is mainly useful with '--diff' to filter out expected differences.",
349 help="only return tasks that are of the given kind, or their dependencies.",
356 help="enable fast task generation for local debugging.",
363 help="Generate and diff the current taskgraph against another revision. "
364 "Without args the base revision will be used. A revision specifier such as "
365 "the hash or `.~1` (hg) or `HEAD~1` (git) can be used as well.",
373 help="The maximum number of workers to use for parallel operations such as"
374 "when multiple parameters files are passed.",
376 def show_taskgraph(options
):
377 from mozversioncontrol
import get_repository_object
as get_repository
378 from taskgraph
.parameters
import Parameters
, parameters_loader
380 if options
.pop("verbose", False):
381 logging
.root
.setLevel(logging
.DEBUG
)
386 output_file
= options
["output_file"]
389 # --root argument is taskgraph's config at <repo>/taskcluster/ci
390 repo_root
= os
.getcwd()
392 repo_root
= f
"{options['root']}/../.."
393 repo
= get_repository(repo_root
)
395 if not repo
.working_directory_clean():
397 "abort: can't diff taskgraph with dirty working directory",
402 # We want to return the working directory to the current state
403 # as best we can after we're done. In all known cases, using
404 # branch or bookmark (which are both available on the VCS object)
405 # as `branch` is preferable to a specific revision.
406 cur_ref
= repo
.branch
or repo
.head_ref
[:12]
408 diffdir
= tempfile
.mkdtemp()
410 shutil
.rmtree
, diffdir
411 ) # make sure the directory gets cleaned up
412 options
["output_file"] = os
.path
.join(
413 diffdir
, f
"{options['graph_attr']}_{cur_ref}"
415 print(f
"Generating {options['graph_attr']} @ {cur_ref}", file=sys
.stderr
)
418 "target-kinds": options
.get("target_kinds"),
420 parameters
: List
[Any
[str, Parameters
]] = options
.pop("parameters")
423 parameters_loader(None, strict
=False, overrides
=overrides
)
424 ] # will use default values
426 # This is the default behaviour anyway, so no need to re-compute.
427 options
["force_local_files_changed"] = False
429 elif options
["force_local_files_changed"]:
430 overrides
["files-changed"] = sorted(get_locally_changed_files(GECKO
))
432 for param
in parameters
[:]:
433 if isinstance(param
, str) and os
.path
.isdir(param
):
434 parameters
.remove(param
)
438 for p
in Path(param
).iterdir()
439 if p
.suffix
in (".yml", ".json")
444 if len(parameters
) > 1:
445 # Log to separate files for each process instead of stderr to
446 # avoid interleaving.
447 basename
= os
.path
.basename(os
.getcwd())
448 logdir
= os
.path
.join(appdirs
.user_log_dir("taskgraph"), basename
)
449 if not os
.path
.isdir(logdir
):
452 # Only setup logging if we have a single parameter spec. Otherwise
453 # logging will go to files. This is also used as a hook for Gecko
454 # to setup its `mach` based logging.
457 generate_taskgraph(options
, parameters
, overrides
, logdir
)
460 assert diffdir
is not None
461 assert repo
is not None
463 # Reload taskgraph modules to pick up changes and clear global state.
464 for mod
in sys
.modules
.copy():
465 if mod
!= __name__
and mod
.split(".", 1)[0].endswith(
466 ("taskgraph", "mozbuild")
470 # Ensure gecko_taskgraph is ahead of taskcluster_taskgraph in sys.path.
471 # Without this, we may end up validating some things against the wrong
473 import gecko_taskgraph
# noqa
475 if options
["diff"] == "default":
476 base_ref
= repo
.base_ref
478 base_ref
= options
["diff"]
481 repo
.update(base_ref
)
482 base_ref
= repo
.head_ref
[:12]
483 options
["output_file"] = os
.path
.join(
484 diffdir
, f
"{options['graph_attr']}_{base_ref}"
486 print(f
"Generating {options['graph_attr']} @ {base_ref}", file=sys
.stderr
)
487 generate_taskgraph(options
, parameters
, overrides
, logdir
)
495 "--report-identical-files",
496 f
"--label={options['graph_attr']}@{base_ref}",
497 f
"--label={options['graph_attr']}@{cur_ref}",
500 non_fatal_failures
= []
501 for spec
in parameters
:
502 base_path
= os
.path
.join(diffdir
, f
"{options['graph_attr']}_{base_ref}")
503 cur_path
= os
.path
.join(diffdir
, f
"{options['graph_attr']}_{cur_ref}")
506 if len(parameters
) > 1:
507 params_name
= Parameters
.format_spec(spec
)
508 base_path
+= f
"_{params_name}"
509 cur_path
+= f
"_{params_name}"
511 # If the base or cur files are missing it means that generation
512 # failed. If one of them failed but not the other, the failure is
513 # likely due to the patch making changes to taskgraph in modules
514 # that don't get reloaded (safe to ignore). If both generations
515 # failed, there's likely a real issue.
516 base_missing
= not os
.path
.isfile(base_path
)
517 cur_missing
= not os
.path
.isfile(cur_path
)
518 if base_missing
!= cur_missing
: # != is equivalent to XOR for booleans
519 non_fatal_failures
.append(os
.path
.basename(base_path
))
523 # If the output file(s) are missing, this command will raise
524 # CalledProcessError with a returncode > 1.
525 proc
= subprocess
.run(
526 diffcmd
+ [base_path
, cur_path
],
527 stdout
=subprocess
.PIPE
,
528 stderr
=subprocess
.PIPE
,
529 universal_newlines
=True,
532 diff_output
= proc
.stdout
534 except subprocess
.CalledProcessError
as e
:
535 # returncode 1 simply means diffs were found
536 if e
.returncode
!= 1:
537 print(e
.stderr
, file=sys
.stderr
)
539 diff_output
= e
.output
540 returncode
= e
.returncode
544 # Don't bother saving file if no diffs were found. Log to
545 # console in this case instead.
546 path
=None if returncode
== 0 else output_file
,
547 params_spec
=spec
if len(parameters
) > 1 else None,
550 if non_fatal_failures
:
551 failstr
= "\n ".join(sorted(non_fatal_failures
))
553 "WARNING: Diff skipped for the following generation{s} "
554 "due to failures:\n {failstr}".format(
555 s
="s" if len(non_fatal_failures
) > 1 else "", failstr
=failstr
560 if options
["format"] != "json":
562 "If you were expecting differences in task bodies "
563 'you should pass "-J"\n',
567 if len(parameters
) > 1:
568 print("See '{}' for logs".format(logdir
), file=sys
.stderr
)
571 @command("build-image", help="Build a Docker image")
572 @argument("image_name", help="Name of the image to build")
574 "-t", "--tag", help="tag that the image should be built as.", metavar
="name:tag"
578 help="File name the context tarball should be written to."
579 "with this option it will only build the context.tar.",
580 metavar
="context.tar",
582 def build_image(args
):
583 from gecko_taskgraph
.docker
import build_context
, build_image
585 if args
["context_only"] is None:
586 build_image(args
["image_name"], args
["tag"], os
.environ
)
588 build_context(args
["image_name"], args
["context_only"], os
.environ
)
593 help="Load a pre-built Docker image. Note that you need to "
594 "have docker installed and running for this to work.",
598 help="Load the image at public/image.tar.zst in this task, "
599 "rather than searching the index",
604 help="tag that the image should be loaded as. If not "
605 "image will be loaded with tag from the tarball",
611 help="Load the image of this name based on the current "
612 "contents of the tree (as built for mozilla-central "
613 "or mozilla-inbound)",
615 def load_image(args
):
616 from gecko_taskgraph
.docker
import load_image_by_name
, load_image_by_task_id
618 if not args
.get("image_name") and not args
.get("task_id"):
619 print("Specify either IMAGE-NAME or TASK-ID")
623 ok
= load_image_by_task_id(args
["task_id"], args
.get("tag"))
625 ok
= load_image_by_name(args
["image_name"], args
.get("tag"))
629 traceback
.print_exc()
633 @command("image-digest", help="Print the digest of a docker image.")
636 help="Print the digest of the image of this name based on the current "
637 "contents of the tree.",
639 def image_digest(args
):
640 from gecko_taskgraph
.docker
import get_image_digest
643 digest
= get_image_digest(args
["image_name"])
646 traceback
.print_exc()
650 @command("decision", help="Run the decision task")
651 @argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
655 help=argparse
.SUPPRESS
,
660 help="Project to use for creating task graph. Example: --project=try",
662 @argument("--pushlog-id", dest
="pushlog_id", required
=True, default
="0")
663 @argument("--pushdate", dest
="pushdate", required
=True, type=int, default
=0)
664 @argument("--owner", required
=True, help="email address of who owns this graph")
665 @argument("--level", required
=True, help="SCM level of this repository")
667 "--target-tasks-method", help="method for selecting the target tasks to generate"
672 help='Type of repository, either "hg" or "git"',
674 @argument("--base-repository", required
=True, help='URL for "base" repository to clone')
676 "--base-ref", default
="", help='Reference of the revision in the "base" repository'
681 help="Taskgraph decides what to do based on the revision range between "
682 "`--base-rev` and `--head-rev`. Value is determined automatically if not provided",
687 help='URL for "head" repository to fetch revision from',
690 "--head-ref", required
=True, help="Reference (this is same as rev usually for hg)"
693 "--head-rev", required
=True, help="Commit revision to use from head repository"
695 @argument("--head-tag", help="Tag attached to the revision", default
="")
697 "--tasks-for", required
=True, help="the tasks_for value used to generate this task"
699 @argument("--try-task-config-file", help="path to try task configuration file")
700 def decision(options
):
701 from gecko_taskgraph
.decision
import taskgraph_decision
703 taskgraph_decision(options
)
706 @command("action-callback", description
="Run action callback used by action tasks")
710 default
="taskcluster/ci",
711 help="root of the taskgraph definition relative to topsrcdir",
713 def action_callback(options
):
714 from gecko_taskgraph
.actions
import trigger_action_callback
715 from gecko_taskgraph
.actions
.util
import get_parameters
718 # the target task for this action (or null if it's a group action)
719 task_id
= json
.loads(os
.environ
.get("ACTION_TASK_ID", "null"))
720 # the target task group for this action
721 task_group_id
= os
.environ
.get("ACTION_TASK_GROUP_ID", None)
722 input = json
.loads(os
.environ
.get("ACTION_INPUT", "null"))
723 callback
= os
.environ
.get("ACTION_CALLBACK", None)
724 root
= options
["root"]
726 parameters
= get_parameters(task_group_id
)
728 return trigger_action_callback(
729 task_group_id
=task_group_id
,
733 parameters
=parameters
,
738 traceback
.print_exc()
742 @command("test-action-callback", description
="Run an action callback in a testing mode")
746 default
="taskcluster/ci",
747 help="root of the taskgraph definition relative to topsrcdir",
753 help="parameters file (.yml or .json; see " "`taskcluster/docs/parameters.rst`)`",
755 @argument("--task-id", default
=None, help="TaskId to which the action applies")
757 "--task-group-id", default
=None, help="TaskGroupId to which the action applies"
759 @argument("--input", default
=None, help="Action input (.yml or .json)")
760 @argument("callback", default
=None, help="Action callback name (Python function name)")
761 def test_action_callback(options
):
762 import taskgraph
.parameters
763 from taskgraph
.config
import load_graph_config
764 from taskgraph
.util
import yaml
766 import gecko_taskgraph
.actions
768 def load_data(filename
):
769 with
open(filename
) as f
:
770 if filename
.endswith(".yml"):
771 return yaml
.load_stream(f
)
772 if filename
.endswith(".json"):
774 raise Exception(f
"unknown filename {filename}")
777 task_id
= options
["task_id"]
780 input = load_data(options
["input"])
784 root
= options
["root"]
785 graph_config
= load_graph_config(root
)
786 trust_domain
= graph_config
["trust-domain"]
787 graph_config
.register()
789 parameters
= taskgraph
.parameters
.load_parameters_file(
790 options
["parameters"], strict
=False, trust_domain
=trust_domain
794 return gecko_taskgraph
.actions
.trigger_action_callback(
795 task_group_id
=options
["task_group_id"],
798 callback
=options
["callback"],
799 parameters
=parameters
,
804 traceback
.print_exc()
809 parser
= argparse
.ArgumentParser(description
="Interact with taskgraph")
810 subparsers
= parser
.add_subparsers()
811 for _
, (func
, args
, kwargs
, defaults
) in commands
.items():
812 subparser
= subparsers
.add_parser(*args
, **kwargs
)
813 for arg
in func
.args
:
814 subparser
.add_argument(*arg
[0], **arg
[1])
815 subparser
.set_defaults(command
=func
, **defaults
)
821 format
="%(asctime)s - %(levelname)s - %(message)s", level
=logging
.INFO
825 def main(args
=sys
.argv
[1:]):
827 parser
= create_parser()
828 args
= parser
.parse_args(args
)
830 args
.command(vars(args
))
832 traceback
.print_exc()