Add bazel launcher

This adds a launcher script for bazel.  Design doc discussing it is
here:  go/cros-build:bazel-launcher-proposal

BUG=b:253268519
TEST=unit tests
TEST=bin/bazel --project metallurgy --help

Change-Id: I13bf14ac709d6e5b9cf2c66c25c61fd4441056d7
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/4718365
Reviewed-by: Aaron Massey <aaronmassey@google.com>
Reviewed-by: Ryo Hashimoto <hashimoto@chromium.org>
Tested-by: Jack Rosenthal <jrosenth@chromium.org>
Commit-Queue: Jack Rosenthal <jrosenth@chromium.org>
Reviewed-by: Shuhei Takahashi <nya@chromium.org>
diff --git a/scripts/bazel.py b/scripts/bazel.py
new file mode 100644
index 0000000..817a8dd
--- /dev/null
+++ b/scripts/bazel.py
@@ -0,0 +1,154 @@
+# Copyright 2023 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Bazel command wrapper.
+
+This wrapper sets up necessary symlinks for the workspace, ensures Bazelisk via
+CIPD, and executes it.  It's also the right home for gathering any telemetry on
+users' Bazel commands.
+"""
+import argparse
+import logging
+import os
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from chromite.lib import cipd
+from chromite.lib import commandline
+from chromite.lib import constants
+from chromite.lib import osutils
+
+
+# TODO(jrosenth): We likely want to publish our own Bazelisk at some point
+# instead of relying upon Skia's.
+_BAZELISK_PACKAGE = "skia/bots/bazelisk_${os}_${arch}"
+_BAZELISK_VERSION = "version:0"
+
+# Symlinks which may exist in the workspace root without an underlying file in
+# src/bazel/workspace_root.  These are symlinks generated by bazel itself.
+_KNOWN_SYMLINKS = [
+    "bazel-bin",
+    "bazel-out",
+    "bazel-src",
+    "bazel-testlogs",
+]
+
+# Workspaces for each project are defined here.
+_PROJECTS = ["alchemy", "metallurgy", "fwsdk"]
+_WORKSPACES_DIR = constants.BAZEL_WORKSPACE_ROOT / "bazel" / "workspace_root"
+
+
+def _setup_workspace(project: str):
+    """Setup the Bazel workspace root.
+
+    Args:
+        project: The temporary project type (e.g., metallurgy, alchemy, or
+            fwsdk).  This argument will eventually be removed when all Bazel
+            projects share a unified workspace.
+    """
+    known_symlinks = set(_KNOWN_SYMLINKS)
+    for workspace in (
+        _WORKSPACES_DIR / "general",
+        _WORKSPACES_DIR / project,
+    ):
+        for path in workspace.iterdir():
+            osutils.SafeSymlink(
+                path, constants.BAZEL_WORKSPACE_ROOT / path.name
+            )
+            known_symlinks.add(path.name)
+
+    # Remove any stale symlinks from the workspace root.
+    for path in constants.BAZEL_WORKSPACE_ROOT.iterdir():
+        if path.is_symlink() and not path.name in known_symlinks:
+            osutils.SafeUnlink(path)
+
+
+def _get_default_project() -> str:
+    """Get the default value for --project.
+
+    It's inconvenient to pass --project for each Bazel invocation.  We assume if
+    the user has run with --project before, we can use the value from their last
+    invocation.
+
+    If no other default project can be found, the assumed project is "alchemy".
+
+    This function will be removed once all projects unify into a single Bazel
+    workspace.
+    """
+    workspace_file = constants.BAZEL_WORKSPACE_ROOT / "WORKSPACE.bazel"
+    if workspace_file.is_symlink():
+        project = Path(os.readlink(workspace_file)).parent.name
+        if project in _PROJECTS:
+            return project
+        else:
+            logging.warning(
+                "Your checkout contains a WORKSPACE.bazel symlink which points "
+                "to an unknown project (%s).",
+                project,
+            )
+
+    logging.notice(
+        "Assuming a default project of alchemy.  Pass --project if you want a "
+        "different one."
+    )
+    return "alchemy"
+
+
+def _get_bazelisk() -> Path:
+    """Ensure Bazelisk from CIPD.
+
+    Returns:
+        The path to the Bazel executable.
+    """
+    cipd_path = cipd.GetCIPDFromCache()
+    package_path = cipd.InstallPackage(
+        cipd_path, _BAZELISK_PACKAGE, _BAZELISK_VERSION
+    )
+    return package_path / "bazelisk"
+
+
+def _get_parser() -> commandline.ArgumentParser:
+    """Build the argument parser."""
+
+    # We don't create a help message, as we want --help to go to the Bazel help.
+    parser = commandline.ArgumentParser(add_help=False)
+
+    parser.add_argument(
+        "--project",
+        choices=_PROJECTS,
+        default=_get_default_project(),
+        help=(
+            "The temporary project type.  This argument will be removed once "
+            "all projects unify into a single Bazel workspace."
+        ),
+    )
+
+    return parser
+
+
+def parse_arguments(
+    argv: Optional[List[str]],
+) -> Tuple[argparse.Namespace, List[str]]:
+    """Parse and validate arguments.
+
+    Args:
+        argv: The command line to parse.
+
+    Returns:
+        A two tuple, the parsed arguments, and the remaining arguments that
+        should be passed to Bazel.
+    """
+    parser = _get_parser()
+    opts, bazel_args = parser.parse_known_args(argv)
+    return opts, bazel_args
+
+
+def main(argv: Optional[List[str]]) -> Optional[int]:
+    """Main."""
+    opts, bazel_args = parse_arguments(argv)
+
+    _setup_workspace(opts.project)
+
+    bazelisk = _get_bazelisk()
+    os.execv(bazelisk, [bazelisk, *bazel_args])