Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
491 changes: 491 additions & 0 deletions internal/cli/commands.rs

Large diffs are not rendered by default.

460 changes: 3 additions & 457 deletions internal/cli/mod.rs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions internal/dispatch.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
//! Command dispatch: map a parsed `Commands` to engine calls.
//!
//! Split out of `main.rs` to keep that file within the source line limit as the
Expand Down Expand Up @@ -244,6 +244,17 @@
.await?
}
Commands::Top { services } => engine.top(file, &services).await?,
Commands::Wait { services } => engine.wait_services(file, &services).await?,
Commands::Commit {
service,
image,
index,
} => engine.commit(file, &service, &image, index).await?,
Commands::Export {
service,
output,
index,
} => engine.export(file, &service, output, index).await?,
Commands::Stats {
no_stream,
services,
Expand Down
89 changes: 89 additions & 0 deletions internal/engine/commit_export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! `commit` and `export`: snapshot a service container to an image, or stream
//! its filesystem out as a tar archive (`docker compose commit` / `export`).

use std::io::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::Engine;

impl Engine {
/// Commit a service container to a new image (`docker compose commit`).
/// Targets the given replica (`index`, 1-based) or the first one. `image`
/// is `repo[:tag]`; an omitted tag defaults to `latest`.
pub async fn commit(
&self,
file: &ComposeFile,
service_name: &str,
image: &str,
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)?;

let (repo, tag) = match image.rsplit_once(':') {
// A ':' after the last '/' is a tag; otherwise it's part of a registry
// host:port and the whole string is the repo.
Some((r, t)) if !t.contains('/') => (r, t),
_ => (image, "latest"),
};
let path = format!(
"{API_PREFIX}/commit?container={}&repo={}&tag={}",
urlencoded(&container),
urlencoded(repo),
urlencoded(tag),
);
self.client
.post_empty_ok(&path)
.await
.map_err(ComposeError::Podman)?;
tracing::info!("committed {container} to {repo}:{tag}");
Ok(())
}

/// Export a service container's filesystem as a tar stream (`docker compose
/// export`), to `output` or stdout. Streamed chunk-by-chunk so a large
/// rootfs is never fully buffered in memory.
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)?;

let path = format!("{API_PREFIX}/containers/{}/export", urlencoded(&container),);
let resp = self
.client
.get_stream(&path)
.await
.map_err(ComposeError::Podman)?;

let mut sink: Box<dyn Write> = match &output {
Some(p) => Box::new(std::fs::File::create(p).map_err(ComposeError::Io)?),
None => Box::new(std::io::stdout().lock()),
};
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() {
sink.write_all(&data).map_err(ComposeError::Io)?;
}
}
sink.flush().map_err(ComposeError::Io)?;
Ok(())
}
}
36 changes: 36 additions & 0 deletions internal/engine/lifecycle/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,42 @@ impl Engine {
Ok(())
}

/// Block until each targeted service's containers stop, printing each exit
/// code (`docker compose wait`). Returns `RunExited` with the last non-zero
/// code so the process exit status reflects it, mirroring docker compose.
pub async fn wait_services(
&self,
file: &ComposeFile,
target_services: &[String],
) -> Result<()> {
let order = crate::compose::resolve_order(file)?;
let order = filter_services(file, order, target_services)?;

let mut last_nonzero = 0i64;
for name in &order {
let service = &file.services[name];
for container_name in self.replica_names(name, service) {
let path = format!(
"{API_PREFIX}/containers/{}/wait?condition=stopped",
crate::libpod::urlencoded(&container_name),
);
let code = self
.client
.post_empty_json::<i64>(&path)
.await
.map_err(ComposeError::Podman)?;
println!("{code}");
if code != 0 {
last_nonzero = code;
}
}
}
if last_nonzero != 0 {
return Err(ComposeError::RunExited(last_nonzero));
}
Ok(())
}

/// Stop running containers without removing them.
///
/// Services are stopped in reverse dependency order. If `target_services`
Expand Down
1 change: 1 addition & 0 deletions internal/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
mod build;
mod config_digests;
pub use config_digests::resolve_image_digests;
mod commit_export;
mod container;
mod copy;
pub use build::{BuildOptions, PullOptions, PushOptions};
Expand Down
2 changes: 2 additions & 0 deletions tests/engine_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ mod group_a3;
mod group_a4;
#[path = "engine_integration/group_b1.rs"]
mod group_b1;
#[path = "engine_integration/niche.rs"]
mod niche;
#[path = "engine_integration/run_flags.rs"]
mod run_flags;

Expand Down
102 changes: 102 additions & 0 deletions tests/engine_integration/niche.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Niche-command CLI integration tests (wait/export/commit), split for the
//! source line limit.
use std::fs;
use std::process::Command;
use tempfile::tempdir;

use super::*;

fn run(args: &[&str]) -> std::process::Output {
Command::new(bin()).args(args).output().unwrap()
}
#[tokio::test]
async fn cli_wait_prints_exit_code() {
if super::podman().await.is_none() {
return;
}
let dir = tempdir().unwrap();
let proj = format!("t{}-wait", std::process::id());
let compose = dir.path().join("docker-compose.yml");
fs::write(
&compose,
"services:\n job:\n image: alpine:latest\n command: [\"sh\", \"-c\", \"exit 0\"]\n",
)
.unwrap();
let c = compose.to_str().unwrap();

run(&["-f", c, "-p", &proj, "up", "-d"]);
let out = run(&["-f", c, "-p", &proj, "wait", "job"]);
assert!(out.status.success(), "wait failed: {:?}", out.stderr);
assert!(
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| l.trim() == "0"),
"wait must print the exit code 0"
);
run(&["-f", c, "-p", &proj, "down"]);
}

#[tokio::test]
async fn cli_export_writes_tar() {
if super::podman().await.is_none() {
return;
}
let dir = tempdir().unwrap();
let proj = format!("t{}-export", std::process::id());
let compose = dir.path().join("docker-compose.yml");
fs::write(
&compose,
"services:\n web:\n image: alpine:latest\n command: [\"sleep\", \"infinity\"]\n",
)
.unwrap();
let c = compose.to_str().unwrap();
let tar = dir.path().join("rootfs.tar");

run(&["-f", c, "-p", &proj, "up", "-d"]);
let out = run(&[
"-f",
c,
"-p",
&proj,
"export",
"-o",
tar.to_str().unwrap(),
"web",
]);
run(&["-f", c, "-p", &proj, "down"]);
assert!(out.status.success(), "export failed: {:?}", out.stderr);
let meta = fs::metadata(&tar).expect("export must create the tar file");
assert!(meta.len() > 0, "exported tar must be non-empty");
}

#[tokio::test]
async fn cli_commit_creates_image() {
if super::podman().await.is_none() {
return;
}
let dir = tempdir().unwrap();
let proj = format!("t{}-commit", std::process::id());
let img = format!("podup-commit-test-{}:latest", std::process::id());
let compose = dir.path().join("docker-compose.yml");
fs::write(
&compose,
"services:\n web:\n image: alpine:latest\n command: [\"sleep\", \"infinity\"]\n",
)
.unwrap();
let c = compose.to_str().unwrap();

run(&["-f", c, "-p", &proj, "up", "-d"]);
let out = run(&["-f", c, "-p", &proj, "commit", "web", &img]);
run(&["-f", c, "-p", &proj, "down"]);
let exists = std::process::Command::new("podman")
.args(["image", "exists", &img])
.status()
.map(|s| s.success())
.unwrap_or(false);
// Clean up the committed image regardless.
let _ = std::process::Command::new("podman")
.args(["rmi", "-f", &img])
.output();
assert!(out.status.success(), "commit failed: {:?}", out.stderr);
assert!(exists, "commit must create the target image");
}
Loading