1
0
Fork 0
mirror of https://github.com/containers/youki synced 2024-05-05 07:06:14 +02:00
youki/crates/libcontainer/src/hooks.rs
Emanuele Aina 6ba0efdff9 style(spelling): Spellcheck the libcontainer crate
Signed-off-by: Emanuele Aina <emanuele.aina@collabora.com>
2022-01-03 22:06:03 +01:00

236 lines
9.5 KiB
Rust

use anyhow::{bail, Context, Result};
use nix::{sys::signal, unistd::Pid};
use oci_spec::runtime::Hook;
use std::{
collections::HashMap, fmt, io::ErrorKind, io::Write, os::unix::prelude::CommandExt, process,
thread, time,
};
use crate::{container::Container, utils};
// A special error used to signal a timeout. We want to differentiate between a
// timeout vs. other error.
#[derive(Debug)]
pub struct HookTimeoutError;
impl std::error::Error for HookTimeoutError {}
impl fmt::Display for HookTimeoutError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
"hook command timeout".fmt(f)
}
}
pub fn run_hooks(hooks: Option<&Vec<Hook>>, container: Option<&Container>) -> Result<()> {
if container.is_none() {
bail!("container state is required to run hook");
}
let state = &container.unwrap().state;
if let Some(hooks) = hooks {
for hook in hooks {
let mut hook_command = process::Command::new(&hook.path());
// Based on OCI spec, the first argument of the args vector is the
// arg0, which can be different from the path. For example, path
// may be "/usr/bin/true" and arg0 is set to "true". However, rust
// command differenciates arg0 from args, where rust command arg
// doesn't include arg0. So we have to make the split arg0 from the
// rest of args.
if let Some((arg0, args)) = hook.args().as_ref().and_then(|a| a.split_first()) {
log::debug!("run_hooks arg0: {:?}, args: {:?}", arg0, args);
hook_command.arg0(arg0).args(args)
} else {
hook_command.arg0(&hook.path().display().to_string())
};
let envs: HashMap<String, String> = if let Some(env) = hook.env() {
utils::parse_env(env)
} else {
HashMap::new()
};
log::debug!("run_hooks envs: {:?}", envs);
let mut hook_process = hook_command
.env_clear()
.envs(envs)
.stdin(process::Stdio::piped())
.spawn()
.with_context(|| "Failed to execute hook")?;
let hook_process_pid = Pid::from_raw(hook_process.id() as i32);
// Based on the OCI spec, we need to pipe the container state into
// the hook command through stdin.
if let Some(stdin) = &mut hook_process.stdin {
// We want to ignore BrokenPipe here. A BrokenPipe indicates
// either the hook is crashed/errored or it ran successfully.
// Either way, this is an indication that the hook command
// finished execution. If the hook command was successful,
// which we will check later in this function, we should not
// fail this step here. We still want to check for all the other
// error, in the case that the hook command is waiting for us to
// write to stdin.
let encoded_state =
serde_json::to_string(state).context("failed to encode container state")?;
if let Err(e) = stdin.write_all(encoded_state.as_bytes()) {
if e.kind() != ErrorKind::BrokenPipe {
// Not a broken pipe. The hook command may be waiting
// for us.
let _ = signal::kill(hook_process_pid, signal::Signal::SIGKILL);
bail!("failed to write container state to stdin: {:?}", e);
}
}
}
let res = if let Some(timeout_sec) = hook.timeout() {
// Rust does not make it easy to handle executing a command and
// timeout. Here we decided to wait for the command in a
// different thread, so the main thread is not blocked. We use a
// channel shared between main thread and the wait thread, since
// the channel has timeout functions out of the box. Rust won't
// let us copy the Command structure, so we can't share it
// between the wait thread and main thread. Therefore, we will
// use pid to identify the process and send a kill signal. This
// is what the Command.kill() does under the hood anyway. When
// timeout, we have to kill the process and clean up properly.
let (s, r) = crossbeam_channel::unbounded();
thread::spawn(move || {
let res = hook_process.wait();
let _ = s.send(res);
});
match r.recv_timeout(time::Duration::from_secs(timeout_sec as u64)) {
Ok(res) => res,
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
// Kill the process. There is no need to further clean
// up because we will be error out.
let _ = signal::kill(hook_process_pid, signal::Signal::SIGKILL);
return Err(HookTimeoutError.into());
}
Err(_) => {
unreachable!();
}
}
} else {
hook_process.wait()
};
match res {
Ok(exit_status) => match exit_status.code() {
Some(0) => {}
Some(exit_code) => {
bail!(
"Failed to execute hook command. Non-zero return code. {:?}",
exit_code
);
}
None => {
bail!("Process is killed by signal");
}
},
Err(e) => {
bail!("Failed to execute hook command: {:?}", e);
}
}
}
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::{bail, Result};
use oci_spec::runtime::HookBuilder;
use serial_test::serial;
use std::{env, fs};
fn is_command_in_path(program: &str) -> bool {
if let Ok(path) = env::var("PATH") {
for p in path.split(':') {
let p_str = format!("{}/{}", p, program);
if fs::metadata(p_str).is_ok() {
return true;
}
}
}
false
}
// Note: the run_hook will require the use of pipe to write the container
// state into stdin of the hook command. When cargo test runs these tests in
// parallel with other tests, the pipe becomes flaky and often we will get
// broken pipe or bad file descriptors. There is not much we can do and we
// decide not to retry in the test. The most sensible way to test this is
// ask cargo test to run these tests in serial.
#[test]
#[serial]
fn test_run_hook() -> Result<()> {
{
let default_container: Container = Default::default();
run_hooks(None, Some(&default_container)).context("Failed simple test")?;
}
{
assert!(is_command_in_path("true"), "The true was not found.");
let default_container: Container = Default::default();
let hook = HookBuilder::default().path("true").build()?;
let hooks = Some(vec![hook]);
run_hooks(hooks.as_ref(), Some(&default_container)).context("Failed true")?;
}
{
assert!(
is_command_in_path("printenv"),
"The printenv was not found."
);
// Use `printenv` to make sure the environment is set correctly.
let default_container: Container = Default::default();
let hook = HookBuilder::default()
.path("bash")
.args(vec![
String::from("bash"),
String::from("-c"),
String::from("printenv key > /dev/null"),
])
.env(vec![String::from("key=value")])
.build()?;
let hooks = Some(vec![hook]);
run_hooks(hooks.as_ref(), Some(&default_container)).context("Failed printenv test")?;
}
Ok(())
}
#[test]
#[serial]
// This will test executing hook with a timeout. Since the timeout is set in
// secs, minimally, the test will run for 1 second to trigger the timeout.
fn test_run_hook_timeout() -> Result<()> {
let default_container: Container = Default::default();
// We use `tail -f /dev/null` here to simulate a hook command that hangs.
let hook = HookBuilder::default()
.path("tail")
.args(vec![
String::from("tail"),
String::from("-f"),
String::from("/dev/null"),
])
.timeout(1)
.build()?;
let hooks = Some(vec![hook]);
match run_hooks(hooks.as_ref(), Some(&default_container)) {
Ok(_) => {
bail!("The test expects the hook to error out with timeout. Should not execute cleanly");
}
Err(err) => {
// We want to make sure the error returned is indeed timeout
// error. All other errors are considered failure.
if !err.is::<HookTimeoutError>() {
bail!("Failed to execute hook: {:?}", err);
}
}
}
Ok(())
}
}