Skip to content

Commit 60fee81

Browse files
authored
bazel: validate foreign_cc outputs before the action exits (#1517)
When a foreign build's outputs are set up wrong, Bazel fails with an opaque "output ... was not created" error. That skips the existing foreign_cc failure path, so users lose both the build log and the usual --sandbox_debug workflow. Teach the generated build script to validate every expected installed output before the action completes. Missing outputs now fail the original action, print the build log, and keep the temp tree around for postmortem debug. The failure output looks something like: ``` _____ BEGIN OUTPUT VALIDATION _____ rules_foreign_cc: install root: /[...]/execroot/_main/bazel-out/k8-fastbuild/bin/test/output_validation/repro rules_foreign_cc: validating expected installed outputs rules_foreign_cc: missing expected installed output: lib/librepro.a rules_foreign_cc: other files in the same directory (max 5): rules_foreign_cc: librepro-renamed.a rules_foreign_cc: other files in the install root with the same name (max 5): rules_foreign_cc: wrong/librepro.a rules_foreign_cc: missing expected installed output: lib/librepro_same_dir.a rules_foreign_cc: other files in the same directory (max 5): rules_foreign_cc: librepro-renamed.a rules_foreign_cc: expected output validation failed _____ END OUTPUT VALIDATION _____ ``` Because it's awful to sit through an entire build just to have it fail, then realize you didn't set --sandbox_debug and have to do the entire build again to learn anything, it also performs two checks: - what other files are in the same dir? (max 5) - what other files are in the install tree with the same name? (max 5) This makes it more likely you learn something from the failed build. If this malfunctions for a specific target, you can disable it by setting the attribute: `experimental_validate_outputs_in_action=False`. It defaults to True.
1 parent a398941 commit 60fee81

10 files changed

Lines changed: 233 additions & 1 deletion

File tree

examples/cmake_defines/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@rules_foreign_cc//foreign_cc:defs.bzl", "cmake")
33
cmake(
44
name = "lib_a",
55
lib_source = ":lib_a_sources",
6+
out_include_dir = "",
67
out_static_libs = select({
78
"@platforms//os:windows": ["lib_a.lib"],
89
"//conditions:default": ["liblib_a.a"],
@@ -14,6 +15,7 @@ cmake(
1415
name = "lib_b",
1516
defines = ["FOO"],
1617
lib_source = ":lib_b_sources",
18+
out_include_dir = "",
1719
out_static_libs = select({
1820
"@platforms//os:windows": ["lib_b.lib"],
1921
"//conditions:default": ["liblib_b.a"],

examples/cmake_defines/nocrosstool/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ cmake(
44
name = "lib_a",
55
generate_crosstool_file = False,
66
lib_source = "//cmake_defines:lib_a_sources",
7+
out_include_dir = "",
78
out_static_libs = select({
89
"@platforms//os:windows": ["lib_a.lib"],
910
"//conditions:default": ["liblib_a.a"],
@@ -16,6 +17,7 @@ cmake(
1617
defines = ["FOO"],
1718
generate_crosstool_file = False,
1819
lib_source = "//cmake_defines:lib_b_sources",
20+
out_include_dir = "",
1921
out_static_libs = select({
2022
"@platforms//os:windows": ["lib_b.lib"],
2123
"//conditions:default": ["liblib_b.a"],

foreign_cc/private/framework.bzl

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ CC_EXTERNAL_RULE_ATTRIBUTES = {
122122
"Variables containing `PATH` (e.g. `PATH`, `LD_LIBRARY_PATH`, `CPATH`) entries will be prepended to the existing variable."
123123
),
124124
),
125+
"experimental_validate_outputs_in_action": attr.bool(
126+
doc = "Validate expected installed outputs inside the main foreign build action before it exits.",
127+
mandatory = False,
128+
default = True,
129+
),
125130
"includes": attr.string_list(
126131
doc = (
127132
"Optional list of include dirs to be passed to the dependencies of this library. " +
@@ -476,6 +481,13 @@ def cc_external_rule_impl(ctx, attrs):
476481
else:
477482
postfix_script = [expand_locations_and_make_variables(ctx, attrs.postfix_script, "postfix_script", data_dependencies)]
478483

484+
validation_script = []
485+
if attrs.experimental_validate_outputs_in_action:
486+
validation_script = _validate_expected_outputs_script(
487+
ctx,
488+
outputs.expected_output_paths,
489+
)
490+
479491
script_lines = [
480492
"##echo## \"\"",
481493
"##echo## \"{}\"".format(lib_header),
@@ -490,7 +502,7 @@ def cc_external_rule_impl(ctx, attrs):
490502
"##mkdirs## $$EXT_BUILD_DEPS$$",
491503
] + _print_env() + _copy_deps_and_tools(inputs) + [
492504
"cd $$BUILD_TMPDIR$$",
493-
] + attrs.create_configure_script(ConfigureParameters(ctx = ctx, attrs = attrs, inputs = inputs)) + postfix_script + [
505+
] + attrs.create_configure_script(ConfigureParameters(ctx = ctx, attrs = attrs, inputs = inputs)) + postfix_script + validation_script + [
494506
# replace references to the root directory when building ($BUILD_TMPDIR)
495507
# and the root where the dependencies were installed ($EXT_BUILD_DEPS)
496508
# for the results which are in $INSTALLDIR (with placeholder)
@@ -852,6 +864,7 @@ _Outputs = provider(
852864
data_dirs = "Directory containing additional files generated by the build",
853865
data_files = "Data files, which will be created by the action",
854866
declared_outputs = "All output files and directories of the action",
867+
expected_output_paths = "Install-root-relative paths expected to exist after the foreign build finishes",
855868
),
856869
)
857870

@@ -884,19 +897,27 @@ def _define_outputs(ctx, attrs, lib_name):
884897
else:
885898
out_include_dir = ""
886899

900+
expected_output_paths = [attrs.out_include_dir] if out_include_dir else []
901+
887902
out_data_dirs = []
888903
for dir in attr_out_data_dirs:
889904
out_data_dirs.append(ctx.actions.declare_directory(lib_name + "/" + dir.lstrip("/")))
905+
expected_output_paths.append(dir.lstrip("/"))
890906

891907
out_data_files = _declare_out(ctx, lib_name, "/", attr_out_data_files)
908+
expected_output_paths += [_expected_output_path("/", file) for file in attr_out_data_files]
892909

893910
out_binary_files = _declare_out(ctx, lib_name, attrs.out_bin_dir, attr_binaries_libs)
911+
expected_output_paths += [_expected_output_path(attrs.out_bin_dir, file) for file in attr_binaries_libs]
894912

895913
libraries = LibrariesToLinkInfo(
896914
static_libraries = _declare_out(ctx, lib_name, attrs.out_lib_dir, static_libraries),
897915
shared_libraries = _declare_out(ctx, lib_name, attrs.out_dll_dir if targets_windows(ctx, None) else attrs.out_lib_dir, attr_shared_libs),
898916
interface_libraries = _declare_out(ctx, lib_name, attrs.out_lib_dir, attr_interface_libs),
899917
)
918+
expected_output_paths += [_expected_output_path(attrs.out_lib_dir, file) for file in static_libraries]
919+
expected_output_paths += [_expected_output_path(attrs.out_dll_dir if targets_windows(ctx, None) else attrs.out_lib_dir, file) for file in attr_shared_libs]
920+
expected_output_paths += [_expected_output_path(attrs.out_lib_dir, file) for file in attr_interface_libs]
900921

901922
declared_outputs = [out_include_dir] if out_include_dir else []
902923
declared_outputs += out_data_dirs + out_binary_files + out_data_files
@@ -910,13 +931,91 @@ def _define_outputs(ctx, attrs, lib_name):
910931
data_dirs = out_data_dirs,
911932
data_files = out_data_files,
912933
declared_outputs = declared_outputs,
934+
expected_output_paths = expected_output_paths,
913935
)
914936

915937
def _declare_out(ctx, lib_name, dir_, files):
916938
if files and len(files) > 0:
917939
return [ctx.actions.declare_file("/".join([lib_name, dir_, file])) for file in files]
918940
return []
919941

942+
def _validate_expected_outputs_script(ctx, expected_output_paths):
943+
if not expected_output_paths:
944+
return []
945+
946+
validate_output_function = create_function(
947+
ctx,
948+
"validate_expected_output",
949+
"\n".join([
950+
"local expected_output_path=\"$1\"",
951+
"local expected_basename=\"$2\"",
952+
"local rel_expected_output_path=\"${expected_output_path#\"$INSTALLDIR\"/}\"",
953+
"local expected_dir",
954+
"local find_bin=\"${REAL_FIND:-find}\"",
955+
"expected_dir=$(dirname \"$expected_output_path\")",
956+
"if [[ -e \"$expected_output_path\" ]]; then",
957+
" return 0",
958+
"fi",
959+
"echo \"rules_foreign_cc: missing expected installed output: $rel_expected_output_path\" >&2",
960+
"if [[ -d \"$expected_dir\" ]]; then",
961+
" local rel_expected_dir=\"${expected_dir#\"$INSTALLDIR\"/}\"",
962+
" local sibling_candidates",
963+
" sibling_candidates=$($find_bin \"$expected_dir\" -maxdepth 1 -mindepth 1 -print 2>/dev/null | head -n 5 || true)",
964+
" if [[ -n \"$sibling_candidates\" ]]; then",
965+
" echo \"rules_foreign_cc: other files in the same directory (max 5):\" >&2",
966+
" while IFS= read -r sibling_candidate; do",
967+
" [[ -n \"$sibling_candidate\" ]] || continue",
968+
" echo \"rules_foreign_cc: ${sibling_candidate##*/}\" >&2",
969+
" done <<< \"$sibling_candidates\"",
970+
" fi",
971+
"fi",
972+
"local nearby_candidates",
973+
"nearby_candidates=$($find_bin \"$INSTALLDIR\" -mindepth 1 -name \"$expected_basename\" -print 2>/dev/null | head -n 5 || true)",
974+
"if [[ -n \"$nearby_candidates\" ]]; then",
975+
" echo \"rules_foreign_cc: other files in the install root with the same name (max 5):\" >&2",
976+
" while IFS= read -r nearby_candidate; do",
977+
" [[ -n \"$nearby_candidate\" ]] || continue",
978+
" echo \"rules_foreign_cc: ${nearby_candidate#\"$INSTALLDIR\"/}\" >&2",
979+
" done <<< \"$nearby_candidates\"",
980+
"fi",
981+
"return 1",
982+
]),
983+
)
984+
985+
lines = [
986+
validate_output_function,
987+
"echo \"_____ BEGIN OUTPUT VALIDATION _____\"",
988+
"missing_foreign_cc_outputs=0",
989+
"echo \"rules_foreign_cc: install root: $INSTALLDIR\"",
990+
"echo \"rules_foreign_cc: validating expected installed outputs\"",
991+
]
992+
993+
for rel in expected_output_paths:
994+
basename = rel.rsplit("/", 1)[-1]
995+
lines.extend([
996+
"if ! validate_expected_output \"$$INSTALLDIR$$/{rel}\" \"{basename}\"; then".format(
997+
rel = rel,
998+
basename = basename,
999+
),
1000+
" missing_foreign_cc_outputs=1",
1001+
"fi",
1002+
])
1003+
1004+
lines.extend([
1005+
"if [[ \"$missing_foreign_cc_outputs\" -ne 0 ]]; then",
1006+
" echo \"rules_foreign_cc: expected output validation failed\" >&2",
1007+
" echo \"_____ END OUTPUT VALIDATION _____\" >&2",
1008+
" exit 1",
1009+
"fi",
1010+
"echo \"_____ END OUTPUT VALIDATION _____\"",
1011+
])
1012+
return lines
1013+
1014+
def _expected_output_path(dir_, file):
1015+
if dir_ == "/" or not dir_:
1016+
return file.lstrip("/")
1017+
return dir_.rstrip("/") + "/" + file.lstrip("/")
1018+
9201019
# buildifier: disable=name-conventions
9211020
InputFiles = provider(
9221021
doc = (

test/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
load("@bazel_lib//lib:diff_test.bzl", "diff_test")
22
load("@rules_shell//shell:sh_library.bzl", "sh_library")
3+
load("@rules_shell//shell:sh_test.bzl", "sh_test")
34
load("//tools/lint:linters.bzl", "shellcheck_test")
45
load(":cmake_text_tests.bzl", "cmake_script_test_suite")
56
load(":convert_shell_script_test.bzl", "shell_script_conversion_suite")
@@ -103,3 +104,11 @@ diff_test(
103104
file1 = "expected/out_symlinked_dirs.txt",
104105
file2 = ":symlink_dirs",
105106
)
107+
108+
sh_test(
109+
name = "output_validation_script_test",
110+
size = "small",
111+
srcs = ["assert_output_validation_script.sh"],
112+
args = ["$(locations //test/output_validation:wrong_output_in_action_check_script)"],
113+
data = ["//test/output_validation:wrong_output_in_action_check_script"],
114+
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
script=""
5+
for candidate in "$@"; do
6+
if [[ "$candidate" == *"/build_script.sh" ]]; then
7+
script="$candidate"
8+
break
9+
fi
10+
done
11+
12+
if [[ -z "$script" ]]; then
13+
echo "expected one generated build_script.sh path in args" >&2
14+
exit 1
15+
fi
16+
17+
grep -F 'function validate_expected_output() {' "$script"
18+
grep -F 'rules_foreign_cc: validating expected installed outputs' "$script"
19+
grep -F 'rules_foreign_cc: other files in the same directory (max 5):' "$script"
20+
grep -F 'rules_foreign_cc: other files in the install root with the same name (max 5):' "$script"
21+
grep -F "find_bin=\"\${REAL_FIND:-find}\"" "$script"

test/output_group_file_rule.bzl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Expose files from a target output group through DefaultInfo for tests."""
2+
3+
def _output_group_file_impl(ctx):
4+
files = getattr(ctx.attr.target[OutputGroupInfo], ctx.attr.output_group).to_list()
5+
if not files:
6+
fail("Expected at least one file in output group {} for {}".format(
7+
ctx.attr.output_group,
8+
ctx.attr.target.label,
9+
))
10+
return [DefaultInfo(files = depset(files))]
11+
12+
output_group_file = rule(
13+
implementation = _output_group_file_impl,
14+
attrs = {
15+
"output_group": attr.string(mandatory = True),
16+
"target": attr.label(mandatory = True),
17+
},
18+
)

test/output_validation/BUILD.bazel

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
load("@rules_foreign_cc//foreign_cc:defs.bzl", "cmake")
2+
load("//test:output_group_file_rule.bzl", "output_group_file")
3+
4+
package(default_visibility = ["//visibility:public"])
5+
6+
filegroup(
7+
name = "srcs",
8+
srcs = [
9+
"CMakeLists.txt",
10+
"repro.c",
11+
"repro.h",
12+
],
13+
)
14+
15+
cmake(
16+
name = "output_validation_script_fixture",
17+
generate_args = ["-GNinja"],
18+
lib_name = "repro_fixture",
19+
lib_source = ":srcs",
20+
out_static_libs = select({
21+
"@platforms//os:windows": ["repro.lib"],
22+
"//conditions:default": ["librepro.a"],
23+
}),
24+
tags = ["manual"],
25+
)
26+
27+
output_group_file(
28+
name = "wrong_output_in_action_check_script",
29+
output_group = "CMake_logs",
30+
target = ":output_validation_script_fixture",
31+
)
32+
33+
# TODO: We don't have a good way of validating that actions fail with the
34+
# expected error message, so this target can be bazel built to perform a manual
35+
# test.
36+
cmake(
37+
name = "wrong_output_in_action_check",
38+
generate_args = ["-GNinja"],
39+
lib_name = "repro",
40+
lib_source = ":srcs",
41+
out_static_libs = select({
42+
"@platforms//os:windows": [
43+
"repro.lib",
44+
"repro_same_dir.lib",
45+
],
46+
"//conditions:default": [
47+
"librepro.a",
48+
"librepro_same_dir.a",
49+
],
50+
}),
51+
postfix_script = select({
52+
"@platforms//os:windows": "\n".join([
53+
"mkdir -p \"$$INSTALLDIR/wrong\"",
54+
"mv \"$$INSTALLDIR/lib/repro.lib\" \"$$INSTALLDIR/wrong/repro.lib\"",
55+
"cp \"$$INSTALLDIR/wrong/repro.lib\" \"$$INSTALLDIR/lib/repro-renamed.lib\"",
56+
]),
57+
"//conditions:default": "\n".join([
58+
"mkdir -p \"$$INSTALLDIR/wrong\"",
59+
"mv \"$$INSTALLDIR/lib/librepro.a\" \"$$INSTALLDIR/wrong/librepro.a\"",
60+
"cp \"$$INSTALLDIR/wrong/librepro.a\" \"$$INSTALLDIR/lib/librepro-renamed.a\"",
61+
]),
62+
}),
63+
tags = ["manual"],
64+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
cmake_minimum_required(VERSION 3.10)
2+
project(output_validation_repro C)
3+
4+
add_library(repro STATIC repro.c)
5+
target_include_directories(repro PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
6+
7+
install(TARGETS repro ARCHIVE DESTINATION lib)
8+
install(FILES repro.h DESTINATION include)

test/output_validation/repro.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#include "repro.h"
2+
3+
int repro_answer(void) { return 42; }

test/output_validation/repro.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#ifndef TEST_OUTPUT_VALIDATION_REPRO_H_
2+
#define TEST_OUTPUT_VALIDATION_REPRO_H_
3+
4+
int repro_answer(void);
5+
6+
#endif

0 commit comments

Comments
 (0)