use std::io::Write;
use futures_util::StreamExt;
use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::types::exec::{
ExecCreateConfig, ExecCreateResponse, ExecInspect, ExecStartConfig,
};
use crate::libpod::{urlencoded, LogOutput, API_PREFIX};
use super::Engine;
const EXEC_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
#[derive(Default)]
pub struct ExecOptions {
pub env: Vec<String>,
pub user: Option<String>,
pub workdir: Option<String>,
pub privileged: bool,
pub detach: bool,
pub index: Option<u32>,
}
fn expand_exec_env(env: &[String]) -> Vec<String> {
env.iter()
.filter_map(|e| {
if e.contains('=') {
Some(e.clone())
} else {
std::env::var(e).ok().map(|v| format!("{e}={v}"))
}
})
.collect()
}
fn is_exec_teardown_noise(line: &str) -> bool {
line.contains("unixpacket") && line.contains("connection reset by peer")
}
fn map_not_running(e: crate::libpod::PodmanError, service_name: &str) -> ComposeError {
let not_running = e.is_status(404)
|| matches!(
&e,
crate::libpod::PodmanError::Api { message, .. }
if {
let m = message.to_ascii_lowercase();
m.contains("can only create exec sessions on running containers")
|| m.contains("is not running")
|| m.contains("no such container")
}
);
if not_running {
ComposeError::NotRunning(service_name.to_string())
} else {
ComposeError::Podman(e)
}
}
fn map_exec_start_err(e: crate::libpod::PodmanError, opts: &ExecOptions) -> ComposeError {
if e.is_timeout() {
let cause = if let Some(user) = &opts.user {
format!(" (the requested user '{user}' may not exist in the container)")
} else if let Some(dir) = &opts.workdir {
format!(" (the requested working directory '{dir}' may not exist)")
} else {
String::new()
};
return ComposeError::ExecFailed(format!(
"the exec session did not start within {}s{cause}",
EXEC_START_TIMEOUT.as_secs()
));
}
ComposeError::Podman(e)
}
impl Engine {
pub async fn exec(
&self,
file: &ComposeFile,
service_name: &str,
cmd: Vec<String>,
) -> Result<()> {
self.exec_with_options(file, service_name, cmd, ExecOptions::default())
.await
}
pub async fn exec_with_options(
&self,
file: &ComposeFile,
service_name: &str,
cmd: Vec<String>,
opts: ExecOptions,
) -> Result<()> {
if cmd.is_empty() {
return Err(ComposeError::Unsupported(
"exec: a command is required".into(),
));
}
let service = file
.services
.get(service_name)
.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
let container_name = self.replica_name_at(service_name, service, opts.index)?;
let env = expand_exec_env(&opts.env);
let exec_cfg = ExecCreateConfig {
cmd: Some(cmd),
attach_stdout: Some(true),
attach_stderr: Some(true),
user: opts.user.clone(),
working_dir: opts.workdir.clone(),
privileged: opts.privileged.then_some(true),
env: (!env.is_empty()).then_some(env),
..Default::default()
};
let create_path = format!(
"{API_PREFIX}/containers/{}/exec",
urlencoded(&container_name),
);
let resp: ExecCreateResponse = self
.client
.post_json(&create_path, &exec_cfg)
.await
.map_err(|e| map_not_running(e, service_name))?;
let exec_id = resp.id;
if opts.detach {
let start_cfg = ExecStartConfig {
detach: true,
tty: false,
};
let start_path = format!("{API_PREFIX}/exec/{}/start", urlencoded(&exec_id));
let _ = self
.client
.post_json_stream_within(&start_path, &start_cfg, Some(EXEC_START_TIMEOUT))
.await
.map_err(|e| map_exec_start_err(e, &opts))?;
return Ok(());
}
let start_cfg = ExecStartConfig {
detach: false,
tty: false,
};
let start_path = format!("{API_PREFIX}/exec/{}/start", urlencoded(&exec_id));
let start_resp = self
.client
.post_json_stream_within(&start_path, &start_cfg, Some(EXEC_START_TIMEOUT))
.await
.map_err(|e| map_exec_start_err(e, &opts))?;
let mut stream = crate::libpod::parse_multiplexed(start_resp.into_body());
{
let mut out = std::io::stdout().lock();
while let Some(msg) = stream.next().await {
match msg.map_err(ComposeError::Podman)? {
LogOutput::StdOut { message } => {
let _ = out.write_all(String::from_utf8_lossy(&message).as_bytes());
let _ = out.flush();
}
LogOutput::StdErr { message } => {
let text = String::from_utf8_lossy(&message);
if is_exec_teardown_noise(&text) {
continue;
}
let mut err = std::io::stderr().lock();
let _ = err.write_all(text.as_bytes());
let _ = err.flush();
}
}
}
}
let inspect_path = format!("{API_PREFIX}/exec/{}/json", urlencoded(&exec_id));
let inspect: ExecInspect = self
.client
.get_json(&inspect_path)
.await
.map_err(ComposeError::Podman)?;
if let Some(code) = inspect.exit_code {
if code != 0 {
return Err(ComposeError::RunExited(code));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
expand_exec_env, is_exec_teardown_noise, map_exec_start_err, map_not_running, ExecOptions,
EXEC_START_TIMEOUT,
};
#[test]
fn expand_exec_env_passes_through_key_value() {
let out = expand_exec_env(&["FOO=bar".to_string(), "BAZ=qux".to_string()]);
assert_eq!(out, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
}
#[test]
fn expand_exec_env_resolves_bare_key_from_host() {
std::env::set_var("PODUP_TEST_EXEC_ENV", "from-host");
let out = expand_exec_env(&[
"PODUP_TEST_EXEC_ENV".to_string(),
"PODUP_TEST_EXEC_UNSET_ENV".to_string(),
]);
std::env::remove_var("PODUP_TEST_EXEC_ENV");
assert_eq!(out, vec!["PODUP_TEST_EXEC_ENV=from-host".to_string()]);
}
#[test]
fn teardown_noise_matches_only_connection_reset_frame() {
assert!(is_exec_teardown_noise(
"read unixpacket @->/run/...: read: connection reset by peer"
));
assert!(!is_exec_teardown_noise("connection reset by peer"));
assert!(!is_exec_teardown_noise("hello world"));
}
#[test]
fn map_not_running_maps_404_and_stopped() {
use crate::error::ComposeError;
use crate::libpod::PodmanError;
let e404 = PodmanError::Api {
status: 404,
message: "no such container: web".into(),
};
assert!(matches!(
map_not_running(e404, "web"),
ComposeError::NotRunning(s) if s == "web"
));
let e500 = PodmanError::Api {
status: 500,
message: "can only create exec sessions on running containers".into(),
};
assert!(matches!(
map_not_running(e500, "web"),
ComposeError::NotRunning(_)
));
let other = PodmanError::Api {
status: 500,
message: "disk full".into(),
};
assert!(matches!(
map_not_running(other, "web"),
ComposeError::Podman(_)
));
}
#[test]
fn exec_start_timeout_with_user_names_the_user() {
use crate::libpod::PodmanError;
let timeout = PodmanError::Api {
status: 0,
message: format!(
"timed out after {}s waiting for the Podman socket to respond",
EXEC_START_TIMEOUT.as_secs()
),
};
let opts = ExecOptions {
user: Some("doesnotexist".into()),
..Default::default()
};
let mapped = map_exec_start_err(timeout, &opts);
match mapped {
crate::error::ComposeError::ExecFailed(msg) => {
assert!(msg.contains("doesnotexist"), "got {msg}");
assert!(msg.contains("did not start"), "got {msg}");
assert!(
!msg.to_ascii_lowercase().contains("podman socket"),
"must not leak the socket-timeout wording: {msg}"
);
}
other => panic!("expected ExecFailed, got {other:?}"),
}
}
#[test]
fn exec_start_timeout_without_user_names_the_workdir() {
use crate::libpod::PodmanError;
let timeout = PodmanError::Api {
status: 0,
message: "timed out after 20s waiting for the Podman socket to respond".into(),
};
let opts = ExecOptions {
workdir: Some("/no/such/dir".into()),
..Default::default()
};
match map_exec_start_err(timeout, &opts) {
crate::error::ComposeError::ExecFailed(msg) => {
assert!(msg.contains("/no/such/dir"), "got {msg}");
}
other => panic!("expected ExecFailed, got {other:?}"),
}
}
#[test]
fn exec_start_real_api_error_passes_through() {
use crate::error::ComposeError;
use crate::libpod::PodmanError;
let api = PodmanError::Api {
status: 500,
message: "unable to find user doesnotexist: no matching entries in passwd file".into(),
};
let opts = ExecOptions {
user: Some("doesnotexist".into()),
..Default::default()
};
match map_exec_start_err(api, &opts) {
ComposeError::Podman(e) => {
assert!(e.to_string().contains("no matching entries in passwd file"));
}
other => panic!("expected Podman passthrough, got {other:?}"),
}
}
}