scripts: subtools builder - a starting point.

The starting point is the developer-run script that can build and
export a subtool with a single command.

This CL implements fetching a base SDK, setting it up for building
subtools, and the ability to install packages into the subtools
builder chroot.

BUG=b:277992359
TEST=./bin/build_sdk_subtools shellcheck

Change-Id: Ie30733fcb48f69a10f58fc9fc4b73aa37f7f5142
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/4617263
Reviewed-by: Mike Frysinger <vapier@chromium.org>
Commit-Queue: Trent Apted <tapted@chromium.org>
Tested-by: Trent Apted <tapted@chromium.org>
diff --git a/scripts/build_sdk_subtools_unittest.py b/scripts/build_sdk_subtools_unittest.py
new file mode 100644
index 0000000..4c346e5
--- /dev/null
+++ b/scripts/build_sdk_subtools_unittest.py
@@ -0,0 +1,167 @@
+# 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.
+
+"""Unit tests for build_sdk_subtools."""
+
+import os
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from chromite.lib import commandline
+from chromite.lib import cros_build_lib
+from chromite.scripts import build_sdk_subtools
+
+
+@pytest.fixture(name="outside_chroot")
+def outside_chroot_fixture():
+    """Mocks IsInsideChroot to be False."""
+    with mock.patch.object(
+        cros_build_lib, "IsInsideChroot", return_value=False
+    ) as outside_chroot:
+        yield outside_chroot
+
+
+@pytest.fixture(name="mock_emerge")
+def mock_emerge_fixture():
+    """Stubs the build_sdk_subtools emerge helper and sets it up to run."""
+    with mock.patch.multiple(
+        "chromite.scripts.build_sdk_subtools",
+        _run_system_emerge=mock.DEFAULT,
+        _is_inside_subtools_chroot=mock.DEFAULT,
+    ) as mocks:
+        mocks["_is_inside_subtools_chroot"].return_value = True
+        yield mocks["_run_system_emerge"]
+
+
+def test_must_run_outside_sdk(caplog) -> None:
+    """Tests build_sdk_subtools complains if run in the chroot."""
+    with pytest.raises(cros_build_lib.DieSystemExit) as error_info:
+        build_sdk_subtools.main()
+    assert error_info.value.code == 1
+    assert "build_sdk_subtools must be run outside the chroot" in caplog.text
+
+
+def test_cros_sdk(run_mock, outside_chroot) -> None:
+    """Tests the steps leading up to the cros_sdk invocation."""
+    # Fake a failure from cros_sdk to ensure it propagates.
+    run_mock.SetDefaultCmdResult(returncode=42)
+
+    assert build_sdk_subtools.main() == 42
+    assert outside_chroot.called
+    assert run_mock.call_count == 1
+    assert run_mock.call_args_list[0].args[0] == [
+        "cros_sdk",
+        "--chroot",
+        "/mnt/host/source/chroot/build/amd64-subtools-host",
+        "--create",
+        "--skip-chroot-upgrade",
+    ]
+
+
+def test_cros_sdk_clean(run_mock, outside_chroot) -> None:
+    """Tests steps leading up to the cros_sdk invocation with --clean."""
+    run_mock.SetDefaultCmdResult(returncode=42)
+
+    assert build_sdk_subtools.main(["--clean"]) == 42
+    assert outside_chroot.called
+    assert run_mock.call_count == 1
+    cros_sdk_cmd = run_mock.call_args_list[0].args[0]
+    assert cros_sdk_cmd[0] == "cros_sdk"
+    assert "--delete" in cros_sdk_cmd
+
+
+def test_cros_sdk_output_dir(run_mock, outside_chroot) -> None:
+    """Tests steps leading up to the cros_sdk invocation with --output-dir."""
+    run_mock.SetDefaultCmdResult(returncode=42)
+
+    assert build_sdk_subtools.main(["--output-dir", "/foo"]) == 42
+    assert outside_chroot.called
+    cros_sdk_cmd = run_mock.call_args_list[0].args[0]
+    chroot_arg_index = cros_sdk_cmd.index("/mnt/host/source/chroot/foo")
+    assert cros_sdk_cmd[0] == "cros_sdk"
+    assert cros_sdk_cmd[chroot_arg_index - 1] == "--chroot"
+
+
+def test_chroot_required_after_cros_sdk(run_mock, outside_chroot) -> None:
+    """Tests that build_sdk_subtools will ask for chroot when setup."""
+    with pytest.raises(commandline.ChrootRequiredError) as error_info:
+        build_sdk_subtools.main(["--no-setup-chroot"])
+
+    assert run_mock.call_count == 0
+    assert outside_chroot.called
+    assert error_info.value.cmd == ["build_sdk_subtools", "--no-setup-chroot"]
+    assert error_info.value.chroot_args == [
+        "--chroot",
+        "/mnt/host/source/chroot/build/amd64-subtools-host",
+    ]
+
+
+def test_chroots_into_output_dir(run_mock, outside_chroot) -> None:
+    """Tests that --output-dir is consumed properly after setup."""
+    with pytest.raises(commandline.ChrootRequiredError) as error_info:
+        build_sdk_subtools.main(["--no-setup-chroot", "--output-dir", "/foo"])
+
+    assert run_mock.call_count == 0
+    assert outside_chroot.called
+    assert error_info.value.chroot_args == [
+        "--chroot",
+        "/mnt/host/source/chroot/foo",
+    ]
+
+
+def test_setup_sdk_invocation(run_mock, outside_chroot) -> None:
+    """Tests the SDK setup invocation, before it becomes a subtools chroot."""
+    # Fake success from cros_sdk, failure from _setup_base_sdk().
+    run_mock.SetDefaultCmdResult(returncode=0)
+    run_mock.AddCmdResult(
+        ["sudo", "--", "build_sdk_subtools", "--relaunch-for-setup"],
+        returncode=42,
+    )
+
+    # Avoid bots passing CROS_CACHEDIR via `sudo` and messing up cmd matching.
+    with mock.patch.dict("os.environ"):
+        os.environ.pop("CROS_CACHEDIR", None)
+        assert build_sdk_subtools.main() == 42
+
+    assert run_mock.call_count == 2
+    assert outside_chroot.called
+
+    sudo_run_cmd = run_mock.call_args_list[1]
+
+    assert sudo_run_cmd.args[0] == [
+        "sudo",
+        "--",
+        "build_sdk_subtools",
+        "--relaunch-for-setup",
+    ]
+    assert sudo_run_cmd.kwargs["enter_chroot"] is True
+    assert sudo_run_cmd.kwargs["chroot_args"] == [
+        "--chroot",
+        "/mnt/host/source/chroot/build/amd64-subtools-host",
+    ]
+    # Stop here: Actually running `--relaunch-for-setup` principally wants to
+    # mutate the SDK state as root, which is too messy as a unit test.
+
+
+def test_default_package(mock_emerge) -> None:
+    """Tests a default virtual package is provided to update packages."""
+    assert build_sdk_subtools.main() == 0
+    assert mock_emerge.call_count == 1
+    emerge_cmd = mock_emerge.call_args.args[0]
+    assert Path("/mnt/host/source/chromite/bin/parallel_emerge") in emerge_cmd
+    assert emerge_cmd[-1] == "virtual/target-sdk-subtools"
+
+
+def test_provided_package(mock_emerge) -> None:
+    """Tests the default package can be passed in from command line."""
+    assert build_sdk_subtools.main(["vim"]) == 0
+    assert mock_emerge.call_args.args[0][-1] == "vim"
+
+
+def test_skip_package_update(mock_emerge) -> None:
+    """Tests --skip-package-update will not try to emerge anything."""
+    assert build_sdk_subtools.main(["--no-update-packages"]) == 0
+    assert mock_emerge.call_count == 0