blob: 04c1bcc174e74fa24c07f21b661316d3f86ce2d6 [file] [log] [blame] [edit]
// Copyright 2023 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow::{ensure, Context, Result};
use chrome_trace::{Event, Phase, Trace};
use clap::Parser;
use cliutil::{handle_top_level_result, PROFILES_DIR_ENV};
use fileutil::SafeTempDir;
use nix::unistd::{getgid, getuid};
use processes::status_to_exit_code;
use serde_json::json;
use std::collections::HashMap;
use std::fs::File;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, ExitStatus};
use std::time::Instant;
const PROGRAM_NAME: &str = "action_wrapper";
const SUDO_PATH: &str = "/usr/bin/sudo";
#[derive(Parser, Debug)]
#[clap(
about = "General-purpose wrapper of programs implementing Bazel actions.",
author, version, about, long_about=None, trailing_var_arg = true)]
struct Cli {
#[arg(
help = "If set, redirects stdout/stderr of the wrapped process to \
the specified file, and print it to stderr only when it exits \
abnormally.",
long
)]
log: Option<PathBuf>,
#[arg(
help = "If set, sets up environment variables of the wrapped process \
so that it and its subprocesses records tracing data, and collects \
them into a single Chrome tracing JSON file at the specified path.",
long
)]
profile: Option<PathBuf>,
#[arg(
help = "Runs the wrapped process with privilege using sudo. This \
assumes that we can run sudo without password, so typically this \
option works only within legacy CrOS chroot. Use this option only \
for temporary workaround during Alchemy development.",
long
)]
privileged: bool,
#[arg(
help = "Specifies output files of the wrapped process. It can be \
repeated multiple times. These files will be processed with \
`sudo chown` after the wrapped process finishes so that Bazel \
can access those files.",
long
)]
privileged_output: Vec<PathBuf>,
#[arg(help = "Command line of the wrapped process.", required = true)]
command_line: Vec<String>,
}
fn ensure_passwordless_sudo() -> Result<()> {
let status = Command::new(SUDO_PATH)
.args(["-n", "true"])
.status()
.context("Failed to run sudo")?;
ensure!(
status.success(),
"Failed to run sudo without password; run \"sudo true\" and try again"
);
Ok(())
}
fn merge_profiles(input_profiles_dir: &Path, output_profile_file: &Path) -> Result<()> {
let mut merged_trace = Trace::new();
// Load all profiles and merge events into one trace.
for entry in std::fs::read_dir(input_profiles_dir)? {
let entry = entry?;
let trace = Trace::load(File::open(entry.path())?)?;
merged_trace.events.extend(trace.events);
}
// Compute timestamp offsets from clock_sync metadata events.
let mut clock_offset_by_process_id: HashMap<i64, f64> = HashMap::new();
for event in merged_trace.events.iter() {
if event.phase != Phase::Metadata {
continue;
}
if event.name != "clock_sync" {
continue;
}
let args_value = match &event.args {
Some(a) => a,
None => {
continue;
}
};
let args_object = match args_value.as_object() {
Some(o) => o,
None => {
continue;
}
};
let system_time_number = match args_object.get("system_time") {
Some(serde_json::Value::Number(n)) => n,
_ => {
continue;
}
};
let system_time = match system_time_number.as_f64() {
Some(f) => f,
None => {
continue;
}
};
let offset = system_time - event.timestamp / 1000.0;
clock_offset_by_process_id.insert(event.process_id, offset);
}
// Update timestamps.
if let Some(min_clock_offset) = clock_offset_by_process_id
.values()
.copied()
.reduce(f64::min)
{
for event in merged_trace.events.iter_mut() {
let clock_offset = match clock_offset_by_process_id.get(&event.process_id) {
Some(o) => *o,
None => {
// Leave unadjustable entries as-is.
continue;
}
};
event.timestamp += clock_offset - min_clock_offset;
}
}
// Also add process_sort_index metadata to ensure processes are sorted in
// the execution order.
let mut clock_offsets: Vec<(i64, f64)> = clock_offset_by_process_id.into_iter().collect();
clock_offsets.sort_by(|(_, a), (_, b)| a.partial_cmp(b).expect("Clock offset is NaN"));
for (sort_index, (process_id, _)) in clock_offsets.into_iter().enumerate() {
merged_trace.events.push(Event {
name: "process_sort_index".to_owned(),
category: "".to_owned(),
phase: Phase::Metadata,
timestamp: 0.0,
process_id,
thread_id: 0,
args: Some(json!({ "sort_index": sort_index })),
});
}
// Save merged traces.
merged_trace.save(File::create(output_profile_file)?)?;
Ok(())
}
fn do_main(args: Cli) -> Result<ExitStatus> {
let mut command = if args.privileged {
ensure_passwordless_sudo()?;
let mut command = Command::new(SUDO_PATH);
command.arg("--preserve-env").args(&args.command_line);
command
} else {
let mut command = Command::new(&args.command_line[0]);
command.args(&args.command_line[1..]);
command
};
let profiles_dir = SafeTempDir::new()?;
command.env(PROFILES_DIR_ENV, profiles_dir.path());
let start_time = Instant::now();
let status = processes::run(&mut command)?;
let elapsed = start_time.elapsed();
if let Some(signal_num) = status.signal() {
let signal_name = match nix::sys::signal::Signal::try_from(signal_num) {
Ok(signal) => signal.to_string(),
Err(_) => signal_num.to_string(),
};
eprintln!(
"{}: Command killed with signal {} in {:.1}s",
PROGRAM_NAME,
signal_name,
elapsed.as_secs_f32()
);
} else if let Some(code) = status.code() {
eprintln!(
"{}: Command exited with code {} in {:.1}s",
PROGRAM_NAME,
code,
elapsed.as_secs_f32()
);
} else {
unreachable!("Unexpected ExitStatus: {:?}", status);
};
// Run chown on output files by a privileged process.
if args.privileged && !args.privileged_output.is_empty() {
let mut command = Command::new(SUDO_PATH);
command
.arg("chown")
.arg(format!("{}:{}", getuid(), getgid()))
.arg("--")
.args(args.privileged_output);
processes::run(&mut command)?;
}
if let Some(profile_file) = args.profile {
merge_profiles(profiles_dir.path(), &profile_file)?;
}
// Propagate the exit status of the command.
Ok(status)
}
fn main() -> ExitCode {
let args = Cli::parse();
// Always enable Rust backtraces.
std::env::set_var("RUST_BACKTRACE", "1");
// Redirect stdout/stderr to a file if `--log` was specified.
let redirector = if let Some(log_name) = &args.log {
Some(cliutil::StdioRedirector::new(log_name).unwrap())
} else {
None
};
// We don't use `cli_main` to avoid emitting the preamble logs because
// action_wrapper must queue stdout/stderr until it sees the wrapped program
// to exit abnormally. This means we don't log the arguments passed to
// action_wrapper itself, but the wrapped program should soon print one with
// `cli_main`.
let status = do_main(args);
let exit_code = handle_top_level_result(status.as_ref().map(status_to_exit_code));
match status {
Ok(status) if status.code() == Some(0) => {}
_ => {
if let Some(redirector) = redirector {
redirector.flush_to_real_stderr().unwrap()
}
}
}
exit_code
}
#[cfg(test)]
mod tests {
use std::{
os::unix::prelude::MetadataExt,
process::{ExitStatus, Stdio},
};
use regex::Regex;
use tempfile::NamedTempFile;
use super::*;
const ACTION_WRAPPER_PATH: &str = "bazel/portage/bin/action_wrapper/action_wrapper";
#[derive(PartialEq)]
enum TerminationKind {
ExitCode(u8),
Signal(String),
}
impl std::fmt::Display for TerminationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TerminationKind::ExitCode(code) => write!(f, "{code}"),
TerminationKind::Signal(signal) => write!(f, "{signal}"),
}
}
}
struct ActionWrapperOutputs {
pub status: ExitStatus,
pub stdout: String,
pub stderr: String,
pub log: String,
}
fn run_action_wrapper(termination_kind: TerminationKind) -> Result<ActionWrapperOutputs> {
let out_file = NamedTempFile::new()?;
let mut command = Command::new(ACTION_WRAPPER_PATH);
command
.arg("--log")
.arg(out_file.path())
.arg("bazel/portage/bin/action_wrapper/testdata/test_script.sh")
.arg(format!("{termination_kind}"))
.arg("ONE")
.arg("TWO");
let output = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
Ok(ActionWrapperOutputs {
status: output.status,
stdout: String::from_utf8(output.stdout)?,
stderr: String::from_utf8(output.stderr)?,
log: std::fs::read_to_string(out_file.path())?,
})
}
const PROGRAM_NAME: &str = "action_wrapper";
const TEST_SCRIPT_OUTPUT: &str = "stdout ONE\nstderr TWO\n";
#[test]
fn redirected_error() -> Result<()> {
let log_re = Regex::new(&format!(
r"^{TEST_SCRIPT_OUTPUT}{PROGRAM_NAME}: Command exited with code 40 in \d+\.\d+s\n"
))
.unwrap();
let outputs = run_action_wrapper(TerminationKind::ExitCode(40))?;
assert_eq!(outputs.status.code(), Some(40));
assert_eq!(outputs.stdout, "");
assert!(
log_re.is_match(&outputs.stderr),
"stderr: {}",
outputs.stderr
);
assert!(log_re.is_match(&outputs.log), "log: {}", outputs.log);
Ok(())
}
#[test]
fn redirected_success() -> Result<()> {
let log_re = Regex::new(&format!(
r"^{TEST_SCRIPT_OUTPUT}{PROGRAM_NAME}: Command exited with code 0 in \d+\.\d+s\n"
))
.unwrap();
let outputs = run_action_wrapper(TerminationKind::ExitCode(0))?;
assert_eq!(outputs.status.code(), Some(0));
assert_eq!(outputs.stdout, "");
assert_eq!(outputs.stderr, "");
assert!(log_re.is_match(&outputs.log), "log: {}", outputs.log);
Ok(())
}
#[test]
fn redirected_signal() -> Result<()> {
let log_re = Regex::new(&format!(
r"^{TEST_SCRIPT_OUTPUT}{PROGRAM_NAME}: Command killed with signal SIGUSR1 in \d+\.\d+s\n"
))
.unwrap();
let outputs = run_action_wrapper(TerminationKind::Signal(String::from("USR1")))?;
assert_eq!(outputs.status.code(), Some(128 + libc::SIGUSR1));
assert_eq!(outputs.stdout, "");
assert!(
log_re.is_match(&outputs.stderr),
"stderr: {}",
outputs.stderr
);
assert!(log_re.is_match(&outputs.log), "log: {}", outputs.log);
Ok(())
}
#[test]
fn no_such_command() -> Result<()> {
let out_file = NamedTempFile::new()?;
let output = Command::new(ACTION_WRAPPER_PATH)
.arg("--log")
.arg(out_file.path())
.arg("/no/such/command")
.output()?;
// The error is recorded in the log and printed to stderr.
assert!(!output.stderr.is_empty());
assert!(out_file.as_file().metadata()?.size() > 0);
Ok(())
}
#[test]
fn run_without_arguments() -> Result<()> {
let mut command = Command::new(ACTION_WRAPPER_PATH);
let output = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
assert!(!output.status.success());
let actual_printed_stderr = String::from_utf8(output.stderr)?;
// We only check a part of a long error message.
assert!(
actual_printed_stderr.contains("required arguments were not provided"),
"unexpected stderr: {}",
actual_printed_stderr
);
Ok(())
}
}