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/.
4
5from __future__ import absolute_import
6import argparse
7import hashlib
8import json
9import logging
10import os
11import shutil
12import six
13
14from collections import OrderedDict
15
16from mach.decorators import (
17    CommandArgument,
18    CommandProvider,
19    Command,
20    SubCommand,
21)
22from mozbuild.artifact_builds import JOB_CHOICES
23from mozbuild.base import (
24    MachCommandBase,
25    MachCommandConditions as conditions,
26)
27from mozbuild.util import ensureParentDir
28import mozversioncontrol
29
30
31_COULD_NOT_FIND_ARTIFACTS_TEMPLATE = (
32    "ERROR!!!!!! Could not find artifacts for a toolchain build named "
33    "`{build}`. Local commits, dirty/stale files, and other changes in your "
34    "checkout may cause this error. Make sure you are on a fresh, current "
35    "checkout of mozilla-central. Beware that commands like `mach bootstrap` "
36    "and `mach artifact` are unlikely to work on any versions of the code "
37    "besides recent revisions of mozilla-central."
38)
39
40
41class SymbolsAction(argparse.Action):
42    def __call__(self, parser, namespace, values, option_string=None):
43        # If this function is called, it means the --symbols option was given,
44        # so we want to store the value `True` if no explicit value was given
45        # to the option.
46        setattr(namespace, self.dest, values or True)
47
48
49class ArtifactSubCommand(SubCommand):
50    def __call__(self, func):
51        after = SubCommand.__call__(self, func)
52        args = [
53            CommandArgument("--tree", metavar="TREE", type=str, help="Firefox tree."),
54            CommandArgument(
55                "--job", metavar="JOB", choices=JOB_CHOICES, help="Build job."
56            ),
57            CommandArgument(
58                "--verbose", "-v", action="store_true", help="Print verbose output."
59            ),
60        ]
61        for arg in args:
62            after = arg(after)
63        return after
64
65
66@CommandProvider
67class PackageFrontend(MachCommandBase):
68    """Fetch and install binary artifacts from Mozilla automation."""
69
70    @Command(
71        "artifact",
72        category="post-build",
73        description="Use pre-built artifacts to build Firefox.",
74    )
75    def artifact(self, command_context):
76        """Download, cache, and install pre-built binary artifacts to build Firefox.
77
78        Use |mach build| as normal to freshen your installed binary libraries:
79        artifact builds automatically download, cache, and install binary
80        artifacts from Mozilla automation, replacing whatever may be in your
81        object directory.  Use |mach artifact last| to see what binary artifacts
82        were last used.
83
84        Never build libxul again!
85
86        """
87        pass
88
89    def _make_artifacts(
90        self,
91        tree=None,
92        job=None,
93        skip_cache=False,
94        download_tests=True,
95        download_symbols=False,
96        download_host_bins=False,
97        download_maven_zip=False,
98        no_process=False,
99    ):
100        state_dir = self._mach_context.state_dir
101        cache_dir = os.path.join(state_dir, "package-frontend")
102
103        hg = None
104        if conditions.is_hg(self):
105            hg = self.substs["HG"]
106
107        git = None
108        if conditions.is_git(self):
109            git = self.substs["GIT"]
110
111        # If we're building Thunderbird, we should be checking for comm-central artifacts.
112        topsrcdir = self.substs.get("commtopsrcdir", self.topsrcdir)
113
114        if download_maven_zip:
115            if download_tests:
116                raise ValueError("--maven-zip requires --no-tests")
117            if download_symbols:
118                raise ValueError("--maven-zip requires no --symbols")
119            if download_host_bins:
120                raise ValueError("--maven-zip requires no --host-bins")
121            if not no_process:
122                raise ValueError("--maven-zip requires --no-process")
123
124        from mozbuild.artifacts import Artifacts
125
126        artifacts = Artifacts(
127            tree,
128            self.substs,
129            self.defines,
130            job,
131            log=self.log,
132            cache_dir=cache_dir,
133            skip_cache=skip_cache,
134            hg=hg,
135            git=git,
136            topsrcdir=topsrcdir,
137            download_tests=download_tests,
138            download_symbols=download_symbols,
139            download_host_bins=download_host_bins,
140            download_maven_zip=download_maven_zip,
141            no_process=no_process,
142            mozbuild=self,
143        )
144        return artifacts
145
146    @ArtifactSubCommand("artifact", "install", "Install a good pre-built artifact.")
147    @CommandArgument(
148        "source",
149        metavar="SRC",
150        nargs="?",
151        type=str,
152        help="Where to fetch and install artifacts from.  Can be omitted, in "
153        "which case the current hg repository is inspected; an hg revision; "
154        "a remote URL; or a local file.",
155        default=None,
156    )
157    @CommandArgument(
158        "--skip-cache",
159        action="store_true",
160        help="Skip all local caches to force re-fetching remote artifacts.",
161        default=False,
162    )
163    @CommandArgument("--no-tests", action="store_true", help="Don't install tests.")
164    @CommandArgument(
165        "--symbols", nargs="?", action=SymbolsAction, help="Download symbols."
166    )
167    @CommandArgument("--host-bins", action="store_true", help="Download host binaries.")
168    @CommandArgument("--distdir", help="Where to install artifacts to.")
169    @CommandArgument(
170        "--no-process",
171        action="store_true",
172        help="Don't process (unpack) artifact packages, just download them.",
173    )
174    @CommandArgument(
175        "--maven-zip", action="store_true", help="Download Maven zip (Android-only)."
176    )
177    def artifact_install(
178        self,
179        command_context,
180        source=None,
181        skip_cache=False,
182        tree=None,
183        job=None,
184        verbose=False,
185        no_tests=False,
186        symbols=False,
187        host_bins=False,
188        distdir=None,
189        no_process=False,
190        maven_zip=False,
191    ):
192        self._set_log_level(verbose)
193        artifacts = self._make_artifacts(
194            tree=tree,
195            job=job,
196            skip_cache=skip_cache,
197            download_tests=not no_tests,
198            download_symbols=symbols,
199            download_host_bins=host_bins,
200            download_maven_zip=maven_zip,
201            no_process=no_process,
202        )
203
204        return artifacts.install_from(source, distdir or self.distdir)
205
206    @ArtifactSubCommand(
207        "artifact",
208        "clear-cache",
209        "Delete local artifacts and reset local artifact cache.",
210    )
211    def artifact_clear_cache(self, command_context, tree=None, job=None, verbose=False):
212        self._set_log_level(verbose)
213        artifacts = self._make_artifacts(tree=tree, job=job)
214        artifacts.clear_cache()
215        return 0
216
217    @SubCommand("artifact", "toolchain")
218    @CommandArgument(
219        "--verbose", "-v", action="store_true", help="Print verbose output."
220    )
221    @CommandArgument(
222        "--cache-dir",
223        metavar="DIR",
224        help="Directory where to store the artifacts cache",
225    )
226    @CommandArgument(
227        "--skip-cache",
228        action="store_true",
229        help="Skip all local caches to force re-fetching remote artifacts.",
230        default=False,
231    )
232    @CommandArgument(
233        "--from-build",
234        metavar="BUILD",
235        nargs="+",
236        help="Download toolchains resulting from the given build(s); "
237        "BUILD is a name of a toolchain task, e.g. linux64-clang",
238    )
239    @CommandArgument(
240        "--tooltool-manifest",
241        metavar="MANIFEST",
242        help="Explicit tooltool manifest to process",
243    )
244    @CommandArgument(
245        "--no-unpack", action="store_true", help="Do not unpack any downloaded file"
246    )
247    @CommandArgument(
248        "--retry", type=int, default=4, help="Number of times to retry failed downloads"
249    )
250    @CommandArgument(
251        "--bootstrap",
252        action="store_true",
253        help="Whether this is being called from bootstrap. "
254        "This verifies the toolchain is annotated as a toolchain used for local development.",
255    )
256    @CommandArgument(
257        "--artifact-manifest",
258        metavar="FILE",
259        help="Store a manifest about the downloaded taskcluster artifacts",
260    )
261    def artifact_toolchain(
262        self,
263        command_context,
264        verbose=False,
265        cache_dir=None,
266        skip_cache=False,
267        from_build=(),
268        tooltool_manifest=None,
269        no_unpack=False,
270        retry=0,
271        bootstrap=False,
272        artifact_manifest=None,
273    ):
274        """Download, cache and install pre-built toolchains."""
275        from mozbuild.artifacts import ArtifactCache
276        from mozbuild.action.tooltool import (
277            FileRecord,
278            open_manifest,
279            unpack_file,
280        )
281        import redo
282        import requests
283        import time
284
285        from taskgraph.util.taskcluster import get_artifact_url
286
287        start = time.time()
288        self._set_log_level(verbose)
289        # Normally, we'd use self.log_manager.enable_unstructured(),
290        # but that enables all logging, while we only really want tooltool's
291        # and it also makes structured log output twice.
292        # So we manually do what it does, and limit that to the tooltool
293        # logger.
294        if self.log_manager.terminal_handler:
295            logging.getLogger("mozbuild.action.tooltool").addHandler(
296                self.log_manager.terminal_handler
297            )
298            logging.getLogger("redo").addHandler(self.log_manager.terminal_handler)
299            self.log_manager.terminal_handler.addFilter(
300                self.log_manager.structured_filter
301            )
302        if not cache_dir:
303            cache_dir = os.path.join(self._mach_context.state_dir, "toolchains")
304
305        tooltool_host = os.environ.get("TOOLTOOL_HOST", "tooltool.mozilla-releng.net")
306        taskcluster_proxy_url = os.environ.get("TASKCLUSTER_PROXY_URL")
307        if taskcluster_proxy_url:
308            tooltool_url = "{}/{}".format(taskcluster_proxy_url, tooltool_host)
309        else:
310            tooltool_url = "https://{}".format(tooltool_host)
311
312        cache = ArtifactCache(cache_dir=cache_dir, log=self.log, skip_cache=skip_cache)
313
314        class DownloadRecord(FileRecord):
315            def __init__(self, url, *args, **kwargs):
316                super(DownloadRecord, self).__init__(*args, **kwargs)
317                self.url = url
318                self.basename = self.filename
319
320            def fetch_with(self, cache):
321                self.filename = cache.fetch(self.url)
322                return self.filename
323
324            def validate(self):
325                if self.size is None and self.digest is None:
326                    return True
327                return super(DownloadRecord, self).validate()
328
329        class ArtifactRecord(DownloadRecord):
330            def __init__(self, task_id, artifact_name):
331                for _ in redo.retrier(attempts=retry + 1, sleeptime=60):
332                    cot = cache._download_manager.session.get(
333                        get_artifact_url(task_id, "public/chain-of-trust.json")
334                    )
335                    if cot.status_code >= 500:
336                        continue
337                    cot.raise_for_status()
338                    break
339                else:
340                    cot.raise_for_status()
341
342                digest = algorithm = None
343                data = json.loads(cot.text)
344                for algorithm, digest in (
345                    data.get("artifacts", {}).get(artifact_name, {}).items()
346                ):
347                    pass
348
349                name = os.path.basename(artifact_name)
350                artifact_url = get_artifact_url(
351                    task_id,
352                    artifact_name,
353                    use_proxy=not artifact_name.startswith("public/"),
354                )
355                super(ArtifactRecord, self).__init__(
356                    artifact_url, name, None, digest, algorithm, unpack=True
357                )
358
359        records = OrderedDict()
360        downloaded = []
361
362        if tooltool_manifest:
363            manifest = open_manifest(tooltool_manifest)
364            for record in manifest.file_records:
365                url = "{}/{}/{}".format(tooltool_url, record.algorithm, record.digest)
366                records[record.filename] = DownloadRecord(
367                    url,
368                    record.filename,
369                    record.size,
370                    record.digest,
371                    record.algorithm,
372                    unpack=record.unpack,
373                    version=record.version,
374                    visibility=record.visibility,
375                )
376
377        if from_build:
378            if "MOZ_AUTOMATION" in os.environ:
379                self.log(
380                    logging.ERROR,
381                    "artifact",
382                    {},
383                    "Do not use --from-build in automation; all dependencies "
384                    "should be determined in the decision task.",
385                )
386                return 1
387            from taskgraph.optimize.strategies import IndexSearch
388            from mozbuild.toolchains import toolchain_task_definitions
389
390            tasks = toolchain_task_definitions()
391
392            for b in from_build:
393                user_value = b
394
395                if not b.startswith("toolchain-"):
396                    b = "toolchain-{}".format(b)
397
398                task = tasks.get(b)
399                if not task:
400                    self.log(
401                        logging.ERROR,
402                        "artifact",
403                        {"build": user_value},
404                        "Could not find a toolchain build named `{build}`",
405                    )
406                    return 1
407
408                # Ensure that toolchains installed by `mach bootstrap` have the
409                # `local-toolchain attribute set. Taskgraph ensures that these
410                # are built on trunk projects, so the task will be available to
411                # install here.
412                if bootstrap and not task.attributes.get("local-toolchain"):
413                    self.log(
414                        logging.ERROR,
415                        "artifact",
416                        {"build": user_value},
417                        "Toolchain `{build}` is not annotated as used for local development.",
418                    )
419                    return 1
420
421                artifact_name = task.attributes.get("toolchain-artifact")
422                self.log(
423                    logging.DEBUG,
424                    "artifact",
425                    {
426                        "name": artifact_name,
427                        "index": task.optimization.get("index-search"),
428                    },
429                    "Searching for {name} in {index}",
430                )
431                deadline = None
432                task_id = IndexSearch().should_replace_task(
433                    task, {}, deadline, task.optimization.get("index-search", [])
434                )
435                if task_id in (True, False) or not artifact_name:
436                    self.log(
437                        logging.ERROR,
438                        "artifact",
439                        {"build": user_value},
440                        _COULD_NOT_FIND_ARTIFACTS_TEMPLATE,
441                    )
442                    # Get and print some helpful info for diagnosis.
443                    repo = mozversioncontrol.get_repository_object(self.topsrcdir)
444                    changed_files = set(repo.get_outgoing_files()) | set(
445                        repo.get_changed_files()
446                    )
447                    if changed_files:
448                        self.log(
449                            logging.ERROR,
450                            "artifact",
451                            {},
452                            "Hint: consider reverting your local changes "
453                            "to the following files: %s" % sorted(changed_files),
454                        )
455                    if "TASKCLUSTER_ROOT_URL" in os.environ:
456                        self.log(
457                            logging.ERROR,
458                            "artifact",
459                            {"build": user_value},
460                            "Due to the environment variable TASKCLUSTER_ROOT_URL "
461                            "being set, the artifacts were expected to be found "
462                            "on {}. If this was unintended, unset "
463                            "TASKCLUSTER_ROOT_URL and try again.".format(
464                                os.environ["TASKCLUSTER_ROOT_URL"]
465                            ),
466                        )
467                    return 1
468
469                self.log(
470                    logging.DEBUG,
471                    "artifact",
472                    {"name": artifact_name, "task_id": task_id},
473                    "Found {name} in {task_id}",
474                )
475
476                record = ArtifactRecord(task_id, artifact_name)
477                records[record.filename] = record
478
479        for record in six.itervalues(records):
480            self.log(
481                logging.INFO,
482                "artifact",
483                {"name": record.basename},
484                "Setting up artifact {name}",
485            )
486            valid = False
487            # sleeptime is 60 per retry.py, used by tooltool_wrapper.sh
488            for attempt, _ in enumerate(redo.retrier(attempts=retry + 1, sleeptime=60)):
489                try:
490                    record.fetch_with(cache)
491                except (
492                    requests.exceptions.HTTPError,
493                    requests.exceptions.ChunkedEncodingError,
494                    requests.exceptions.ConnectionError,
495                ) as e:
496
497                    if isinstance(e, requests.exceptions.HTTPError):
498                        # The relengapi proxy likes to return error 400 bad request
499                        # which seems improbably to be due to our (simple) GET
500                        # being borked.
501                        status = e.response.status_code
502                        should_retry = status >= 500 or status == 400
503                    else:
504                        should_retry = True
505
506                    if should_retry or attempt < retry:
507                        level = logging.WARN
508                    else:
509                        level = logging.ERROR
510                    self.log(level, "artifact", {}, str(e))
511                    if not should_retry:
512                        break
513                    if attempt < retry:
514                        self.log(
515                            logging.INFO, "artifact", {}, "Will retry in a moment..."
516                        )
517                    continue
518                try:
519                    valid = record.validate()
520                except Exception:
521                    pass
522                if not valid:
523                    os.unlink(record.filename)
524                    if attempt < retry:
525                        self.log(
526                            logging.INFO,
527                            "artifact",
528                            {},
529                            "Corrupt download. Will retry in a moment...",
530                        )
531                    continue
532
533                downloaded.append(record)
534                break
535
536            if not valid:
537                self.log(
538                    logging.ERROR,
539                    "artifact",
540                    {"name": record.basename},
541                    "Failed to download {name}",
542                )
543                return 1
544
545        artifacts = {} if artifact_manifest else None
546
547        for record in downloaded:
548            local = os.path.join(os.getcwd(), record.basename)
549            if os.path.exists(local):
550                os.unlink(local)
551            # unpack_file needs the file with its final name to work
552            # (https://github.com/mozilla/build-tooltool/issues/38), so we
553            # need to copy it, even though we remove it later. Use hard links
554            # when possible.
555            try:
556                os.link(record.filename, local)
557            except Exception:
558                shutil.copy(record.filename, local)
559            # Keep a sha256 of each downloaded file, for the chain-of-trust
560            # validation.
561            if artifact_manifest is not None:
562                with open(local, "rb") as fh:
563                    h = hashlib.sha256()
564                    while True:
565                        data = fh.read(1024 * 1024)
566                        if not data:
567                            break
568                        h.update(data)
569                artifacts[record.url] = {
570                    "sha256": h.hexdigest(),
571                }
572            if record.unpack and not no_unpack:
573                unpack_file(local)
574                os.unlink(local)
575
576        if not downloaded:
577            self.log(logging.ERROR, "artifact", {}, "Nothing to download")
578
579        if artifacts:
580            ensureParentDir(artifact_manifest)
581            with open(artifact_manifest, "w") as fh:
582                json.dump(artifacts, fh, indent=4, sort_keys=True)
583
584        if "MOZ_AUTOMATION" in os.environ:
585            end = time.time()
586
587            perfherder_data = {
588                "framework": {"name": "build_metrics"},
589                "suites": [
590                    {
591                        "name": "mach_artifact_toolchain",
592                        "value": end - start,
593                        "lowerIsBetter": True,
594                        "shouldAlert": False,
595                        "subtests": [],
596                    }
597                ],
598            }
599            self.log(
600                logging.INFO,
601                "perfherder",
602                {"data": json.dumps(perfherder_data)},
603                "PERFHERDER_DATA: {data}",
604            )
605
606        return 0
607