blob: a0987d483db380afa19e20c4d92b755f799ae686 [file] [log] [blame]
// Copyright 2025 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.devtools.build.lib.starlark;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.actions.Artifact.SpecialArtifact;
import com.google.devtools.build.lib.actions.BuildFailedException;
import com.google.devtools.build.lib.analysis.ViewCreationFailedException;
import com.google.devtools.build.lib.buildtool.util.BuildIntegrationTestCase;
import com.google.devtools.build.lib.testutil.TestConstants;
import com.google.devtools.build.lib.util.io.RecordingOutErr;
import com.google.devtools.build.lib.vfs.Path;
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
import com.google.testing.junit.testparameterinjector.TestParameters;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(TestParameterInjector.class)
public final class StarlarkActionTemplateTest extends BuildIntegrationTestCase {
@Before
public void setUp() throws Exception {
addOptions("--experimental_starlark_action_templates_api");
write(
"test/BUILD",
"""
load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
load(":rules.bzl", "my_rule")
my_rule(name = "target")
cc_binary(
name = "foo",
srcs = ["foo.cc"],
)
filegroup(
name = "data",
srcs = ["data.txt"],
)
""");
write("test/foo.cc", "int main() { return 0; }");
write("test/data.txt", "data");
}
@Test
public void testBuildsSuccessfully() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(actions, input_directory_listing, output_directory):
for file in input_directory_listing:
output_file = actions.declare_file(
file.tree_relative_path + ".out", directory_or_sibling = output_directory
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file],
outputs = [output_file],
arguments = [file.path, output_file.path],
)
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(
ctx.attr.name + "_initial_directory"
)
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..3}; do echo $i > %s/file_$i; done" % initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
buildTarget("//test:target");
ImmutableList<Artifact> artifacts = getArtifacts("//test:target");
// Contains the output directory.
assertThat(artifacts).hasSize(1);
Artifact output = artifacts.get(0);
assertThat(output.getRootRelativePathString()).isEqualTo("test/target_output_dir");
assertThat(output).isInstanceOf(SpecialArtifact.class);
SpecialArtifact outputTree = (SpecialArtifact) output;
Path execRoot = directories.getExecRoot(TestConstants.WORKSPACE_NAME);
// Files should be created.
Path file1 = execRoot.getRelative(outputTree.getExecPath().getChild("file_1.out"));
assertThat(file1.exists()).isTrue();
assertContents("1", file1);
Path file2 = execRoot.getRelative(outputTree.getExecPath().getChild("file_2.out"));
assertThat(file2.exists()).isTrue();
assertContents("2", file2);
Path file3 = execRoot.getRelative(outputTree.getExecPath().getChild("file_3.out"));
assertThat(file3.exists()).isTrue();
assertContents("3", file3);
}
@Test
public void testNonDirectoryFilesCannotBeDeclaredInTransformDirectory() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(actions, input_directory_listing, output_directory):
for file in input_directory_listing:
output_file = actions.declare_file(
file.tree_relative_path + ".out", directory_or_sibling = output_directory
)
# This should fail - cannot declare a file that is not in a directory.
non_directory_file_output = actions.declare_file("non_directory_file.out")
actions.run_shell(
command = "cp $1 $2",
inputs = [file],
outputs = [output_file, non_directory_file_output],
arguments = [file.path, output_file.path],
)
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done"
% initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(BuildFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains(
"Cannot declare a file outside of a directory within a ctx.actions.transform_directory"
+ " `implementation` function.");
}
@Test
public void testDirectoryFilesCannotBeDeclaredOutsideTransformDirectory() throws Exception {
write(
"test/rules.bzl",
"""
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done"
% initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
# This should fail - cannot declare a directory file outside of `transform_directory()`.
directory_file = ctx.actions.declare_file(
"directory_file.out", directory_or_sibling = output_directory
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(ViewCreationFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains(
"Cannot declare a directory file outside of a ctx.actions.transform_directory"
+ " `implementation` function.");
}
@Test
public void testDirectoryFilesCannotBeDeclaredInNonOutputDirectories() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(
actions,
input_directory_listing,
output_directory,
additional_input_dir):
for file in input_directory_listing:
output_file = actions.declare_file(
file.tree_relative_path + ".out", directory_or_sibling = output_directory
)
# This should fail - `additional_input_dir` is not an output directory.
additional_input_dir_file = actions.declare_file(
"directory_file.out", directory_or_sibling = additional_input_dir
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file],
outputs = [output_file],
arguments = [file.path, output_file.path],
)
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done"
% initial_directory.path,
)
additional_input_dir = ctx.actions.declare_directory(ctx.attr.name + "_input_dir")
ctx.actions.run_shell(
mnemonic = "SeedData2",
outputs = [additional_input_dir],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done"
% additional_input_dir.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
additional_input_dir = additional_input_dir,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(BuildFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains("Cannot declare a directory file in a non-output directory.");
}
@Test
public void testNonDirectoriesNotAllowedAsAdditionalOutputs() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(
actions,
input_directory_listing,
output_directory,
non_directory_file_output):
pass
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done"
% initial_directory.path,
)
non_directory_file_output = ctx.actions.declare_file("non_directory_file.out")
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
# This should fail - passing in a non-directory files as additional_outputs is not
# allowed.
additional_outputs = [non_directory_file_output],
non_directory_file_output = non_directory_file_output,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(ViewCreationFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains("Expected directory artifact for additional_outputs[0], but got a File");
}
@Test
public void testActionConflictCheckingDuplicateFiles() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(actions, input_directory_listing, output_directory):
for file in input_directory_listing:
# This should fail - the same file is declared twice.
output_file = actions.declare_file(
"duplicate_file.txt", directory_or_sibling = output_directory
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file],
outputs = [output_file],
arguments = [file.path, output_file.path],
)
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..2}; do echo $i > %s/file_$i.txt; done"
% initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(BuildFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains(
"file 'test/target_output_dir/duplicate_file.txt' is generated by these conflicting"
+ " actions");
}
@Test
public void testActionConflictCheckingPrefixConflicts() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(actions, input_directory_listing, output_directory):
file1, file2 = input_directory_listing
# This should fail - two files where one is a prefix of the other are declared.
output_file1 = actions.declare_file(
"prefix", directory_or_sibling = output_directory
)
output_file2 = actions.declare_file(
"prefix/file.txt", directory_or_sibling = output_directory
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file1],
outputs = [output_file1],
arguments = [file1.path, output_file1.path],
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file2],
outputs = [output_file2],
arguments = [file2.path, output_file2.path],
)
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..2}; do echo $i > %s/file_$i.txt; done" % initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(BuildFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.containsMatch(
"One of the output paths '.*/test/target_output_dir/prefix' \\(belonging to"
+ " //test:target\\) and '.*/test/target_output_dir/prefix/file.txt' \\(belonging"
+ " to //test:target\\) is a prefix of the other.");
}
@Test
public void testActionConflictCheckingAcrossTransformDirectoryImplementationBoundary()
throws Exception {
// An output of an action declared outside of transform_directory can be passed in as an input
// to transform_directory, but that should not be then passed as an output of an action declared
// within transform_directory - which is an action conflict.
write(
"test/rules.bzl",
"""
def _transform_dir_impl(
actions,
input_directory_listing,
output_directory,
some_file):
for file in input_directory_listing:
output_file = actions.declare_file(
file.tree_relative_path + ".out",
directory_or_sibling = output_directory,
)
actions.run_shell(
command = "cp $1 $2",
inputs = [file],
# This should fail - `some_file` should not be passed as an output.
outputs = [output_file, some_file],
arguments = [file.path, output_file.path],
)
def _my_rule_impl(ctx):
some_file = ctx.actions.declare_file(ctx.attr.name + "_some_file.txt")
ctx.actions.write(
mnemonic = "SomeData",
content = "some data",
output = some_file,
)
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..5}; do echo $i > %s/file_$i.txt; done" % initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
some_file = some_file,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(BuildFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.containsMatch(
"`ctx.actions.transform_directory` function generated an action with an output"
+ " .*test/target_some_file.txt"
+ " that belongs to an external action.");
}
@Test
public void testDisallowedKwargs() throws Exception {
write(
"test/rules.bzl",
"""
def _transform_dir_impl(
actions,
input_directory_listing,
output_directory,
some_data,
some_files_to_run):
pass
def _my_rule_impl(ctx):
initial_directory = ctx.actions.declare_directory(ctx.attr.name + "_initial_directory")
ctx.actions.run_shell(
mnemonic = "SeedData",
outputs = [initial_directory],
command = "for i in {1..2}; do echo $i > %s/file_$i.txt; done" % initial_directory.path,
)
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = initial_directory,
output_directory = output_directory,
some_files_to_run = ctx.attr._foo_binary.files_to_run,
some_data = ctx.files.data[0],
# This should fail - only File and FilesToRunProvider are allowed.
unallowed_arg = (1, 2, 3),
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(
implementation = _my_rule_impl,
attrs = {
"_foo_binary": attr.label(
executable = True,
cfg = "exec",
default = Label("//test:foo"),
),
"data": attr.label(
default = Label("//test:data"),
),
}
)
""");
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(ViewCreationFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains(
"Only File(s) and FilesToRunProvider(s) are allowed as kwargs in"
+ "ctx.actions.transform_directory(), but got tuple instead");
}
@Test
// SpecialArtifact: CONSTANT_METADATA(s) are not allowed.
@TestParameters(
"{input: 'ctx.version_file', output: 'output_directory', additionalOutputs: '[]',"
+ " whatErrored: 'input_directory', unexpectedType: 'File[CONSTANT_METADATA]'}")
@TestParameters(
"{input: 'input_directory', output: 'ctx.version_file', additionalOutputs: '[]',"
+ " whatErrored: 'output_directory', unexpectedType: 'File[CONSTANT_METADATA]'}")
@TestParameters(
"{input: 'input_directory', output: 'output_directory', additionalOutputs:"
+ " '[ctx.version_file]', whatErrored: 'additional_outputs[0]', unexpectedType:"
+ " 'File[CONSTANT_METADATA]'}")
// SpecialArtifact: UNRESOLVED_SYMLINK(s) are not allowed.
@TestParameters(
"{input: 'symlink', output: 'output_directory', additionalOutputs: '[]',"
+ " whatErrored: 'input_directory', unexpectedType: 'File[UNRESOLVED_SYMLINK]'}")
@TestParameters(
"{input: 'input_directory', output: 'symlink', additionalOutputs: '[]',"
+ " whatErrored: 'output_directory', unexpectedType: 'File[UNRESOLVED_SYMLINK]'}")
@TestParameters(
"{input: 'input_directory', output: 'output_directory', additionalOutputs:"
+ " '[symlink]', whatErrored: 'additional_outputs[0]', unexpectedType:"
+ " 'File[UNRESOLVED_SYMLINK]'}")
public void testNonDirectorySpecialArtifactsNotAllowed(
String input,
String output,
String additionalOutputs,
String whatErrored,
String unexpectedType)
throws Exception {
addOptions("--allow_unresolved_symlinks");
write(
"test/rules.bzl",
String.format(
"""
def _transform_dir_impl(
actions,
input_directory_listing,
output_directory):
pass
def _my_rule_impl(ctx):
input_directory = ctx.actions.declare_directory(
ctx.attr.name + "_initial_directory"
)
symlink = ctx.actions.declare_symlink("unresolved_symlink")
output_directory = ctx.actions.declare_directory(ctx.attr.name + "_output_dir")
ctx.actions.transform_directory(
implementation = _transform_dir_impl,
input_directory = %s,
output_directory = %s,
additional_outputs = %s,
)
return [DefaultInfo(files = depset([output_directory]))]
my_rule = rule(implementation = _my_rule_impl)
""",
input, output, additionalOutputs));
RecordingOutErr recordingOutErr = new RecordingOutErr();
this.outErr = recordingOutErr;
assertThrows(ViewCreationFailedException.class, () -> buildTarget("//test:target"));
assertThat(recordingOutErr.errAsLatin1())
.contains(
String.format(
"Expected directory artifact for %s, but got a %s instead",
whatErrored, unexpectedType));
}
}