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