Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
9 changes: 9 additions & 0 deletions tools/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
6 changes: 3 additions & 3 deletions tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions tools/internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
60 changes: 47 additions & 13 deletions tools/run-tool
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tool> [args...]" >&2
echo "Usage: $0 [--strict] <tool> [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
121 changes: 121 additions & 0 deletions tools/tests/run_tool_test.sh
Original file line number Diff line number Diff line change
@@ -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."