diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b6472e..dbb66cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,6 +66,7 @@ jobs: # A fake uv executable makes the launcher contract deterministic: # arguments, cwd, and environment are checked without network I/O. bazelisk test //tools:python_tool_runner_test --test_output=errors + bazelisk test //tools:run_tool_test --test_output=errors # The real target covers the integration boundary between # rules_multitool, Bazel runfiles, uvx, and the Python package. diff --git a/README.md b/README.md index e09fabd..d7374c1 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ repository's default build. For pinned command-line tools that are also available outside the container, use the repository's `.devcontainer/run-tool` wrapper. It selects the -container installation here and the matching Bazel target on the host. See -[Pinned command-line tools](tools/README.md) for the supported commands and -setup. +installed command on `PATH` when available and the matching Bazel target +otherwise. See [Pinned command-line tools](tools/README.md) for the supported +commands and setup. After you have build the code, create [compilation databases](https://clang.llvm.org/docs/JSONCompilationDatabase.html) via Visual Studio Code [Task](https://code.visualstudio.com/docs/debugtest/tasks): diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel index dd5b49a..16f32b2 100644 --- a/tools/BUILD.bazel +++ b/tools/BUILD.bazel @@ -62,3 +62,12 @@ sh_test( "lockfiles/python_tools.bzl", ], ) + +sh_test( + name = "run_tool_test", + size = "small", + srcs = ["tests/run_tool_test.sh"], + data = [ + "run-tool", + ], +) diff --git a/tools/README.md b/tools/README.md index 0642aff..a32899f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -28,9 +28,9 @@ $ .devcontainer/run-tool ruff check . ``` Everything after the command is passed to that command. In the DevContainer, -the runner executes its installed executable. Outside the container, it runs -the matching Bazel target. The first host-side invocation may require network -access while Bazel downloads and caches the executable. +it runs the installed executable. Outside the container, it uses the local tool +when available or Bazel otherwise (`--strict` forces Bazel). The first +host-side Bazel invocation may require network access to download the binary. ## Available tools diff --git a/tools/internal/README.md b/tools/internal/README.md index 5ced83a..99227d5 100644 --- a/tools/internal/README.md +++ b/tools/internal/README.md @@ -32,7 +32,7 @@ One catalog is delivered through two execution paths: | `MODULE.bazel` | Makes native tool lockfiles available to `rules_multitool`. | | `tools/BUILD.bazel` | Exposes public Bazel targets for each command. | | Feature installers | Install the commands exposed on the DevContainer's `PATH`. | -| `tools/run-tool` | Uses the command on `PATH` in a container when available; otherwise uses the public Bazel alias. | +| `tools/run-tool` | Uses the command on `PATH` in a container or locally when available; otherwise uses the public Bazel alias (`--strict` forces Bazel). | | `tools/README.md` | Documents every command and its version from the catalog. | A command is available when its catalog metadata, Bazel target, and applicable @@ -42,8 +42,8 @@ those delivery paths; the catalogs remain the command registries. ## Architecture The public interface is `.devcontainer/run-tool` in each consumer -repository. Inside a container, it uses the command installed on `PATH` when -available. Otherwise, it invokes the matching public Bazel target. Developers +repository. Inside a container or on the host, it uses the command on `PATH` +when available, or falls back to Bazel (`--strict` forces Bazel). Developers use the same command line without choosing the execution path. ![Command-line tool delivery](tool-delivery.svg) diff --git a/tools/run-tool b/tools/run-tool index bc6d800..e587fef 100755 --- a/tools/run-tool +++ b/tools/run-tool @@ -14,30 +14,64 @@ # ******************************************************************************* # Consumer repositories install this runner as .devcontainer/run-tool. -# It runs a pinned CLI tool from PATH in a container or through Bazel on the host. +# It runs a pinned CLI tool from PATH in a container, from local PATH on the host +# when available, or through Bazel otherwise. +# Use --strict to bypass local tools on the host and force execution through Bazel. # See https://github.com/eclipse-score/devcontainer/tree/main/tools. set -euo pipefail +strict=0 +if [[ "${1:-}" == "--strict" ]]; then + strict=1 + shift +fi + if [[ "$#" -lt 1 ]]; then - echo "Usage: $0 [args...]" >&2 + echo "Usage: $0 [--strict] [args...]" >&2 exit 2 fi tool_name="$1" shift -# A host PATH may contain an arbitrary, unpinned version, so PATH execution is -# deliberately limited to containers built from this catalog. If a container -# lacks a command, falling through to Bazel still gives it the pinned version. -if { [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]]; } && - command -v "${tool_name}" >/dev/null 2>&1; then - exec "${tool_name}" "$@" +# Tests can force either side of the container boundary without changing the +# runtime's automatic detection behavior. +in_container() { + case "${RUN_TOOL_CONTAINER_MODE:-auto}" in + host) return 1 ;; + container) return 0 ;; + auto) + [[ -f /.dockerenv ]] || [[ -f /run/.containerenv ]] || [[ -d /devcontainer ]] + ;; + *) + echo "Invalid RUN_TOOL_CONTAINER_MODE: ${RUN_TOOL_CONTAINER_MODE}" >&2 + return 2 + ;; + esac +} + +# A container built from this catalog only ever has the pinned version on +# PATH, so container execution is direct and untouched. +# shellcheck disable=SC2310 +if in_container && command -v "${tool_name}" >/dev/null 2>&1; then + exec "${tool_name}" "$@" +# On the host, prefer the local tool on PATH for faster execution unless --strict +# is set. If local execution fails, suggest --strict to run the pinned Bazel target. +elif [[ "${strict}" -eq 0 ]] && command -v "${tool_name}" >/dev/null 2>&1; then + if "${tool_name}" "$@"; then + exit 0 + else + exit_code=$? + echo "Execution of local '${tool_name}' failed with exit code ${exit_code}." >&2 + echo "Use '--strict' to bypass the local tool and run the pinned version via Bazel." >&2 + exit "${exit_code}" + fi elif command -v bazel >/dev/null 2>&1; then - # Consumer repositories expose this module as @score_devcontainer; `--` - # prevents tool flags from being interpreted as Bazel flags. - exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" + # Consumer repositories expose this module as @score_devcontainer; `--` + # prevents tool flags from being interpreted as Bazel flags. + exec bazel run "@score_devcontainer//tools:${tool_name}" -- "$@" else - echo "Could not run '${tool_name}': no container command or Bazel executable is available." >&2 - exit 127 + echo "Could not run '${tool_name}': no container command or Bazel executable is available." >&2 + exit 127 fi diff --git a/tools/tests/run_tool_test.sh b/tools/tests/run_tool_test.sh new file mode 100755 index 0000000..7682dbd --- /dev/null +++ b/tools/tests/run_tool_test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +set -euo pipefail + +runfiles_root="${TEST_SRCDIR}/${TEST_WORKSPACE}" +runner="${runfiles_root}/tools/run-tool" +fake_bin="${TEST_TMPDIR}/bin" +tool_output="${TEST_TMPDIR}/tool.args" +bazel_output="${TEST_TMPDIR}/bazel.args" +mkdir -p "${fake_bin}" + +cat > "${fake_bin}/shellcheck" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${FAIL_LOCAL_TOOL:-}" ]]; then + echo "Simulated local tool error" >&2 + exit "${FAIL_LOCAL_TOOL}" +fi +printf '%s\n' "$@" > "${TOOL_OUTPUT}" +EOF + +cat > "${fake_bin}/bazel" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "${BAZEL_OUTPUT}" +EOF + +chmod +x "${fake_bin}/shellcheck" "${fake_bin}/bazel" + +assert_lines() { + local actual_file="$1" + shift + local expected_file="${TEST_TMPDIR}/expected-lines" + printf '%s\n' "$@" > "${expected_file}" + diff -u "${expected_file}" "${actual_file}" +} + +export PATH="${fake_bin}:${PATH}" +export TOOL_OUTPUT="${tool_output}" +export BAZEL_OUTPUT="${bazel_output}" + +reset_outputs() { + rm -f "${tool_output}" "${bazel_output}" +} + +# 1. Inside container: executes the container command on PATH directly (untouched). +reset_outputs +export RUN_TOOL_CONTAINER_MODE=container +"${runner}" shellcheck scripts/example.sh +assert_lines "${tool_output}" scripts/example.sh +[[ ! -e "${bazel_output}" ]] + +# In container, --strict does not bypass container commands. +reset_outputs +"${runner}" --strict shellcheck scripts/example.sh +assert_lines "${tool_output}" scripts/example.sh +[[ ! -e "${bazel_output}" ]] + +# 2. On host: local tool succeeds. +reset_outputs +export RUN_TOOL_CONTAINER_MODE=host +unset FAIL_LOCAL_TOOL || true +"${runner}" shellcheck scripts/example.sh --fix +assert_lines "${tool_output}" scripts/example.sh --fix +[[ ! -e "${bazel_output}" ]] + +# 3. On host: local tool fails: preserves exit code and prints message to use --strict. +reset_outputs +export FAIL_LOCAL_TOOL=42 +set +e +stderr_output=$("${runner}" shellcheck scripts/example.sh 2>&1 >/dev/null) +status=$? +set -e +[[ "${status}" -eq 42 ]] +echo "${stderr_output}" | grep -q -- "--strict" +echo "${stderr_output}" | grep -q "failed with exit code 42" +[[ ! -e "${bazel_output}" ]] +unset FAIL_LOCAL_TOOL + +# 4. On host: --strict flag bypasses local tool and invokes Bazel. +reset_outputs +"${runner}" --strict shellcheck scripts/example.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:shellcheck" \ + "--" \ + "scripts/example.sh" +[[ ! -e "${tool_output}" ]] + +# 5. On host or container missing tool: falls back to Bazel. +reset_outputs +"${runner}" missing-tool check.sh +assert_lines "${bazel_output}" \ + "run" \ + "@score_devcontainer//tools:missing-tool" \ + "--" \ + "check.sh" +[[ ! -e "${tool_output}" ]] + +# 6. Neither tool nor Bazel is on PATH: exits with 127. +no_bin="${TEST_TMPDIR}/empty-bin" +mkdir -p "${no_bin}" +set +e +no_bazel_output=$(PATH="${no_bin}" /bin/bash "${runner}" missing-tool check.sh 2>&1) +no_bazel_status=$? +set -e +[[ "${no_bazel_status}" -eq 127 ]] +echo "${no_bazel_output}" | grep -q "Could not run 'missing-tool': no container command or Bazel executable is available."