blob: e409d141649d2e9b57ecaaacb65430cbbe728033 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
//! CI steps that can be inspected or run through `cargo xtask`.
use crate::Result;
use std::env;
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
type StepResult<T> = std::result::Result<T, StepError>;
type StepRunner = fn(&StepContext, &[String]) -> StepResult<CiCommand>;
const CI_COMMAND: &str = "cargo xtask ci step";
const CI_SHORTCUT: &str = "cargo ci-step";
const TPCH_DATA_DIR: &str = "datafusion/sqllogictest/test_files/tpch/data";
/// Metadata and implementation for one CI step.
///
/// Adding an entry to `CI_STEPS` makes the step dispatchable and includes its
/// usage and description in the generated help text.
struct StepInfo {
command: &'static str,
help_usage: &'static str,
help_examples: &'static [&'static str],
help_description: &'static str,
error_message: &'static str,
runner: StepRunner,
}
static CI_STEPS: &[StepInfo] = &[
StepInfo {
command: "check",
help_usage: "check <workspace|package> [default|no-default|feature] [--explain]",
help_examples: &["check workspace", "check datafusion default"],
help_description: "Check workspace or package compilation",
error_message: "Cargo check step failed",
runner: StepContext::run_check,
},
StepInfo {
command: "test",
help_usage: "test <workspace|cli|doctest|ffi|benchmark-plan|benchmark-sqllogic|postgres|substrait> [--explain]",
help_examples: &["test cli", "test workspace"],
help_description: "Run a CI test suite",
error_message: "Cargo test step failed",
runner: StepContext::run_test,
},
];
pub(crate) fn help() -> String {
let mut help = format!(
"DataFusion CI commands\n\nUsage:\n {CI_COMMAND} <step-name> [args] [--explain]\n\nExamples:\n",
);
for step in CI_STEPS {
if let Some(example) = step.help_examples.first() {
writeln!(help, " {CI_COMMAND} {example}").ok();
}
}
writeln!(help, " {CI_COMMAND} test workspace --explain").ok();
let command_width = CI_STEPS
.iter()
.map(|step| step.command.len())
.max()
.unwrap_or_default();
help.push_str("\nAvailable steps:\n");
for step in CI_STEPS {
writeln!(
help,
" {:command_width$} {}",
step.command, step.help_description
)
.ok();
}
write!(help,
"\nShortcut:\n # `{CI_SHORTCUT}` is short for `{CI_COMMAND}`.\n {CI_SHORTCUT} check workspace\n\nFor more details:\n {CI_COMMAND} check --help\n"
).ok();
help
}
fn step_help(step: &StepInfo) -> String {
let mut help = format!(
"DataFusion CI command: {}\n\n{}\n\nUsage:\n {CI_COMMAND} {}\n\nExamples:\n",
step.command, step.help_description, step.help_usage,
);
for example in step.help_examples {
writeln!(help, " {CI_COMMAND} {example}").ok();
}
if let Some(example) = step.help_examples.first() {
write!(help,
"\nUse '--explain' to show the full command:\n {CI_COMMAND} {example} --explain\n"
).ok();
}
help
}
fn find_step(command: &str) -> Result<&'static StepInfo> {
CI_STEPS
.iter()
.find(|step| step.command == command)
.ok_or_else(|| format!("unknown CI step `{command}`"))
}
pub(crate) fn is_help_arg(arg: &str) -> bool {
matches!(arg, "help" | "-h" | "--help")
}
pub(crate) fn run(root: &Path, args: &[String]) -> Result<()> {
StepContext::new(root).run(args)
}
struct StepContext {
root: PathBuf,
env_reader: fn(&str) -> Result<String>,
}
impl StepContext {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
env_reader: required_env,
}
}
fn run(&self, args: &[String]) -> Result<()> {
match args {
[] => {
print!("{}", help());
Ok(())
}
[help_arg] if is_help_arg(help_arg) => {
print!("{}", help());
Ok(())
}
[step_name, help_arg] if is_help_arg(help_arg) => {
print!("{}", step_help(find_step(step_name)?));
Ok(())
}
step_args => {
let (action, step, command) = self.ci_step(step_args)?;
match action {
StepAction::Execute => command.execute(step.error_message),
StepAction::Explain => {
command.explain();
Ok(())
}
}
}
}
}
/// Parses a CI step into an action and its complete command description.
fn ci_step(
&self,
args: &[String],
) -> Result<(StepAction, &'static StepInfo, CiCommand)> {
let (action, args) = match args.split_last() {
Some((arg, args)) if arg == "--explain" => (StepAction::Explain, args),
_ => (StepAction::Execute, args),
};
let Some((step, args)) = args.split_first() else {
return Err(format!("missing CI step\n\n{}", help()));
};
let step = find_step(step)?;
let command = (step.runner)(self, args).map_err(|error| error.render(step))?;
Ok((action, step, command))
}
/// Implementation for `cargo xtask ci step check [args]`
fn run_check(&self, args: &[String]) -> StepResult<CiCommand> {
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
let mut command = self.cargo();
command.args(["check", "--profile", "ci"]);
match args.as_slice() {
["workspace"] => {
command.args([
"--workspace",
"--all-targets",
"--features",
"integration-tests",
"--locked",
]);
}
[package, "default"] => {
command.args(["--all-targets", "-p", package]);
}
[package, "no-default"] => {
command.args(["--no-default-features", "-p", package]);
}
[package, feature] => {
command.args([
"--no-default-features",
"-p",
package,
"--features",
feature,
]);
}
_ => return Err(StepError::Usage),
}
Ok(command)
}
/// Implementation for `cargo xtask ci step test [args]`
fn run_test(&self, args: &[String]) -> StepResult<CiCommand> {
let [variant] = args else {
return Err(StepError::Usage);
};
let mut command = self.cargo();
match variant.as_str() {
"workspace" => {
command.args([
"llvm-cov",
"--profile",
"ci",
"--exclude",
"datafusion-examples",
"--exclude",
"ffi_example_table_provider",
"--exclude",
"datafusion-cli",
"--workspace",
"--lib",
"--tests",
"--bins",
"--features",
"serde,avro,json,backtrace,integration-tests,parquet_encryption,substrait",
"--codecov",
"--output-path",
"target/codecov.json",
]);
}
"cli" => {
command.args([
"test",
"--features",
"backtrace",
"--profile",
"ci",
"-p",
"datafusion-cli",
"--lib",
"--tests",
"--bins",
]);
}
"doctest" => {
command.args([
"test",
"--profile",
"ci",
"--doc",
"--features",
"avro,json",
]);
}
"ffi" => {
command.args([
"test",
"--profile",
"ci",
"-p",
"datafusion-ffi",
"--lib",
"--tests",
"--features",
"integration-tests",
]);
}
"benchmark-plan" => {
command
.args([
"test",
"plan_q",
"--package",
"datafusion-benchmarks",
"--profile",
"ci",
"--features=ci",
"--",
"--test-threads=1",
])
.env("RUST_MIN_STACK", "20971520")
.env("TPCH_DATA", self.root.join(TPCH_DATA_DIR));
}
"benchmark-sqllogic" => {
command
.args([
"test",
"--features",
"backtrace,parquet_encryption,substrait",
"--profile",
"ci",
"--package",
"datafusion-sqllogictest",
"--test",
"sqllogictests",
])
.env("RUST_MIN_STACK", "20971520")
.env("TPCH_DATA", self.root.join(TPCH_DATA_DIR))
.env("INCLUDE_TPCH", "true");
}
"postgres" => {
let host = self
.required_env("POSTGRES_HOST")
.map_err(StepError::Message)?;
let port = self
.required_env("POSTGRES_PORT")
.map_err(StepError::Message)?;
let uri = format!("postgresql://postgres:postgres@{host}:{port}/db_test");
command
.args([
"test",
"--features",
"backtrace",
"--profile",
"ci",
"--features=postgres",
"--test",
"sqllogictests",
])
.current_dir(self.root.join("datafusion/sqllogictest"))
.env("PG_COMPAT", "true")
.env("PG_URI", uri);
}
"substrait" => {
command.args([
"test",
"-p",
"datafusion-sqllogictest",
"--test",
"sqllogictests",
"--features",
"substrait",
"--",
"--substrait-round-trip",
"limit.slt",
]);
}
_ => return Err(StepError::Usage),
}
Ok(command)
}
fn cargo(&self) -> CiCommand {
CiCommand::new("cargo", &self.root)
}
fn required_env(&self, name: &str) -> Result<String> {
(self.env_reader)(name)
}
}
#[derive(Debug, PartialEq)]
enum StepAction {
Execute,
Explain,
}
#[derive(Debug)]
enum StepError {
Usage,
Message(String),
}
impl StepError {
fn render(self, step: &StepInfo) -> String {
match self {
Self::Usage => format!("usage: {CI_COMMAND} {}", step.help_usage),
Self::Message(message) => message,
}
}
}
/// Command shared by invocation and `--explain`
struct CiCommand {
command: Command,
}
impl CiCommand {
fn new(program: impl AsRef<OsStr>, current_dir: impl AsRef<Path>) -> Self {
let mut command = Command::new(program);
command.current_dir(current_dir);
Self { command }
}
fn args<I, S>(&mut self, args: I) -> &mut Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.command.args(args);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn current_dir(&mut self, directory: impl AsRef<Path>) -> &mut Self {
self.command.current_dir(directory);
self
}
fn explain(&self) {
println!("{}", self.full_command());
}
fn execute(mut self, error_message: &str) -> Result<()> {
println!("+ {}", self.full_command());
let status = self
.command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|error| format!("{error_message}: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{error_message}: {status}"))
}
}
fn full_command(&self) -> String {
let mut lines = Vec::new();
if let Some(current_dir) = self.command.get_current_dir() {
lines.push(format!("cd {} &&", shell_quote(current_dir.as_os_str())));
}
for (key, value) in self.command.get_envs() {
let Some(value) = value else {
continue;
};
lines.push(format!("{}={}", shell_quote(key), shell_quote(value)));
}
lines.extend(shell_command_lines(
self.command.get_program(),
self.command.get_args(),
));
lines.join(" \\\n")
}
}
/// Groups option-value pairs so the result reads like a hand-written command.
fn shell_command_lines<'a>(
program: &OsStr,
args: impl Iterator<Item = &'a OsStr>,
) -> Vec<String> {
let args = args.collect::<Vec<_>>();
let mut lines = vec![shell_quote(program)];
let mut index = 0;
// Keep command names and leading positional arguments together, such as
// `cargo check` and `cargo test plan_q`.
while index < args.len() && !is_option(args[index]) {
lines[0].push(' ');
lines[0].push_str(&shell_quote(args[index]));
index += 1;
}
while index < args.len() {
let arg = args[index];
let mut line = shell_quote(arg);
index += 1;
if arg == "--" {
while index < args.len() {
line.push(' ');
line.push_str(&shell_quote(args[index]));
index += 1;
}
} else if !arg.to_string_lossy().contains('=')
&& index < args.len()
&& !is_option(args[index])
{
line.push(' ');
line.push_str(&shell_quote(args[index]));
index += 1;
}
lines.push(line);
}
lines
}
fn is_option(value: &OsStr) -> bool {
value.to_string_lossy().starts_with('-')
}
/// Quotes one shell word when it contains characters that need protection.
fn shell_quote(value: &OsStr) -> String {
let value = value.to_string_lossy();
if !value.is_empty()
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(
byte,
b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
)
})
{
value.into_owned()
} else {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
}
fn required_env(name: &str) -> Result<String> {
env::var(name)
.map_err(|_| format!("required environment variable `{name}` is not set"))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_string()).collect()
}
fn test_env(name: &str) -> Result<String> {
match name {
"POSTGRES_HOST" => Ok("postgres.example".to_string()),
"POSTGRES_PORT" => Ok("15432".to_string()),
_ => Err(format!("unexpected environment variable `{name}`")),
}
}
fn context() -> StepContext {
StepContext {
root: PathBuf::from("/workspace"),
env_reader: test_env,
}
}
#[test]
fn explain_builds_the_same_command_as_execute() {
let context = context();
let (execute_action, _, execute_command) = context
.ci_step(&args(&["check", "datafusion", "default"]))
.unwrap();
let (explain_action, _, explain_command) = context
.ci_step(&args(&["check", "datafusion", "default", "--explain"]))
.unwrap();
assert_eq!(execute_action, StepAction::Execute);
assert_eq!(explain_action, StepAction::Explain);
assert_eq!(
execute_command.full_command(),
explain_command.full_command()
);
}
#[test]
fn explain_flag_must_be_last() {
let error = context()
.ci_step(&args(&["test", "--explain", "workspace"]))
.err()
.unwrap();
assert_eq!(
error,
"usage: cargo xtask ci step test <workspace|cli|doctest|ffi|benchmark-plan|benchmark-sqllogic|postgres|substrait> [--explain]"
);
}
// Test all available commands, ensure their executed command is expected
#[test]
fn explain_all_available_ci_commands() {
struct Cmd {
// Actual command to run
command: &'static [&'static str],
// Full explain output
expected: fn(&str),
}
let commands = [
Cmd {
command: &["check", "workspace"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo check \
--profile ci \
--workspace \
--all-targets \
--features integration-tests \
--locked
")
},
},
Cmd {
command: &["check", "datafusion", "default"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo check \
--profile ci \
--all-targets \
-p datafusion
")
},
},
Cmd {
command: &["check", "datafusion", "no-default"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo check \
--profile ci \
--no-default-features \
-p datafusion
")
},
},
Cmd {
command: &["check", "datafusion", "parquet"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo check \
--profile ci \
--no-default-features \
-p datafusion \
--features parquet
")
},
},
Cmd {
command: &["test", "workspace"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo llvm-cov \
--profile ci \
--exclude datafusion-examples \
--exclude ffi_example_table_provider \
--exclude datafusion-cli \
--workspace \
--lib \
--tests \
--bins \
--features serde,avro,json,backtrace,integration-tests,parquet_encryption,substrait \
--codecov \
--output-path target/codecov.json
")
},
},
Cmd {
command: &["test", "cli"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo test \
--features backtrace \
--profile ci \
-p datafusion-cli \
--lib \
--tests \
--bins
")
},
},
Cmd {
command: &["test", "doctest"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo test \
--profile ci \
--doc \
--features avro,json
")
},
},
Cmd {
command: &["test", "ffi"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo test \
--profile ci \
-p datafusion-ffi \
--lib \
--tests \
--features integration-tests
")
},
},
Cmd {
command: &["test", "benchmark-plan"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
RUST_MIN_STACK=20971520 \
TPCH_DATA=/workspace/datafusion/sqllogictest/test_files/tpch/data \
cargo test plan_q \
--package datafusion-benchmarks \
--profile ci \
--features=ci \
-- --test-threads=1
")
},
},
Cmd {
command: &["test", "benchmark-sqllogic"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
INCLUDE_TPCH=true \
RUST_MIN_STACK=20971520 \
TPCH_DATA=/workspace/datafusion/sqllogictest/test_files/tpch/data \
cargo test \
--features backtrace,parquet_encryption,substrait \
--profile ci \
--package datafusion-sqllogictest \
--test sqllogictests
")
},
},
Cmd {
command: &["test", "postgres"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace/datafusion/sqllogictest && \
PG_COMPAT=true \
PG_URI=postgresql://postgres:postgres@postgres.example:15432/db_test \
cargo test \
--features backtrace \
--profile ci \
--features=postgres \
--test sqllogictests
")
},
},
Cmd {
command: &["test", "substrait"],
expected: |actual| {
insta::assert_snapshot!(actual, @r"
cd /workspace && \
cargo test \
-p datafusion-sqllogictest \
--test sqllogictests \
--features substrait \
-- --substrait-round-trip limit.slt
")
},
},
];
let context = context();
for Cmd { command, expected } in commands {
let explain_args = command
.iter()
.copied()
.chain(std::iter::once("--explain"))
.collect::<Vec<_>>();
let (action, _, actual) = context.ci_step(&args(&explain_args)).unwrap();
assert_eq!(action, StepAction::Explain);
expected(&actual.full_command());
}
}
}