From 5e2bb02480bf951c66b6045dac8060065faa97e1 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Fri, 3 Jul 2026 16:47:08 -0700 Subject: [PATCH] Map bats-core artifact to Ubuntu system package --- CHANGELOG.md | 3 + cli/python/base_setup/artifacts.py | 101 +++++++++++++++++- cli/python/base_setup/tests/test_artifacts.py | 79 ++++++++++++++ docs/artifact-adapter-registry.md | 5 + docs/doctor-findings.md | 1 + docs/linux-support.md | 5 + 6 files changed, 193 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c24d9e4..ab62e918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and Base versions are tracked in the repo-root `VERSION` file. ### Fixed +- Made the `tool:bats-core` project artifact platform-aware on Ubuntu/Debian, + mapping it to the system `bats` package instead of planning a Homebrew + `brew install bats-core` command. - Preflighted `mise` trust before `basectl setup` runs `mise install`, so untrusted project configs fail with a Base recovery message instead of raw lower-level `mise install` output. diff --git a/cli/python/base_setup/artifacts.py b/cli/python/base_setup/artifacts.py index d8423f3f..d8f5070d 100644 --- a/cli/python/base_setup/artifacts.py +++ b/cli/python/base_setup/artifacts.py @@ -6,6 +6,7 @@ import venv from collections.abc import Iterable from dataclasses import dataclass +from dataclasses import replace from pathlib import Path import base_cli @@ -14,6 +15,7 @@ from .checks import ArtifactCheck from .errors import ArtifactError from .manifest import ArtifactRequest +from .platform_policy import current_base_platform from .python_policy import evaluate_python_requirement from .python_policy import inspect_python_interpreter from .python_policy import PythonInterpreter @@ -24,6 +26,9 @@ PIP_INSTALL_COMMAND_PREFIX = ("-m", "pip", "install", "--disable-pip-version-check") PYTHON_ARTIFACT_PROBE_TIMEOUT_SECONDS = process.DIAGNOSTIC_TIMEOUT_SECONDS +LINUX_DEBIAN_SYSTEM_TOOL_PACKAGES = { + ("tool", "bats-core"): "bats", +} @dataclass(frozen=True) @@ -65,10 +70,26 @@ def resolve_artifact_definitions(artifacts: tuple[ArtifactRequest, ...]) -> tupl f"'{artifact.name}' of type '{artifact.artifact_type}'. " "Base does not know how to manage this artifact yet." ) - definitions.append(definition) + definitions.append(platform_artifact_definition(definition)) return tuple(definitions) +def platform_artifact_definition(definition: ArtifactDefinition) -> ArtifactDefinition: + if current_base_platform() != "linux-debian": + return definition + + system_package = LINUX_DEBIAN_SYSTEM_TOOL_PACKAGES.get((definition.artifact_type, definition.name)) + if system_package is None: + return definition + + return replace( + definition, + manager="system-package", + package=system_package, + check_kind="system_command", + ) + + def merge_artifacts( default_artifacts: tuple[ArtifactRequest, ...], manifest_artifacts: tuple[ArtifactRequest, ...], @@ -106,6 +127,8 @@ def check_artifact( ) -> ArtifactCheck: if definition.manager == "homebrew": return check_homebrew_artifact(project, artifact, definition) + if definition.manager == "system-package": + return check_system_package_artifact(project, artifact, definition) if definition.manager == "pip": return check_python_artifact(project, artifact, definition) return ArtifactCheck( @@ -237,6 +260,44 @@ def check_python_artifact( ) +def check_system_package_artifact( + _project: str, + artifact: ArtifactRequest, + definition: ArtifactDefinition, +) -> ArtifactCheck: + if artifact.version != "latest": + return ArtifactCheck( + name=artifact.name, + ok=False, + message=( + f"System package artifact '{artifact.name}' specifies version '{artifact.version}', " + "but Base only supports system package artifact version 'latest' right now." + ), + fix=f"Update '{artifact.name}' in the project manifest to use version 'latest'.", + finding_id="BASE-P034", + details=artifact_details(definition), + ) + + if process.command_exists(definition.package): + return ArtifactCheck( + name=artifact.name, + ok=True, + message=f"Artifact '{artifact.name}' is available through system package '{definition.package}'.", + fix="", + finding_id="BASE-P034", + details=artifact_details(definition), + ) + + return ArtifactCheck( + name=artifact.name, + ok=False, + message=f"Artifact '{artifact.name}' is missing system package '{definition.package}'.", + fix=f"Run 'basectl setup --yes' or install Ubuntu/Debian package '{definition.package}'.", + finding_id="BASE-P034", + details=artifact_details(definition), + ) + + def reconcile_artifact( ctx: base_cli.Context, definition: ArtifactDefinition, @@ -248,6 +309,9 @@ def reconcile_artifact( if definition.manager == "homebrew": reconcile_homebrew_artifact(ctx, definition, version, dry_run=dry_run) return + if definition.manager == "system-package": + reconcile_system_package_artifact(ctx, definition, version, dry_run=dry_run) + return if definition.manager == "pip": reconcile_python_artifact(ctx, definition, version, runtime_config, dry_run=dry_run) return @@ -348,6 +412,41 @@ def reconcile_homebrew_artifact( process.run_command(ctx, install_command) +def reconcile_system_package_artifact( + ctx: base_cli.Context, + definition: ArtifactDefinition, + version: str, + dry_run: bool, +) -> None: + if version != "latest": + raise ArtifactError( + "System package artifact " + f"'{definition.name}' specifies version '{version}', but Base only supports " + "system package artifact version 'latest' right now." + ) + + if process.command_exists(definition.package): + ctx.log.info( + "Artifact '%s' is already available through system package '%s'.", + definition.name, + definition.package, + ) + return + + if dry_run: + ctx.log.info( + "[DRY-RUN] Would require system package '%s' for artifact '%s'.", + definition.package, + definition.name, + ) + return + + raise ArtifactError( + f"System package '{definition.package}' is required for artifact '{definition.name}'. " + "Run 'basectl setup --yes' or install the package manually, then rerun setup." + ) + + def reconcile_python_artifact( ctx: base_cli.Context, definition: ArtifactDefinition, diff --git a/cli/python/base_setup/tests/test_artifacts.py b/cli/python/base_setup/tests/test_artifacts.py index af044ff2..172f86c3 100644 --- a/cli/python/base_setup/tests/test_artifacts.py +++ b/cli/python/base_setup/tests/test_artifacts.py @@ -1,5 +1,7 @@ from __future__ import annotations +# pylint: disable=too-many-lines + import io import importlib.util import os @@ -121,6 +123,18 @@ def test_base_dev_manifest_declares_supported_tools(self) -> None: self.assertIsNotNone(get_artifact_definition("tool", "gh")) self.assertIsNotNone(get_artifact_definition("tool", "shellcheck")) + def test_bats_core_uses_system_package_on_linux_debian(self) -> None: + artifact = ArtifactRequest("tool", "bats-core", "latest") + + with mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}): + definition = artifacts.resolve_artifact_definitions((artifact,))[0] + + self.assertEqual(definition.name, "bats-core") + self.assertEqual(definition.manager, "system-package") + self.assertEqual(definition.package, "bats") + self.assertEqual(definition.target, "system") + self.assertEqual(definition.check_kind, "system_command") + def test_docker_and_colima_are_supported_tools(self) -> None: @@ -375,6 +389,37 @@ def test_known_homebrew_artifact_dry_run_does_not_require_brew(self) -> None: self.assertIn("[DRY-RUN] Would run: brew install terraform", stderr) run_check.assert_not_called() + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") + def test_bats_core_artifact_dry_run_uses_system_package_on_linux_debian(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + manifest_path = Path(tmpdir) / "base_manifest.yaml" + manifest_path.write_text( + "\n".join( + [ + "project:", + " name: demo", + "", + "artifacts:", + " - type: tool", + " name: bats-core", + " version: latest", + ] + ), + encoding="utf-8", + ) + + with ( + mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}), + mock.patch("base_setup.process.command_exists", return_value=False), + mock.patch("base_setup.process.run_check") as run_check, + ): + status, _stdout, stderr = run_engine(["--dry-run", "--manifest", str(manifest_path)]) + + self.assertEqual(status, 0) + self.assertNotIn("brew install bats-core", stderr) + self.assertIn("system package 'bats'", stderr) + run_check.assert_not_called() + @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") @@ -411,6 +456,40 @@ def test_docker_and_colima_artifacts_dry_run_through_homebrew(self) -> None: run_check.assert_not_called() + def test_bats_core_artifact_setup_accepts_installed_system_command_on_linux_debian(self) -> None: + artifact = ArtifactRequest("tool", "bats-core", "latest") + ctx = fake_context() + + with ( + mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}), + mock.patch("base_setup.process.command_exists", return_value=True), + mock.patch("base_setup.process.run_command") as run_command, + ): + definition = artifacts.resolve_artifact_definitions((artifact,))[0] + artifacts.reconcile_artifact(ctx, definition, artifact.version, "demo", dry_run=False) + + run_command.assert_not_called() + info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] + self.assertIn( + "Artifact 'bats-core' is already available through system package 'bats'.", + info_messages, + ) + + def test_bats_core_artifact_check_reports_missing_system_package_on_linux_debian(self) -> None: + artifact = ArtifactRequest("tool", "bats-core", "latest") + + with ( + mock.patch.dict(os.environ, {"BASE_PLATFORM": "linux-debian"}), + mock.patch("base_setup.process.command_exists", return_value=False), + ): + definition = artifacts.resolve_artifact_definitions((artifact,))[0] + check = artifacts.check_artifact("demo", artifact, definition) + + self.assertFalse(check.ok) + self.assertEqual(check.finding_id, "BASE-P034") + self.assertIn("system package 'bats'", check.message) + self.assertEqual(check.fix, "Run 'basectl setup --yes' or install Ubuntu/Debian package 'bats'.") + def test_homebrew_artifact_rejects_non_latest_version(self) -> None: definition = get_artifact_definition("tool", "terraform") diff --git a/docs/artifact-adapter-registry.md b/docs/artifact-adapter-registry.md index 634b0e77..4ae827a2 100644 --- a/docs/artifact-adapter-registry.md +++ b/docs/artifact-adapter-registry.md @@ -15,6 +15,11 @@ Today, Base has two artifact behaviors backed by the bundled registry: Base-managed project virtual environment. - `tool` artifacts resolve to Homebrew packages installed on the host system. +On Ubuntu/Debian, the provider layer may adapt a small supported subset of +portable `tool` artifacts to system packages. The initial mapping is +`tool:bats-core` to system package `bats`; macOS keeps using the Homebrew +`bats-core` package for the same manifest artifact. + That behavior is useful but too implicit. The supported artifact list, manager selection, package name, install target, and diagnostics are encoded in Python. Adding another artifact family would require changing command code before Base diff --git a/docs/doctor-findings.md b/docs/doctor-findings.md index cd8a38c4..8327ab6d 100644 --- a/docs/doctor-findings.md +++ b/docs/doctor-findings.md @@ -119,6 +119,7 @@ Doctor commands use the same diagnostic item fields. The top-level | `BASE-P031` | Unsupported Homebrew artifact version | | `BASE-P032` | Homebrew unavailable for artifact checks | | `BASE-P033` | Homebrew artifact package presence and freshness | +| `BASE-P034` | Platform system-package artifact presence | | `BASE-P040` | Python package artifact status in the project virtual environment | | `BASE-P050` | Project virtual environment readiness | | `BASE-P060` | Project demo declaration | diff --git a/docs/linux-support.md b/docs/linux-support.md index c891b95d..ed7507a4 100644 --- a/docs/linux-support.md +++ b/docs/linux-support.md @@ -160,6 +160,11 @@ delegating project tool setup to `mise install`. Base does not automatically trust project-owned mise configs; if mise reports an untrusted config, review it and run `mise trust ` before retrying setup. +Project tool artifacts are still intentionally conservative on Linux. The first +platform-aware mapping is `tool:bats-core`: the manifest keeps the portable +artifact name, macOS continues to use Homebrew package `bats-core`, and +Ubuntu/Debian treats the artifact as satisfied by system package `bats`. + GitHub CLI authentication remains a user-owned step. After `gh` is installed, run the browser-backed flow when you need GitHub access: