docs: def build_docker_image(
          build_context, name: str, tag: str, base_image, deps: list=None,
          env: dict=None, work_dir: str=None,
          entrypoint: list=None, cmd: list=None, full_path_cmd: bool=False,
          distro: dict=None, image_caching_behavior: dict=None,
          runtime_params: dict=None, ybt_bin_path: str=None,
          build_user: str=None, run_user: str=None, labels: dict=None,
          no_artifacts: bool=False):
      """Build Docker image, and return a (image_id, image_name:tag) tuple of
         built image, if built successfully.
  
      Notes:
      Using the given image name & tag as they are, but using the global host
      Docker image namespace (as opposed to a private-project-workspace),
      so collisions between projects are possible (and very likely, e.g., when
      used in a CI environment, or shared machine use-case).
      Trying to address this issue to some extent by using the image ID after
      it is built, which is unique.
      There's a race condition between "build" and "get ID" - ignoring this at
      the moment.
      Also, I'm not sure about Docker's garbage collection...
      If I use the image ID in other places, and someone else "grabbed" my image
      name and tag (so now my image ID is floating), is it still safe to use
      the ID? Or is it going to be garbage collected and cleaned up sometime?
      From my experiments, the "floating image ID" was left alone (usable),
      but prone to "manual cleanups".
      Also ignoring this at the moment...
      Thought about an alternative approach based on first building an image
      with a randomly generated tag, so I can use that safely later, and tag it
      to the requested tag.
      Decided against it, seeing that every run increases the local Docker
      images spam significantly with a bunch of random tags, making it even less
      useful.
      Documenting it here to remember it was considered, and to discuss it
      further in case anyone thinks it's a better idea than what I went with.
      """
      docker_image = '{}:{}'.format(name, tag)
      # create directory for this target under a private builder workspace
      workspace_dir = build_context.get_workspace('DockerBuilder', docker_image)
      # generate Dockerfile and build it
      dockerfile_path = join(workspace_dir, 'Dockerfile')
      dockerfile = [
          'FROM {}\n'.format(format_qualified_image_name(base_image)),
          'ARG DEBIAN_FRONTEND=noninteractive\n',
      ]
      if build_user:
          dockerfile.append('USER {}\n'.format(build_user))
      workspace_src_dir = join(workspace_dir, 'src')
      rmtree(workspace_src_dir)
      num_linked = 0
      apt_repo_deps = []
      effective_env = {}
      effective_labels = {}
      KNOWN_RUNTIME_PARAMS = frozenset((
          'ports', 'volumes', 'container_name', 'daemonize', 'interactive',
          'term', 'auto_it', 'rm', 'env', 'work_dir', 'impersonate'))
      if runtime_params is None:
          runtime_params = {}
      runtime_params['ports'] = listify(runtime_params.get('ports'))
      runtime_params['volumes'] = listify(runtime_params.get('volumes'))
      runtime_params['env'] = dict(runtime_params.get('env', {}))
      env_manipulations = {}
      packaging_layers = []
  
      def add_package(pkg_type, pkg_spec):
          """Add package specification of certain package type.
  
          Uses last layer if matches package type, otherwise opens a new layer.
  
          This can result "Docker layer framgantation", by opening and closing
          many layers.
          No optimization is performed on detecting opportunities to merge layers
          that were split just because of arbitrary topological sort decision
          (tie breaker), and not a real topology in the target graph.
          Such an optimization could be done here by inspecting the graph
          directly, but decided not to go into it at this stage, since it's not
          clear it's beneficial overall (e.g. better to have more layers so
          some of them can remain cached if others change).
          A better optimization (also not implemented) could be to do topological
          sort tie breaking based on Docker-cache optimization - e.g., move new
          things to new layers in order to keep old things in cached layers.
          """
          if len(packaging_layers) == 0:
              layer = (pkg_type, list())
              packaging_layers.append(layer)
          else:
              layer = packaging_layers[-1]
              if pkg_type != layer[0]:
                  layer = (pkg_type, list())
                  packaging_layers.append(layer)
          if isinstance(pkg_spec, list):
              layer[1].extend(pkg_spec)
          else:
              layer[1].append(pkg_spec)
  
      def check_env_overrides(new_vars: set, op_kind: str, vars_source: str):
          overridden_vars = new_vars.intersection(effective_env.keys())
          if overridden_vars:
              raise ValueError(
                  'Following env vars {} from {} override previously set vars '
                  'during build of Docker image "{}": {}'.format(
                      op_kind, vars_source, docker_image,
                      ', '.join(overridden_vars)))
          if op_kind == 'set':
              overridden_vars = new_vars.intersection(env_manipulations.keys())
              if overridden_vars:
                  raise ValueError(
                      'Following env vars {} from {} override previous '
                      'manipulations during build of Docker image "{}": {}'
                      .format(op_kind, vars_source, docker_image,
                              ', '.join(overridden_vars)))
  
      def check_label_overrides(new_labels: set, labels_source: str):
          overridden_labels = new_labels.intersection(effective_labels.keys())
          if overridden_labels:
              raise ValueError(
                  'Following labels set from {} override previously set labels '
                  'during build of Docker image "{}": {}'.format(
                      labels_source, docker_image, ', '.join(overridden_labels)))
  
      def update_runtime_params(new_rt_param: dict, params_source: str):
          invalid_keys = set(
              new_rt_param.keys()).difference(KNOWN_RUNTIME_PARAMS)
          if invalid_keys:
              raise ValueError(
                  'Unknown keys in runtime params of {}: {}'.format(
                      params_source, ', '.join(invalid_keys)))
          # TODO(itamar): check for invalid values and inconsistencies
          runtime_params['ports'].extend(listify(new_rt_param.get('ports')))
          runtime_params['volumes'].extend(listify(new_rt_param.get('volumes')))
          runtime_params['env'].update(dict(runtime_params.get('env', {})))
          for param in ('container_name', 'daemonize', 'interactive', 'term',
                        'auto_it', 'rm', 'work_dir', 'impersonate'):
              if param in new_rt_param:
                  # TODO(itamar): check conflicting overrides
                  runtime_params[param] = new_rt_param[param]
  
      if deps is None:
          deps = []
      # Get all base image deps, so when building this image we can skip adding
      # deps that already exist in the base image.
      base_image_deps = set(build_context.generate_dep_names(base_image))
      for dep in deps:
          if not distro and 'distro' in dep.props:
              distro = dep.props.distro
          if 'runtime_params' in dep.props:
              update_runtime_params(dep.props.runtime_params,
                                    'dependency {}'.format(dep.name))
  
          if dep.name in base_image_deps:
              logger.debug('Skipping base image dep {}', dep.name)
              continue
          if not no_artifacts:
              num_linked += dep.artifacts.link_for_image(
                  workspace_src_dir, build_context.conf)
  
          PACKAGING_PARAMS = frozenset(
              ('set_env', 'semicolon_join_env', 'set_label'))
          invalid_keys = set(
              dep.props.packaging_params.keys()).difference(PACKAGING_PARAMS)
          if invalid_keys:
              raise ValueError(
                  'Unknown keys in packaging params of target "{}": {}'.format(
                      dep.name, ', '.join(invalid_keys)))
          if 'set_env' in dep.props.packaging_params:
              dep_env = dep.props.packaging_params['set_env']
              check_env_overrides(
                  set(dep_env.keys()), 'set', 'dependency {}'.format(dep.name))
              effective_env.update(dep_env)
          if 'semicolon_join_env' in dep.props.packaging_params:
              append_env = dep.props.packaging_params['semicolon_join_env']
              check_env_overrides(set(append_env.keys()), 'manipulations',
                                  'dependency {}'.format(dep.name))
              for key, value in append_env.items():
                  env_manip = env_manipulations.setdefault(
                      key, ['${{{}}}'.format(key)])
                  if value not in env_manip:
                      env_manip.append(value)
          if 'set_label' in dep.props.packaging_params:
              dep_labels = dep.props.packaging_params['set_label']
              check_label_overrides(
                  set(dep_labels.keys()), 'dependency {}'.format(dep.name))
              effective_labels.update(dep_labels)
  
          if 'apt-repository' in dep.tags:
              apt_repo_deps.append(dep)
          if 'apt-installable' in dep.tags:
              add_package('apt', format_apt_specifier(dep))
          if 'pip-installable' in dep.tags:
              add_package(dep.props.pip, format_pypi_specifier(dep))
          if 'custom-installer' in dep.tags:
              add_package('custom', get_installer_desc(build_context, dep))
          if 'npm-installable' in dep.tags:
              if dep.props.global_install:
                  add_package('npm-global', format_npm_specifier(dep))
              else:
                  add_package('npm-local', format_npm_specifier(dep))
          if 'gem-installable' in dep.tags:
              add_package('gem', format_gem_specifier(dep))
  
      # Add environment variables (one layer)
      if env:
          check_env_overrides(set(env.keys()), 'set', 'the target')
          effective_env.update(env)
      for key, value in env_manipulations.items():
          effective_env[key] = ':'.join(value)
      if effective_env:
          dockerfile.append(
              'ENV {}\n'.format(
                  ' '.join('{}="{}"'.format(key, value)
                           for key, value in sorted(effective_env.items()))))
  
      apt_key_cmds = []
      apt_repositories = []
      for dep in apt_repo_deps:
          source_line, apt_key_cmd = parse_apt_repository(
              build_context, dep, distro)
          apt_repositories.append(source_line)
          if apt_key_cmd:
              apt_key_cmds.append(apt_key_cmd)
      # Handle apt keys (one layer for all)
      if apt_key_cmds:
          dockerfile.append(
              'RUN {}\n'.format(' && '.join(apt_key_cmds)))
      # Handle apt repositories (one layer for all)
      if apt_repositories:
          list_name = '{}.list'.format(name)
          apt_src_file = join(workspace_dir, list_name)
          if make_apt_sources_list(apt_repositories, apt_src_file):
              dockerfile.append(
                  'COPY {} /etc/apt/sources.list.d/\n'.format(list_name))
  
      custom_cnt = 0
      pip_req_cnt = defaultdict(int)
  
      def install_npm(npm_packages: list, global_install: bool):
          if npm_packages:
              if not global_install:
                  dockerfile.append('WORKDIR /usr/src\n')
              dockerfile.append(
                  'RUN npm install {} {}\n'.format(
                      ' '.join(npm_packages),
                      '--global' if global_install else '&& npm dedupe'))
  
      for layer in packaging_layers:
          pkg_type, packages = layer
  
          if pkg_type == 'apt':
              dockerfile.append(
                  'RUN apt-get update -y && apt-get install '
                  '--no-install-recommends -y {} '
                  '&& rm -rf /var/lib/apt/lists/*\n'.format(
                      ' '.join(sorted(packages))))
  
          elif pkg_type == 'custom':
              # Handle custom installers (2 layers per occurrence)
              custom_cnt += 1
              packages_dir = 'packages{}'.format(custom_cnt)
              tmp_install = '/tmp/install{}'.format(custom_cnt)
              workspace_packages_dir = join(workspace_dir, packages_dir)
              rmtree(workspace_packages_dir)
              os.makedirs(workspace_packages_dir)
              run_installers = []
              for custom_installer_desc in packages:
                  target_name, install_script, package = custom_installer_desc
                  package_tar = basename(package)
                  link_node(package, join(workspace_packages_dir, package_tar))
                  run_installers.extend([
                      'tar -xf {0}/{1} -C {0}'.format(tmp_install, package_tar),
                      'cd {}/{}'.format(tmp_install, target_name),
                      'cat {} | tr -d \'\\r\' | bash'.format(install_script),
                  ])
              dockerfile.extend([
                  'COPY {} {}\n'.format(packages_dir, tmp_install),
                  'RUN {} && cd / && rm -rf {}\n'.format(
                      ' && '.join(run_installers), tmp_install),
              ])
  
          elif pkg_type.startswith('pip'):
              # Handle pip packages (2 layers per occurrence)
              req_fname = 'requirements_{}_{}.txt'.format(
                  pkg_type, pip_req_cnt[pkg_type] + 1)
              pip_req_file = join(workspace_dir, req_fname)
              if make_pip_requirements(packages, pip_req_file):
                  upgrade_pip = (
                      '{pip} install --no-cache-dir --upgrade pip && '
                      .format(pip=pkg_type)
                      if pip_req_cnt[pkg_type] == 0 else '')
                  dockerfile.extend([
                      'COPY {} /usr/src/\n'.format(req_fname),
                      'RUN {upgrade_pip}'
                      '{pip} install --no-cache-dir -r /usr/src/{reqs}\n'
                      .format(upgrade_pip=upgrade_pip, pip=pkg_type,
                              reqs=req_fname)
                  ])
                  pip_req_cnt[pkg_type] += 1
  
          elif pkg_type == 'npm-global':
              # Handle npm global packages (1 layer per occurrence)
              install_npm(packages, True)
  
          elif pkg_type == 'npm-local':
              # Handle npm local packages (1 layer per occurrence)
              install_npm(packages, False)
  
          elif pkg_type == 'gem':
              # Handle gem (ruby) packages (1 layer per occurrence)
              dockerfile.append(
                  'RUN gem install {}\n'.format(' '.join(packages)))
  
      if work_dir:
          dockerfile.append('WORKDIR {}\n'.format(work_dir))
  
      if num_linked > 0:
          dockerfile.append('COPY src /usr/src\n')
  
      # Add labels (one layer)
      if labels:
          check_label_overrides(set(labels.keys()), 'the target')
          effective_labels.update(labels)
      if effective_labels:
          dockerfile.append(
              'LABEL {}\n'.format(
                  ' '.join('"{}"="{}"'.format(key, value)
                           for key, value in sorted(effective_labels.items()))))
  
      def format_docker_cmd(docker_cmd):
          return ('"{}"'.format(cmd) for cmd in docker_cmd)
  
      if run_user:
          dockerfile.append('USER {}\n'.format(run_user))
  
      # Add ENTRYPOINT (one layer)
      if entrypoint:
          # TODO(itamar): Consider adding tini as entrypoint also if given
          # Docker CMD without a Docker ENTRYPOINT?
          entrypoint[0] = PurePath(entrypoint[0]).as_posix()
          if full_path_cmd:
              entrypoint[0] = (PurePath('/usr/src/app') /
                               entrypoint[0]).as_posix()
          if build_context.conf.with_tini_entrypoint:
              entrypoint = ['tini', '--'] + entrypoint
          dockerfile.append(
              'ENTRYPOINT [{}]\n'.format(
                  ', '.join(format_docker_cmd(entrypoint))))
  
      # Add CMD (one layer)
      if cmd:
          cmd[0] = PurePath(cmd[0]).as_posix()
          if full_path_cmd:
              cmd[0] = (PurePath('/usr/src/app') / cmd[0]).as_posix()
          dockerfile.append(
              'CMD [{}]\n'.format(', '.join(format_docker_cmd(cmd))))
  
      # TODO(itamar): write only if changed?
      with open(dockerfile_path, 'w') as dockerfile_f:
          dockerfile_f.writelines(dockerfile)
      docker_build_cmd = ['docker', 'build']
      if build_context.conf.no_docker_cache:
          docker_build_cmd.append('--no-cache')
      docker_build_cmd.extend(['-t', docker_image, workspace_dir])
      logger.info('Building docker image "{}" using command {}',
                  docker_image, docker_build_cmd)
      run(docker_build_cmd, check=True)
      # TODO(itamar): race condition here
      image_id = get_cached_image_id(docker_image)
      metadata = {
          'image_id': image_id,
          'images': [{
              'name': docker_image,
              'pushed': False,
          }],
      }
      icb = ImageCachingBehavior(name, tag, image_caching_behavior)
      if icb.push_image_after_build:
          tag_docker_image(image_id, icb.remote_image)
          push_docker_image(icb.remote_image, build_context.conf.docker_push_cmd)
          metadata['images'].append({
              'name': icb.remote_image,
              'pushed': True,
          })
      # Generate ybt_bin scripts
      if ybt_bin_path:
          if ybt_bin_path.startswith('//'):
              ybt_bin_path = join(build_context.conf.get_bin_path(),
                                  ybt_bin_path[2:])
          # Make sure ybt_bin's are created only under bin_path
          assert (build_context.conf.get_bin_path() ==
                  commonpath([build_context.conf.get_bin_path(), ybt_bin_path]))
  
          def format_docker_run_params(params: dict):
              param_strings = []
              if 'container_name' in params:
                  param_strings.extend(['--name', params['container_name']])
              if params.get('interactive'):
                  param_strings.append('-i')
              if params.get('term'):
                  param_strings.append('-t')
              if params.get('rm'):
                  param_strings.append('--rm')
              if params.get('daemonize'):
                  param_strings.append('-d')
              if params.get('impersonate') and platform.system() == 'Linux':
                  param_strings.extend([
                      '-u', '$( id -u ):$( id -g )',
                      '-v', '/etc/passwd:/etc/passwd:ro',
                      '-v', '/etc/group:/etc/group:ro'])
              for port in params['ports']:
                  param_strings.extend(['-p', port])
              for volume in params['volumes']:
                  param_strings.extend(['-v', volume])
              if params.get('work_dir'):
                  param_strings.extend(['-w', params['work_dir']])
              for var, value in params['env'].items():
                  param_strings.extend(['-e', '{}="{}"'.format(var, value)])
              return ' '.join(param_strings)
  
          with open(join(dirname(abspath(__file__)),
                    'ybtbin.sh.tmpl'), 'r') as tmpl_f:
              ybt_bin = tmpl_f.read().format(
                  image_name=docker_image, image_id=image_id,
                  docker_opts=format_docker_run_params(runtime_params),
                  default_opts='$IT' if runtime_params.get('auto_it') else '')
          with open(ybt_bin_path, 'w') as ybt_bin_f:
              ybt_bin_f.write(ybt_bin)
          os.chmod(ybt_bin_path, 0o755)
          metadata['ybt_bin'] = ybt_bin_path
      return metadata