use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use http_body_util::BodyExt;
use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::{urlencoded, API_PREFIX};
use super::super::Engine;
#[derive(Debug, Clone, Default)]
pub struct CommitOptions {
pub message: Option<String>,
pub author: Option<String>,
pub pause: Option<bool>,
pub changes: Vec<String>,
}
fn split_image_ref(image: &str) -> Result<(&str, &str)> {
let (repo, tag) = match image.rsplit_once(':') {
Some((r, t)) if !t.contains('/') => (r, t),
_ => (image, "latest"),
};
if repo.is_empty() {
return Err(ComposeError::Unsupported(format!(
"invalid image reference {image:?}: a non-empty repository name is required \
(e.g. myimage or myimage:tag)"
)));
}
if tag.is_empty() {
return Err(ComposeError::Unsupported(format!(
"invalid image reference {image:?}: the tag after ':' must not be empty"
)));
}
Ok((repo, tag))
}
fn commit_path(container: &str, repo: &str, tag: &str, pause: bool) -> String {
format!(
"{API_PREFIX}/commit?container={}&repo={}&tag={}&pause={pause}",
urlencoded(container),
urlencoded(repo),
urlencoded(tag),
)
}
impl Engine {
pub async fn commit(
&self,
file: &ComposeFile,
service_name: &str,
image: &str,
index: Option<u32>,
) -> Result<()> {
self.commit_with_pause(file, service_name, image, index, true)
.await
}
pub async fn commit_with_pause(
&self,
file: &ComposeFile,
service_name: &str,
image: &str,
index: Option<u32>,
pause: bool,
) -> Result<()> {
self.commit_with_options(
file,
service_name,
image,
index,
CommitOptions {
pause: Some(pause),
..Default::default()
},
)
.await
}
pub async fn commit_with_options(
&self,
file: &ComposeFile,
service_name: &str,
image: &str,
index: Option<u32>,
opts: CommitOptions,
) -> Result<()> {
let service = file
.services
.get(service_name)
.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
let container = self.replica_name_at(service_name, service, index)?;
let (repo, tag) = split_image_ref(image)?;
let pause = opts.pause.unwrap_or(true);
let mut path = commit_path(&container, repo, tag, pause);
if let Some(message) = &opts.message {
path.push_str(&format!("&comment={}", urlencoded(message)));
}
if let Some(author) = &opts.author {
path.push_str(&format!("&author={}", urlencoded(author)));
}
for change in &opts.changes {
path.push_str(&format!("&changes={}", urlencoded(change)));
}
self.client
.post_empty_ok(&path)
.await
.map_err(ComposeError::Podman)?;
tracing::info!("committed {container} to {repo}:{tag}");
Ok(())
}
pub async fn export(
&self,
file: &ComposeFile,
service_name: &str,
output: Option<PathBuf>,
index: Option<u32>,
) -> Result<()> {
let service = file
.services
.get(service_name)
.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
let container = self.replica_name_at(service_name, service, index)?;
if refuse_tar_to_tty(output.is_none(), std::io::stdout().is_terminal()) {
return Err(ComposeError::Unsupported(
"refusing to write a tar archive to the terminal: pass -o FILE or redirect stdout"
.into(),
));
}
let path = format!("{API_PREFIX}/containers/{}/export", urlencoded(&container),);
let resp = self
.client
.get_stream(&path)
.await
.map_err(ComposeError::Podman)?;
let to_stdout = output.is_none() || output.as_deref() == Some(std::path::Path::new("-"));
let mut sink: Box<dyn Write> = if to_stdout {
Box::new(std::io::stdout().lock())
} else {
let p = output.as_ref().unwrap();
Box::new(std::fs::File::create(p).map_err(|e| ComposeError::IoPath {
path: p.display().to_string(),
source: e,
})?)
};
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| ComposeError::Build(format!("export stream: {e}")))?;
if let Ok(data) = frame.into_data() {
if let Err(e) = sink.write_all(&data) {
if is_stdout_broken_pipe(to_stdout, &e) {
return Ok(());
}
return Err(io_to_err(&output, e));
}
}
}
if let Err(e) = sink.flush() {
if is_stdout_broken_pipe(to_stdout, &e) {
return Ok(());
}
return Err(io_to_err(&output, e));
}
Ok(())
}
}
fn is_stdout_broken_pipe(to_stdout: bool, e: &std::io::Error) -> bool {
to_stdout && e.kind() == std::io::ErrorKind::BrokenPipe
}
fn io_to_err(output: &Option<PathBuf>, e: std::io::Error) -> ComposeError {
match output {
Some(p) => ComposeError::IoPath {
path: p.display().to_string(),
source: e,
},
None => ComposeError::Io(e),
}
}
fn refuse_tar_to_tty(no_output_file: bool, stdout_is_tty: bool) -> bool {
no_output_file && stdout_is_tty
}
#[cfg(test)]
mod tests {
use super::{commit_path, refuse_tar_to_tty, split_image_ref};
#[test]
fn split_image_ref_defaults_tag() {
assert_eq!(split_image_ref("myimage").unwrap(), ("myimage", "latest"));
assert_eq!(split_image_ref("myimage:1.0").unwrap(), ("myimage", "1.0"));
}
#[test]
fn split_image_ref_keeps_registry_port() {
assert_eq!(
split_image_ref("registry:5000/app").unwrap(),
("registry:5000/app", "latest")
);
assert_eq!(
split_image_ref("localhost:5000/app:v2").unwrap(),
("localhost:5000/app", "v2")
);
}
#[test]
fn split_image_ref_rejects_empty_and_empty_repo() {
assert!(split_image_ref("").is_err());
assert!(split_image_ref(":tag").is_err());
assert!(split_image_ref("repo:").is_err());
}
#[test]
fn commit_path_includes_pause_flag() {
let paused = commit_path("proj_web_1", "repo", "latest", true);
assert!(paused.contains("container=proj_web_1"));
assert!(paused.contains("repo=repo"));
assert!(paused.contains("tag=latest"));
assert!(paused.contains("pause=true"));
let live = commit_path("proj_web_1", "repo", "latest", false);
assert!(live.contains("pause=false"));
}
#[test]
fn refuses_only_when_no_file_and_tty() {
assert!(refuse_tar_to_tty(true, true));
assert!(!refuse_tar_to_tty(true, false));
assert!(!refuse_tar_to_tty(false, true));
assert!(!refuse_tar_to_tty(false, false));
}
#[test]
fn stdout_broken_pipe_is_a_clean_stop_only_on_stdout() {
use super::is_stdout_broken_pipe;
use std::io::{Error, ErrorKind};
let epipe = || Error::from(ErrorKind::BrokenPipe);
assert!(is_stdout_broken_pipe(true, &epipe()));
assert!(!is_stdout_broken_pipe(false, &epipe()));
assert!(!is_stdout_broken_pipe(
true,
&Error::from(ErrorKind::PermissionDenied)
));
}
}