Bug 1838739 - Initialize result of SetAsGPUOutOfMemoryError. r=webgpu-reviewers,nical
[gecko.git] / taskcluster / gecko_taskgraph / main.py
blobbb49ba600046d3aa09a4c6b98eb865fba6eca4cd
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/.
5 import argparse
6 import atexit
7 import json
8 import logging
9 import os
10 import re
11 import shutil
12 import subprocess
13 import sys
14 import tempfile
15 import traceback
16 from collections import namedtuple
17 from concurrent.futures import ProcessPoolExecutor, as_completed
18 from pathlib import Path
19 from typing import Any, List
21 import appdirs
22 import yaml
24 Command = namedtuple("Command", ["func", "args", "kwargs", "defaults"])
25 commands = {}
28 def command(*args, **kwargs):
29 defaults = kwargs.pop("defaults", {})
31 def decorator(func):
32 commands[args[0]] = Command(func, args, kwargs, defaults)
33 return func
35 return decorator
38 def argument(*args, **kwargs):
39 def decorator(func):
40 if not hasattr(func, "args"):
41 func.args = []
42 func.args.append((args, kwargs))
43 return func
45 return decorator
48 def format_taskgraph_labels(taskgraph):
49 return "\n".join(
50 sorted(
51 taskgraph.tasks[index].label for index in taskgraph.graph.visit_postorder()
56 def format_taskgraph_json(taskgraph):
57 return json.dumps(
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):
67 """
68 Filter all the tasks on basis of a regular expression
69 and returns a new TaskGraph object
70 """
71 from taskgraph.graph import Graph
72 from taskgraph.taskgraph import TaskGraph
74 # return original taskgraph if no regular expression is passed
75 if not tasksregex:
76 return taskgraph
77 named_links_dict = taskgraph.graph.named_links_dict()
78 filteredtasks = {}
79 filterededges = set()
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
95 FORMAT_METHODS = {
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):
110 import taskgraph
111 from taskgraph.parameters import parameters_loader
113 if logfile:
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)
121 if options["fast"]:
122 taskgraph.fast = True
124 if isinstance(parameters, str):
125 parameters = parameters_loader(
126 parameters,
127 overrides={"target-kind": options.get("target_kind")},
128 strict=False,
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)
143 fh = None
144 if path:
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)
148 name += "_{params}"
149 path = name + ext
151 path = path.format(params=params_name)
152 fh = open(path, "w")
153 else:
154 print(
155 "Dumping result with parameters from {}:".format(params_name),
156 file=sys.stderr,
158 print(out + "\n", file=fh)
161 def generate_taskgraph(options, parameters, logdir):
162 from taskgraph.parameters import Parameters
164 def logfile(spec):
165 """Determine logfile given a parameters specification."""
166 if logdir is None:
167 return None
168 return os.path.join(
169 logdir,
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:
176 spec = parameters[0]
177 out = format_taskgraph(options, spec, logfile(spec))
178 dump_output(out, options["output_file"])
179 return
181 futures = {}
182 with ProcessPoolExecutor() as executor:
183 for spec in parameters:
184 f = executor.submit(format_taskgraph, options, spec, logfile(spec))
185 futures[f] = spec
187 for future in as_completed(futures):
188 output_file = options["output_file"]
189 spec = futures[future]
190 e = future.exception()
191 if e:
192 out = "".join(traceback.format_exception(type(e), e, e.__traceback__))
193 if options["diff"]:
194 # Dump to console so we don't accidentally diff the tracebacks.
195 output_file = None
196 else:
197 out = future.result()
199 dump_output(
200 out,
201 path=output_file,
202 params_spec=spec if len(parameters) > 1 else None,
206 @command(
207 "tasks",
208 help="Show all tasks in the taskgraph.",
209 defaults={"graph_attr": "full_task_set"},
211 @command(
212 "full", help="Show the full taskgraph.", defaults={"graph_attr": "full_task_graph"}
214 @command(
215 "target",
216 help="Show the set of target tasks.",
217 defaults={"graph_attr": "target_task_set"},
219 @command(
220 "target-graph",
221 help="Show the target graph.",
222 defaults={"graph_attr": "target_task_graph"},
224 @command(
225 "optimized",
226 help="Show the optimized graph.",
227 defaults={"graph_attr": "optimized_task_graph"},
229 @command(
230 "morphed",
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")
236 @argument(
237 "--verbose", "-v", action="store_true", help="include debug-level logging output"
239 @argument(
240 "--json",
241 "-J",
242 action="store_const",
243 dest="format",
244 const="json",
245 help="Output task graph as a JSON object",
247 @argument(
248 "--yaml",
249 "-Y",
250 action="store_const",
251 dest="format",
252 const="yaml",
253 help="Output task graph as a YAML object",
255 @argument(
256 "--labels",
257 "-L",
258 action="store_const",
259 dest="format",
260 const="labels",
261 help="Output the label for each task in the task graph (default)",
263 @argument(
264 "--parameters",
265 "-p",
266 default=None,
267 action="append",
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 "
275 "specified).",
277 @argument(
278 "--no-optimize",
279 dest="optimize",
280 action="store_false",
281 default="true",
282 help="do not remove tasks from the graph that are found in the "
283 "index (a.k.a. optimize the graph)",
285 @argument(
286 "-o",
287 "--output-file",
288 default=None,
289 help="file path to store generated output.",
291 @argument(
292 "--tasks-regex",
293 "--tasks",
294 default=None,
295 help="only return tasks with labels matching this regular " "expression.",
297 @argument(
298 "--target-kind",
299 default=None,
300 help="only return tasks that are of the given kind, or their dependencies.",
302 @argument(
303 "-F",
304 "--fast",
305 default=False,
306 action="store_true",
307 help="enable fast task generation for local debugging.",
309 @argument(
310 "--diff",
311 const="default",
312 nargs="?",
313 default=None,
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)
325 repo = None
326 cur_ref = None
327 diffdir = None
328 output_file = options["output_file"]
330 if options["diff"]:
331 repo = get_repository(os.getcwd())
333 if not repo.working_directory_clean():
334 print(
335 "abort: can't diff taskgraph with dirty working directory",
336 file=sys.stderr,
338 return 1
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()
347 atexit.register(
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")
356 if not parameters:
357 overrides = {
358 "target-kind": options.get("target_kind"),
360 parameters = [
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)
367 parameters.extend(
369 p.as_posix()
370 for p in Path(param).iterdir()
371 if p.suffix in (".yml", ".json")
375 logdir = None
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):
382 os.makedirs(logdir)
383 else:
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.
387 setup_logging()
389 generate_taskgraph(options, parameters, logdir)
391 if options["diff"]:
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")
400 del sys.modules[mod]
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
404 # schema.
405 import gecko_taskgraph # noqa
407 if options["diff"] == "default":
408 base_ref = repo.base_ref
409 else:
410 base_ref = options["diff"]
412 try:
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)
420 finally:
421 repo.update(cur_ref)
423 # Generate diff(s)
424 diffcmd = [
425 "diff",
426 "-U20",
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}")
437 params_name = None
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))
452 continue
454 try:
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,
462 check=True,
464 diff_output = proc.stdout
465 returncode = 0
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)
470 raise
471 diff_output = e.output
472 returncode = e.returncode
474 dump_output(
475 diff_output,
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))
484 print(
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
489 file=sys.stderr,
492 if options["format"] != "json":
493 print(
494 "If you were expecting differences in task bodies "
495 'you should pass "-J"\n',
496 file=sys.stderr,
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")
505 @argument(
506 "-t", "--tag", help="tag that the image should be built as.", metavar="name:tag"
508 @argument(
509 "--context-only",
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)
519 else:
520 build_context(args["image_name"], args["context_only"], os.environ)
523 @command(
524 "load-image",
525 help="Load a pre-built Docker image. Note that you need to "
526 "have docker installed and running for this to work.",
528 @argument(
529 "--task-id",
530 help="Load the image at public/image.tar.zst in this task, "
531 "rather than searching the index",
533 @argument(
534 "-t",
535 "--tag",
536 help="tag that the image should be loaded as. If not "
537 "image will be loaded with tag from the tarball",
538 metavar="name:tag",
540 @argument(
541 "image_name",
542 nargs="?",
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")
552 sys.exit(1)
553 try:
554 if args["task_id"]:
555 ok = load_image_by_task_id(args["task_id"], args.get("tag"))
556 else:
557 ok = load_image_by_name(args["image_name"], args.get("tag"))
558 if not ok:
559 sys.exit(1)
560 except Exception:
561 traceback.print_exc()
562 sys.exit(1)
565 @command("image-digest", help="Print the digest of a docker image.")
566 @argument(
567 "image_name",
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
574 try:
575 digest = get_image_digest(args["image_name"])
576 print(digest)
577 except Exception:
578 traceback.print_exc()
579 sys.exit(1)
582 @command("decision", help="Run the decision task")
583 @argument("--root", "-r", help="root of the taskgraph definition relative to topsrcdir")
584 @argument(
585 "--message",
586 required=False,
587 help=argparse.SUPPRESS,
589 @argument(
590 "--project",
591 required=True,
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")
598 @argument(
599 "--target-tasks-method", help="method for selecting the target tasks to generate"
601 @argument(
602 "--repository-type",
603 required=True,
604 help='Type of repository, either "hg" or "git"',
606 @argument("--base-repository", required=True, help='URL for "base" repository to clone')
607 @argument(
608 "--base-ref", default="", help='Reference of the revision in the "base" repository'
610 @argument(
611 "--base-rev",
612 default="",
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",
616 @argument(
617 "--head-repository",
618 required=True,
619 help='URL for "head" repository to fetch revision from',
621 @argument(
622 "--head-ref", required=True, help="Reference (this is same as rev usually for hg)"
624 @argument(
625 "--head-rev", required=True, help="Commit revision to use from head repository"
627 @argument("--head-tag", help="Tag attached to the revision", default="")
628 @argument(
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")
639 @argument(
640 "--root",
641 "-r",
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
649 try:
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,
662 task_id=task_id,
663 input=input,
664 callback=callback,
665 parameters=parameters,
666 root=root,
667 test=False,
669 except Exception:
670 traceback.print_exc()
671 sys.exit(1)
674 @command("test-action-callback", description="Run an action callback in a testing mode")
675 @argument(
676 "--root",
677 "-r",
678 default="taskcluster/ci",
679 help="root of the taskgraph definition relative to topsrcdir",
681 @argument(
682 "--parameters",
683 "-p",
684 default="",
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")
688 @argument(
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"):
705 return json.load(f)
706 raise Exception(f"unknown filename {filename}")
708 try:
709 task_id = options["task_id"]
711 if options["input"]:
712 input = load_data(options["input"])
713 else:
714 input = None
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
724 parameters.check()
726 return gecko_taskgraph.actions.trigger_action_callback(
727 task_group_id=options["task_group_id"],
728 task_id=task_id,
729 input=input,
730 callback=options["callback"],
731 parameters=parameters,
732 root=root,
733 test=True,
735 except Exception:
736 traceback.print_exc()
737 sys.exit(1)
740 def create_parser():
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)
748 return parser
751 def setup_logging():
752 logging.basicConfig(
753 format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO
757 def main(args=sys.argv[1:]):
758 setup_logging()
759 parser = create_parser()
760 args = parser.parse_args(args)
761 try:
762 args.command(vars(args))
763 except Exception:
764 traceback.print_exc()
765 sys.exit(1)