1#!/usr/bin/env python3
2#
3# Build the required docker image to run package unit tests
4#
5# Script Variables:
6#   DOCKER_IMG_NAME:  <optional, the name of the docker image to generate>
7#                     default is openbmc/ubuntu-unit-test
8#   DISTRO:           <optional, the distro to build a docker image against>
9#   FORCE_DOCKER_BUILD: <optional, a non-zero value with force all Docker
10#                     images to be rebuilt rather than reusing caches.>
11#   BUILD_URL:        <optional, used to detect running under CI context
12#                     (ex. Jenkins)>
13#   BRANCH:           <optional, branch to build from each of the openbmc/
14#                     repositories>
15#                     default is master, which will be used if input branch not
16#                     provided or not found
17#   UBUNTU_MIRROR:    <optional, the URL of a mirror of Ubuntu to override the
18#                     default ones in /etc/apt/sources.list>
19#                     default is empty, and no mirror is used.
20#   DOCKER_REG:       <optional, the URL of a docker registry to utilize
21#                     instead of our default (public.ecr.aws/ubuntu)
22#                     (ex. docker.io)
23#   http_proxy        The HTTP address of the proxy server to connect to.
24#                     Default: "", proxy is not setup if this is not set
25
26import json
27import os
28import re
29import sys
30import threading
31import urllib.request
32from datetime import date
33from hashlib import sha256
34
35# typing.Dict is used for type-hints.
36from typing import Any, Callable, Dict, Iterable, Optional  # noqa: F401
37
38from sh import docker, git, nproc  # type: ignore
39
40try:
41    # Python before 3.8 doesn't have TypedDict, so reroute to standard 'dict'.
42    from typing import TypedDict
43except Exception:
44
45    class TypedDict(dict):  # type: ignore
46        # We need to do this to eat the 'total' argument.
47        def __init_subclass__(cls, **kwargs: Any) -> None:
48            super().__init_subclass__()
49
50
51# Declare some variables used in package definitions.
52prefix = "/usr/local"
53proc_count = nproc().strip()
54
55
56class PackageDef(TypedDict, total=False):
57    """Package Definition for packages dictionary."""
58
59    # rev [optional]: Revision of package to use.
60    rev: str
61    # url [optional]: lambda function to create URL: (package, rev) -> url.
62    url: Callable[[str, str], str]
63    # depends [optional]: List of package dependencies.
64    depends: Iterable[str]
65    # build_type [required]: Build type used for package.
66    #   Currently supported: autoconf, cmake, custom, make, meson
67    build_type: str
68    # build_steps [optional]: Steps to run for 'custom' build_type.
69    build_steps: Iterable[str]
70    # config_flags [optional]: List of options to pass configuration tool.
71    config_flags: Iterable[str]
72    # config_env [optional]: List of environment variables to set for config.
73    config_env: Iterable[str]
74    # custom_post_dl [optional]: List of steps to run after download, but
75    #   before config / build / install.
76    custom_post_dl: Iterable[str]
77    # custom_post_install [optional]: List of steps to run after install.
78    custom_post_install: Iterable[str]
79
80    # __tag [private]: Generated Docker tag name for package stage.
81    __tag: str
82    # __package [private]: Package object associated with this package.
83    __package: Any  # Type is Package, but not defined yet.
84
85
86# Packages to include in image.
87packages = {
88    "boost": PackageDef(
89        rev="1.84.0",
90        url=(
91            lambda pkg, rev: f"https://github.com/boostorg/{pkg}/releases/download/{pkg}-{rev}/{pkg}-{rev}.tar.gz"
92        ),
93        build_type="custom",
94        build_steps=[
95            (
96                "./bootstrap.sh"
97                f" --prefix={prefix} --with-libraries=context,coroutine,url"
98            ),
99            "./b2",
100            f"./b2 install --prefix={prefix} valgrind=on",
101        ],
102    ),
103    "USCiLab/cereal": PackageDef(
104        rev="v1.3.2",
105        build_type="custom",
106        build_steps=[f"cp -a include/cereal/ {prefix}/include/"],
107    ),
108    "danmar/cppcheck": PackageDef(
109        rev="2.12.1",
110        build_type="cmake",
111    ),
112    "CLIUtils/CLI11": PackageDef(
113        rev="v2.3.2",
114        build_type="cmake",
115        config_flags=[
116            "-DBUILD_TESTING=OFF",
117            "-DCLI11_BUILD_DOCS=OFF",
118            "-DCLI11_BUILD_EXAMPLES=OFF",
119        ],
120    ),
121    "fmtlib/fmt": PackageDef(
122        rev="10.1.1",
123        build_type="cmake",
124        config_flags=[
125            "-DFMT_DOC=OFF",
126            "-DFMT_TEST=OFF",
127        ],
128    ),
129    "Naios/function2": PackageDef(
130        rev="4.2.4",
131        build_type="custom",
132        build_steps=[
133            f"mkdir {prefix}/include/function2",
134            f"cp include/function2/function2.hpp {prefix}/include/function2/",
135        ],
136    ),
137    "google/googletest": PackageDef(
138        rev="v1.15.2",
139        build_type="cmake",
140        config_env=["CXXFLAGS=-std=c++20"],
141        config_flags=["-DTHREADS_PREFER_PTHREAD_FLAG=ON"],
142    ),
143    "nghttp2/nghttp2": PackageDef(
144        rev="v1.61.0",
145        build_type="cmake",
146        config_env=["CXXFLAGS=-std=c++20"],
147        config_flags=[
148            "-DENABLE_LIB_ONLY=ON",
149            "-DENABLE_STATIC_LIB=ON",
150        ],
151    ),
152    "nlohmann/json": PackageDef(
153        rev="v3.11.2",
154        build_type="cmake",
155        config_flags=["-DJSON_BuildTests=OFF"],
156        custom_post_install=[
157            (
158                f"ln -s {prefix}/include/nlohmann/json.hpp"
159                f" {prefix}/include/json.hpp"
160            ),
161        ],
162    ),
163    "json-c/json-c": PackageDef(
164        rev="json-c-0.17-20230812",
165        build_type="cmake",
166    ),
167    "LibVNC/libvncserver": PackageDef(
168        rev="LibVNCServer-0.9.14",
169        build_type="cmake",
170    ),
171    "leethomason/tinyxml2": PackageDef(
172        rev="9.0.0",
173        build_type="cmake",
174    ),
175    "tristanpenman/valijson": PackageDef(
176        rev="v1.0.1",
177        build_type="cmake",
178        config_flags=[
179            "-Dvalijson_BUILD_TESTS=0",
180            "-Dvalijson_INSTALL_HEADERS=1",
181        ],
182    ),
183    "open-power/pdbg": PackageDef(build_type="autoconf"),
184    "openbmc/gpioplus": PackageDef(
185        build_type="meson",
186        config_flags=[
187            "-Dexamples=false",
188            "-Dtests=disabled",
189        ],
190    ),
191    "openbmc/phosphor-dbus-interfaces": PackageDef(
192        depends=["openbmc/sdbusplus"],
193        build_type="meson",
194        config_flags=["-Dgenerate_md=false"],
195    ),
196    "openbmc/phosphor-logging": PackageDef(
197        depends=[
198            "USCiLab/cereal",
199            "openbmc/phosphor-dbus-interfaces",
200            "openbmc/sdbusplus",
201            "openbmc/sdeventplus",
202        ],
203        build_type="meson",
204        config_flags=[
205            "-Dlibonly=true",
206            "-Dtests=disabled",
207            f"-Dyamldir={prefix}/share/phosphor-dbus-yaml/yaml",
208        ],
209    ),
210    "openbmc/phosphor-objmgr": PackageDef(
211        depends=[
212            "CLIUtils/CLI11",
213            "boost",
214            "leethomason/tinyxml2",
215            "openbmc/phosphor-dbus-interfaces",
216            "openbmc/phosphor-logging",
217            "openbmc/sdbusplus",
218        ],
219        build_type="meson",
220        config_flags=[
221            "-Dtests=disabled",
222        ],
223    ),
224    "openbmc/libpeci": PackageDef(
225        build_type="meson",
226        config_flags=[
227            "-Draw-peci=disabled",
228        ],
229    ),
230    "openbmc/libpldm": PackageDef(
231        build_type="meson",
232        config_flags=[
233            "-Dabi=deprecated,stable",
234            "-Doem-ibm=enabled",
235            "-Dtests=disabled",
236        ],
237    ),
238    "openbmc/sdbusplus": PackageDef(
239        depends=[
240            "nlohmann/json",
241        ],
242        build_type="meson",
243        custom_post_dl=[
244            "cd tools",
245            f"./setup.py install --root=/ --prefix={prefix}",
246            "cd ..",
247        ],
248        config_flags=[
249            "-Dexamples=disabled",
250            "-Dtests=disabled",
251        ],
252    ),
253    "openbmc/sdeventplus": PackageDef(
254        depends=[
255            "openbmc/stdplus",
256        ],
257        build_type="meson",
258        config_flags=[
259            "-Dexamples=false",
260            "-Dtests=disabled",
261        ],
262    ),
263    "openbmc/stdplus": PackageDef(
264        depends=[
265            "fmtlib/fmt",
266            "google/googletest",
267            "Naios/function2",
268        ],
269        build_type="meson",
270        config_flags=[
271            "-Dexamples=false",
272            "-Dtests=disabled",
273            "-Dgtest=enabled",
274        ],
275    ),
276}  # type: Dict[str, PackageDef]
277
278# Define common flags used for builds
279configure_flags = " ".join(
280    [
281        f"--prefix={prefix}",
282    ]
283)
284cmake_flags = " ".join(
285    [
286        "-DBUILD_SHARED_LIBS=ON",
287        "-DCMAKE_BUILD_TYPE=RelWithDebInfo",
288        f"-DCMAKE_INSTALL_PREFIX:PATH={prefix}",
289        "-GNinja",
290        "-DCMAKE_MAKE_PROGRAM=ninja",
291    ]
292)
293meson_flags = " ".join(
294    [
295        "--wrap-mode=nodownload",
296        f"-Dprefix={prefix}",
297    ]
298)
299
300
301class Package(threading.Thread):
302    """Class used to build the Docker stages for each package.
303
304    Generally, this class should not be instantiated directly but through
305    Package.generate_all().
306    """
307
308    # Copy the packages dictionary.
309    packages = packages.copy()
310
311    # Lock used for thread-safety.
312    lock = threading.Lock()
313
314    def __init__(self, pkg: str):
315        """pkg - The name of this package (ex. foo/bar )"""
316        super(Package, self).__init__()
317
318        self.package = pkg
319        self.exception = None  # type: Optional[Exception]
320
321        # Reference to this package's
322        self.pkg_def = Package.packages[pkg]
323        self.pkg_def["__package"] = self
324
325    def run(self) -> None:
326        """Thread 'run' function.  Builds the Docker stage."""
327
328        # In case this package has no rev, fetch it from Github.
329        self._update_rev()
330
331        # Find all the Package objects that this package depends on.
332        #   This section is locked because we are looking into another
333        #   package's PackageDef dict, which could be being modified.
334        Package.lock.acquire()
335        deps: Iterable[Package] = [
336            Package.packages[deppkg]["__package"]
337            for deppkg in self.pkg_def.get("depends", [])
338        ]
339        Package.lock.release()
340
341        # Wait until all the depends finish building.  We need them complete
342        # for the "COPY" commands.
343        for deppkg in deps:
344            deppkg.join()
345
346        # Generate this package's Dockerfile.
347        dockerfile = f"""
348FROM {docker_base_img_name}
349{self._df_copycmds()}
350{self._df_build()}
351"""
352
353        # Generate the resulting tag name and save it to the PackageDef.
354        #   This section is locked because we are modifying the PackageDef,
355        #   which can be accessed by other threads.
356        Package.lock.acquire()
357        tag = Docker.tagname(self._stagename(), dockerfile)
358        self.pkg_def["__tag"] = tag
359        Package.lock.release()
360
361        # Do the build / save any exceptions.
362        try:
363            Docker.build(self.package, tag, dockerfile)
364        except Exception as e:
365            self.exception = e
366
367    @classmethod
368    def generate_all(cls) -> None:
369        """Ensure a Docker stage is created for all defined packages.
370
371        These are done in parallel but with appropriate blocking per
372        package 'depends' specifications.
373        """
374
375        # Create a Package for each defined package.
376        pkg_threads = [Package(p) for p in cls.packages.keys()]
377
378        # Start building them all.
379        #   This section is locked because threads depend on each other,
380        #   based on the packages, and they cannot 'join' on a thread
381        #   which is not yet started.  Adding a lock here allows all the
382        #   threads to start before they 'join' their dependencies.
383        Package.lock.acquire()
384        for t in pkg_threads:
385            t.start()
386        Package.lock.release()
387
388        # Wait for completion.
389        for t in pkg_threads:
390            t.join()
391            # Check if the thread saved off its own exception.
392            if t.exception:
393                print(f"Package {t.package} failed!", file=sys.stderr)
394                raise t.exception
395
396    @staticmethod
397    def df_all_copycmds() -> str:
398        """Formulate the Dockerfile snippet necessary to copy all packages
399        into the final image.
400        """
401        return Package.df_copycmds_set(Package.packages.keys())
402
403    @classmethod
404    def depcache(cls) -> str:
405        """Create the contents of the '/tmp/depcache'.
406        This file is a comma-separated list of "<pkg>:<rev>".
407        """
408
409        # This needs to be sorted for consistency.
410        depcache = ""
411        for pkg in sorted(cls.packages.keys()):
412            depcache += "%s:%s," % (pkg, cls.packages[pkg]["rev"])
413        return depcache
414
415    def _check_gerrit_topic(self) -> bool:
416        if not gerrit_topic:
417            return False
418        if not self.package.startswith("openbmc/"):
419            return False
420        if gerrit_project == self.package and gerrit_rev:
421            return False
422
423        try:
424            commits = json.loads(
425                urllib.request.urlopen(
426                    f"https://gerrit.openbmc.org/changes/?q=status:open+project:{self.package}+topic:{gerrit_topic}"
427                )
428                .read()
429                .splitlines()[-1]
430            )
431
432            if len(commits) == 0:
433                return False
434            if len(commits) > 1:
435                print(
436                    f"{self.package} has more than 1 commit under {gerrit_topic}; using lastest upstream: {len(commits)}",
437                    file=sys.stderr,
438                )
439                return False
440
441            change_id = commits[0]["id"]
442
443            commit = json.loads(
444                urllib.request.urlopen(
445                    f"https://gerrit.openbmc.org/changes/{change_id}/revisions/current/commit"
446                )
447                .read()
448                .splitlines()[-1]
449            )["commit"]
450
451            print(
452                f"Using {commit} from {gerrit_topic} for {self.package}",
453                file=sys.stderr,
454            )
455            self.pkg_def["rev"] = commit
456            return True
457
458        except urllib.error.HTTPError as e:
459            print(
460                f"Error loading topic {gerrit_topic} for {self.package}: ",
461                e,
462                file=sys.stderr,
463            )
464            return False
465
466    def _update_rev(self) -> None:
467        """Look up the HEAD for missing a static rev."""
468
469        if "rev" in self.pkg_def:
470            return
471
472        if self._check_gerrit_topic():
473            return
474
475        # Check if Jenkins/Gerrit gave us a revision and use it.
476        if gerrit_project == self.package and gerrit_rev:
477            print(
478                f"Found Gerrit revision for {self.package}: {gerrit_rev}",
479                file=sys.stderr,
480            )
481            self.pkg_def["rev"] = gerrit_rev
482            return
483
484        # Ask Github for all the branches.
485        lookup = git(
486            "ls-remote", "--heads", f"https://github.com/{self.package}"
487        )
488
489        # Find the branch matching {branch} (or fallback to master).
490        #   This section is locked because we are modifying the PackageDef.
491        Package.lock.acquire()
492        for line in lookup.split("\n"):
493            if re.fullmatch(f".*{branch}$", line.strip()):
494                self.pkg_def["rev"] = line.split()[0]
495                break
496            elif (
497                "refs/heads/master" in line or "refs/heads/main" in line
498            ) and "rev" not in self.pkg_def:
499                self.pkg_def["rev"] = line.split()[0]
500        Package.lock.release()
501
502    def _stagename(self) -> str:
503        """Create a name for the Docker stage associated with this pkg."""
504        return self.package.replace("/", "-").lower()
505
506    def _url(self) -> str:
507        """Get the URL for this package."""
508        rev = self.pkg_def["rev"]
509
510        # If the lambda exists, call it.
511        if "url" in self.pkg_def:
512            return self.pkg_def["url"](self.package, rev)
513
514        # Default to the github archive URL.
515        return f"https://github.com/{self.package}/archive/{rev}.tar.gz"
516
517    def _cmd_download(self) -> str:
518        """Formulate the command necessary to download and unpack to source."""
519
520        url = self._url()
521        if ".tar." not in url:
522            raise NotImplementedError(
523                f"Unhandled download type for {self.package}: {url}"
524            )
525
526        cmd = f"curl -L {url} | tar -x"
527
528        if url.endswith(".bz2"):
529            cmd += "j"
530        elif url.endswith(".gz"):
531            cmd += "z"
532        else:
533            raise NotImplementedError(
534                f"Unknown tar flags needed for {self.package}: {url}"
535            )
536
537        return cmd
538
539    def _cmd_cd_srcdir(self) -> str:
540        """Formulate the command necessary to 'cd' into the source dir."""
541        return f"cd {self.package.split('/')[-1]}*"
542
543    def _df_copycmds(self) -> str:
544        """Formulate the dockerfile snippet necessary to COPY all depends."""
545
546        if "depends" not in self.pkg_def:
547            return ""
548        return Package.df_copycmds_set(self.pkg_def["depends"])
549
550    @staticmethod
551    def df_copycmds_set(pkgs: Iterable[str]) -> str:
552        """Formulate the Dockerfile snippet necessary to COPY a set of
553        packages into a Docker stage.
554        """
555
556        copy_cmds = ""
557
558        # Sort the packages for consistency.
559        for p in sorted(pkgs):
560            tag = Package.packages[p]["__tag"]
561            copy_cmds += f"COPY --from={tag} {prefix} {prefix}\n"
562            # Workaround for upstream docker bug and multiple COPY cmds
563            # https://github.com/moby/moby/issues/37965
564            copy_cmds += "RUN true\n"
565
566        return copy_cmds
567
568    def _df_build(self) -> str:
569        """Formulate the Dockerfile snippet necessary to download, build, and
570        install a package into a Docker stage.
571        """
572
573        # Download and extract source.
574        result = f"RUN {self._cmd_download()} && {self._cmd_cd_srcdir()} && "
575
576        # Handle 'custom_post_dl' commands.
577        custom_post_dl = self.pkg_def.get("custom_post_dl")
578        if custom_post_dl:
579            result += " && ".join(custom_post_dl) + " && "
580
581        # Build and install package based on 'build_type'.
582        build_type = self.pkg_def["build_type"]
583        if build_type == "autoconf":
584            result += self._cmd_build_autoconf()
585        elif build_type == "cmake":
586            result += self._cmd_build_cmake()
587        elif build_type == "custom":
588            result += self._cmd_build_custom()
589        elif build_type == "make":
590            result += self._cmd_build_make()
591        elif build_type == "meson":
592            result += self._cmd_build_meson()
593        else:
594            raise NotImplementedError(
595                f"Unhandled build type for {self.package}: {build_type}"
596            )
597
598        # Handle 'custom_post_install' commands.
599        custom_post_install = self.pkg_def.get("custom_post_install")
600        if custom_post_install:
601            result += " && " + " && ".join(custom_post_install)
602
603        return result
604
605    def _cmd_build_autoconf(self) -> str:
606        options = " ".join(self.pkg_def.get("config_flags", []))
607        env = " ".join(self.pkg_def.get("config_env", []))
608        result = "./bootstrap.sh && "
609        result += f"{env} ./configure {configure_flags} {options} && "
610        result += f"make -j{proc_count} && make install"
611        return result
612
613    def _cmd_build_cmake(self) -> str:
614        options = " ".join(self.pkg_def.get("config_flags", []))
615        env = " ".join(self.pkg_def.get("config_env", []))
616        result = "mkdir builddir && cd builddir && "
617        result += f"{env} cmake {cmake_flags} {options} .. && "
618        result += "cmake --build . --target all && "
619        result += "cmake --build . --target install && "
620        result += "cd .."
621        return result
622
623    def _cmd_build_custom(self) -> str:
624        return " && ".join(self.pkg_def.get("build_steps", []))
625
626    def _cmd_build_make(self) -> str:
627        return f"make -j{proc_count} && make install"
628
629    def _cmd_build_meson(self) -> str:
630        options = " ".join(self.pkg_def.get("config_flags", []))
631        env = " ".join(self.pkg_def.get("config_env", []))
632        result = f"{env} meson setup builddir {meson_flags} {options} && "
633        result += "ninja -C builddir && ninja -C builddir install"
634        return result
635
636
637class Docker:
638    """Class to assist with Docker interactions.  All methods are static."""
639
640    @staticmethod
641    def timestamp() -> str:
642        """Generate a timestamp for today using the ISO week."""
643        today = date.today().isocalendar()
644        return f"{today[0]}-W{today[1]:02}"
645
646    @staticmethod
647    def tagname(pkgname: Optional[str], dockerfile: str) -> str:
648        """Generate a tag name for a package using a hash of the Dockerfile."""
649        result = docker_image_name
650        if pkgname:
651            result += "-" + pkgname
652
653        result += ":" + Docker.timestamp()
654        result += "-" + sha256(dockerfile.encode()).hexdigest()[0:16]
655
656        return result
657
658    @staticmethod
659    def build(pkg: str, tag: str, dockerfile: str) -> None:
660        """Build a docker image using the Dockerfile and tagging it with 'tag'."""
661
662        # If we're not forcing builds, check if it already exists and skip.
663        if not force_build:
664            if docker.image.ls(tag, "--format", '"{{.Repository}}:{{.Tag}}"'):
665                print(
666                    f"Image {tag} already exists.  Skipping.", file=sys.stderr
667                )
668                return
669
670        # Build it.
671        #   Capture the output of the 'docker build' command and send it to
672        #   stderr (prefixed with the package name).  This allows us to see
673        #   progress but not pollute stdout.  Later on we output the final
674        #   docker tag to stdout and we want to keep that pristine.
675        #
676        #   Other unusual flags:
677        #       --no-cache: Bypass the Docker cache if 'force_build'.
678        #       --force-rm: Clean up Docker processes if they fail.
679        docker.build(
680            proxy_args,
681            "--network=host",
682            "--force-rm",
683            "--no-cache=true" if force_build else "--no-cache=false",
684            "-t",
685            tag,
686            "-",
687            _in=dockerfile,
688            _out=(
689                lambda line: print(
690                    pkg + ":", line, end="", file=sys.stderr, flush=True
691                )
692            ),
693            _err_to_out=True,
694        )
695
696
697# Read a bunch of environment variables.
698docker_image_name = os.environ.get(
699    "DOCKER_IMAGE_NAME", "openbmc/ubuntu-unit-test"
700)
701force_build = os.environ.get("FORCE_DOCKER_BUILD")
702is_automated_ci_build = os.environ.get("BUILD_URL", False)
703distro = os.environ.get("DISTRO", "ubuntu:noble")
704branch = os.environ.get("BRANCH", "master")
705ubuntu_mirror = os.environ.get("UBUNTU_MIRROR")
706docker_reg = os.environ.get("DOCKER_REG", "public.ecr.aws/ubuntu")
707http_proxy = os.environ.get("http_proxy")
708
709gerrit_project = os.environ.get("GERRIT_PROJECT")
710gerrit_rev = os.environ.get("GERRIT_PATCHSET_REVISION")
711gerrit_topic = os.environ.get("GERRIT_TOPIC")
712
713# Ensure appropriate docker build output to see progress and identify
714# any issues
715os.environ["BUILDKIT_PROGRESS"] = "plain"
716
717# Set up some common variables.
718username = os.environ.get("USER", "root")
719homedir = os.environ.get("HOME", "/root")
720gid = os.getgid()
721uid = os.getuid()
722
723# Use well-known constants if user is root
724if username == "root":
725    homedir = "/root"
726    gid = 0
727    uid = 0
728
729# Special flags if setting up a deb mirror.
730mirror = ""
731if "ubuntu" in distro and ubuntu_mirror:
732    mirror = f"""
733RUN echo "deb {ubuntu_mirror} \
734        $(. /etc/os-release && echo $VERSION_CODENAME) \
735        main restricted universe multiverse" > /etc/apt/sources.list && \\
736    echo "deb {ubuntu_mirror} \
737        $(. /etc/os-release && echo $VERSION_CODENAME)-updates \
738            main restricted universe multiverse" >> /etc/apt/sources.list && \\
739    echo "deb {ubuntu_mirror} \
740        $(. /etc/os-release && echo $VERSION_CODENAME)-security \
741            main restricted universe multiverse" >> /etc/apt/sources.list && \\
742    echo "deb {ubuntu_mirror} \
743        $(. /etc/os-release && echo $VERSION_CODENAME)-proposed \
744            main restricted universe multiverse" >> /etc/apt/sources.list && \\
745    echo "deb {ubuntu_mirror} \
746        $(. /etc/os-release && echo $VERSION_CODENAME)-backports \
747            main restricted universe multiverse" >> /etc/apt/sources.list
748"""
749
750# Special flags for proxying.
751proxy_cmd = ""
752proxy_keyserver = ""
753proxy_args = []
754if http_proxy:
755    proxy_cmd = f"""
756RUN echo "[http]" >> {homedir}/.gitconfig && \
757    echo "proxy = {http_proxy}" >> {homedir}/.gitconfig
758"""
759    proxy_keyserver = f"--keyserver-options http-proxy={http_proxy}"
760
761    proxy_args.extend(
762        [
763            "--build-arg",
764            f"http_proxy={http_proxy}",
765            "--build-arg",
766            f"https_proxy={http_proxy}",
767        ]
768    )
769
770# Create base Dockerfile.
771dockerfile_base = f"""
772FROM {docker_reg}/{distro}
773
774{mirror}
775
776ENV DEBIAN_FRONTEND noninteractive
777
778ENV PYTHONPATH "/usr/local/lib/python3.10/site-packages/"
779
780# Sometimes the ubuntu key expires and we need a way to force an execution
781# of the apt-get commands for the dbgsym-keyring.  When this happens we see
782# an error like: "Release: The following signatures were invalid:"
783# Insert a bogus echo that we can change here when we get this error to force
784# the update.
785RUN echo "ubuntu keyserver rev as of 2021-04-21"
786
787# We need the keys to be imported for dbgsym repos
788# New releases have a package, older ones fall back to manual fetching
789# https://wiki.ubuntu.com/Debug%20Symbol%20Packages
790# Known issue with gpg to get keys via proxy -
791# https://bugs.launchpad.net/ubuntu/+source/gnupg2/+bug/1788190, hence using
792# curl to get keys.
793RUN apt-get update && apt-get dist-upgrade -yy && \
794    ( apt-get install -yy gpgv ubuntu-dbgsym-keyring || \
795        ( apt-get install -yy dirmngr curl && \
796          curl -sSL \
797          'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xF2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622' \
798          | apt-key add - ))
799
800# Parse the current repo list into a debug repo list
801RUN sed -n '/^deb /s,^deb [^ ]* ,deb http://ddebs.ubuntu.com ,p' \
802        /etc/apt/sources.list >/etc/apt/sources.list.d/debug.list
803
804# Remove non-existent debug repos
805RUN sed -i '/-\\(backports\\|security\\) /d' /etc/apt/sources.list.d/debug.list
806
807RUN cat /etc/apt/sources.list.d/debug.list
808
809RUN apt-get update && apt-get dist-upgrade -yy && apt-get install -yy \
810    abi-compliance-checker \
811    abi-dumper \
812    autoconf \
813    autoconf-archive \
814    bison \
815    cmake \
816    curl \
817    dbus \
818    device-tree-compiler \
819    flex \
820    g++-13 \
821    gcc-13 \
822    git \
823    glib-2.0 \
824    gnupg \
825    iproute2 \
826    iputils-ping \
827    libaudit-dev \
828    libc6-dbg \
829    libc6-dev \
830    libcjson-dev \
831    libconfig++-dev \
832    libcryptsetup-dev \
833    libdbus-1-dev \
834    libevdev-dev \
835    libgpiod-dev \
836    libi2c-dev \
837    libjpeg-dev \
838    libjson-perl \
839    libldap2-dev \
840    libmimetic-dev \
841    libnl-3-dev \
842    libnl-genl-3-dev \
843    libpam0g-dev \
844    libpciaccess-dev \
845    libperlio-gzip-perl \
846    libpng-dev \
847    libprotobuf-dev \
848    libsnmp-dev \
849    libssl-dev \
850    libsystemd-dev \
851    libtool \
852    liburing-dev \
853    libxml2-utils \
854    libxml-simple-perl \
855    lsb-release \
856    ninja-build \
857    npm \
858    pkg-config \
859    protobuf-compiler \
860    python3 \
861    python3-dev\
862    python3-git \
863    python3-mako \
864    python3-pip \
865    python3-protobuf \
866    python3-setuptools \
867    python3-socks \
868    python3-yaml \
869    rsync \
870    shellcheck \
871    socat \
872    software-properties-common \
873    sudo \
874    systemd \
875    valgrind \
876    vim \
877    wget \
878    xxd
879
880RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 13 \
881  --slave /usr/bin/g++ g++ /usr/bin/g++-13 \
882  --slave /usr/bin/gcov gcov /usr/bin/gcov-13 \
883  --slave /usr/bin/gcov-dump gcov-dump /usr/bin/gcov-dump-13 \
884  --slave /usr/bin/gcov-tool gcov-tool /usr/bin/gcov-tool-13
885RUN update-alternatives --remove cpp /usr/bin/cpp && \
886    update-alternatives --install /usr/bin/cpp cpp /usr/bin/cpp-13 13
887
888# Set up LLVM apt repository.
889RUN bash -c "$(wget -O - https://apt.llvm.org/llvm.sh)" 18
890
891# Install extra clang tools
892RUN apt-get install \
893        clang-18 \
894        clang-format-18 \
895        clang-tidy-18
896
897RUN update-alternatives --install /usr/bin/clang clang /usr/bin/clang-18 1000 \
898  --slave /usr/bin/clang++ clang++ /usr/bin/clang++-18 \
899  --slave /usr/bin/clang-tidy clang-tidy /usr/bin/clang-tidy-18 \
900  --slave /usr/bin/clang-format clang-format /usr/bin/clang-format-18 \
901  --slave /usr/bin/run-clang-tidy run-clang-tidy.py \
902        /usr/bin/run-clang-tidy-18 \
903  --slave /usr/bin/scan-build scan-build /usr/bin/scan-build-18
904
905"""
906
907if is_automated_ci_build:
908    dockerfile_base += f"""
909# Run an arbitrary command to pollute the docker cache regularly force us
910# to re-run `apt-get update` daily.
911RUN echo {Docker.timestamp()}
912RUN apt-get update && apt-get dist-upgrade -yy
913
914"""
915
916dockerfile_base += """
917RUN pip3 install --break-system-packages \
918        beautysh \
919        black \
920        codespell \
921        flake8 \
922        gcovr \
923        gitlint \
924        inflection \
925        isort \
926        jsonschema \
927        meson==1.3.0 \
928        requests
929
930RUN npm install -g \
931        eslint@v8.56.0 eslint-plugin-json@v3.1.0 \
932        markdownlint-cli@latest \
933        prettier@latest
934"""
935
936# Build the base and stage docker images.
937docker_base_img_name = Docker.tagname("base", dockerfile_base)
938Docker.build("base", docker_base_img_name, dockerfile_base)
939Package.generate_all()
940
941# Create the final Dockerfile.
942dockerfile = f"""
943# Build the final output image
944FROM {docker_base_img_name}
945{Package.df_all_copycmds()}
946
947# Some of our infrastructure still relies on the presence of this file
948# even though it is no longer needed to rebuild the docker environment
949# NOTE: The file is sorted to ensure the ordering is stable.
950RUN echo '{Package.depcache()}' > /tmp/depcache
951
952# Ensure the group, user, and home directory are created (or rename them if
953# they already exist).
954RUN if grep -q ":{gid}:" /etc/group ; then \
955        groupmod -n {username} $(awk -F : '{{ if ($3 == {gid}) {{ print $1 }} }}' /etc/group) ; \
956    else \
957        groupadd -f -g {gid} {username} ; \
958    fi
959RUN mkdir -p "{os.path.dirname(homedir)}"
960RUN if grep -q ":{uid}:" /etc/passwd ; then \
961        usermod -l {username} -d {homedir} -m $(awk -F : '{{ if ($3 == {uid}) {{ print $1 }} }}' /etc/passwd) ; \
962    else \
963        useradd -d {homedir} -m -u {uid} -g {gid} {username} ; \
964    fi
965RUN sed -i '1iDefaults umask=000' /etc/sudoers
966RUN echo "{username} ALL=(ALL) NOPASSWD: ALL" >>/etc/sudoers
967
968# Ensure user has ability to write to /usr/local for different tool
969# and data installs
970RUN chown -R {username}:{username} /usr/local/share
971
972# Update library cache
973RUN ldconfig
974
975{proxy_cmd}
976
977RUN /bin/bash
978"""
979
980# Do the final docker build
981docker_final_img_name = Docker.tagname(None, dockerfile)
982Docker.build("final", docker_final_img_name, dockerfile)
983
984# Print the tag of the final image.
985print(docker_final_img_name)
986