Skip to content

Commit 53cdc0f

Browse files
committed
feat(cli): build flags --no-cache/--pull/--build-arg/-q
docker compose build accepts --no-cache, --pull, --build-arg KEY=VAL, and -q/--quiet. podup build took only service names. Add the flags, threaded via a new BuildOptions struct: each augments the per-service build: config (forces no-cache/pull on, overrides build.args on conflict) and -q suppresses build output. Non-breaking: the existing Engine::build_all is kept as a default-options wrapper; overrides go through Engine::build_all_with_options (mirroring up/up_with_options). BuildKit-only flags (--builder/--sbom/--provenance/--ssh) stay out of scope; --push is tracked separately under #410. Closes #414. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
1 parent 9a00471 commit 53cdc0f

10 files changed

Lines changed: 233 additions & 85 deletions

File tree

internal/cli.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ pub(crate) enum Commands {
106106
},
107107
/// Build or rebuild service images.
108108
Build {
109+
/// Do not use cache when building the image.
110+
#[arg(long)]
111+
no_cache: bool,
112+
/// Always attempt to pull a newer version of the base image.
113+
#[arg(long)]
114+
pull: bool,
115+
/// Set build-time variables (KEY=VAL); may be repeated.
116+
#[arg(long = "build-arg")]
117+
build_arg: Vec<String>,
118+
/// Suppress the build output.
119+
#[arg(short, long)]
120+
quiet: bool,
109121
/// Build only these services.
110122
#[arg(trailing_var_arg = true)]
111123
services: Vec<String>,

internal/engine/build/mod.rs

Lines changed: 46 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
//! passed as the `target=` query parameter — the full Dockerfile is always sent.
77
88
mod context;
9+
mod pull;
910

1011
use bytes::Bytes;
1112
use futures_util::StreamExt;
12-
use tracing::{debug, info, warn};
13+
use tracing::{info, warn};
1314

1415
use crate::compose::types::{BuildConfig, Service};
1516
use crate::error::{ComposeError, Result};
16-
use crate::libpod::types::image::{BuildOutput, ImagePullProgress};
17+
use crate::libpod::types::image::BuildOutput;
1718
use crate::libpod::urlencoded;
1819
use crate::libpod::API_PREFIX;
1920
use crate::size;
@@ -26,53 +27,22 @@ use super::Engine;
2627
/// specs (`id=NAME,src=ENTRY`) for the libpod build endpoint.
2728
type ResolvedBuildSecrets = (Vec<(String, Vec<u8>)>, Vec<String>);
2829

29-
impl Engine {
30-
pub(super) async fn pull_image(&self, service: &Service) -> Result<()> {
31-
let image = match &service.image {
32-
Some(img) => img.clone(),
33-
None => return Ok(()),
34-
};
35-
36-
info!("pulling {image}");
37-
38-
let requested = service.pull_policy.as_deref();
39-
let pull_policy = libpod_pull_policy(requested).unwrap_or_else(|| {
40-
warn!(
41-
"unknown pull_policy '{}', defaulting to 'missing'",
42-
requested.unwrap_or_default()
43-
);
44-
"missing"
45-
});
46-
let mut query = format!("reference={}&policy={}", urlencoded(&image), pull_policy);
47-
if let Some(platform) = &service.platform {
48-
query.push_str(&format!("&platform={}", urlencoded(platform)));
49-
}
50-
51-
let path = format!("{API_PREFIX}/images/pull?{query}");
52-
let resp = self
53-
.client
54-
.post_empty_stream(&path)
55-
.await
56-
.map_err(ComposeError::Podman)?;
57-
let mut stream = crate::libpod::parse_json_lines::<ImagePullProgress>(resp.into_body());
58-
59-
while let Some(result) = stream.next().await {
60-
match result {
61-
Ok(progress) => {
62-
if !progress.stream.is_empty() {
63-
debug!("{}", progress.stream.trim_end());
64-
}
65-
if !progress.error.is_empty() {
66-
warn!("pull error: {}", progress.error);
67-
}
68-
}
69-
Err(e) => warn!("pull warning: {e}"),
70-
}
71-
}
72-
73-
Ok(())
74-
}
30+
/// `docker compose build`-style CLI overrides. Each augments (never weakens)
31+
/// the per-service `build:` config: a flag forces the behaviour on even when
32+
/// the compose file leaves it off.
33+
#[derive(Default, Clone)]
34+
pub struct BuildOptions {
35+
/// Force a cache-less build (`--no-cache`).
36+
pub no_cache: bool,
37+
/// Always attempt to pull a newer base image (`--pull`).
38+
pub pull: bool,
39+
/// Extra build args (`KEY=VAL`); override the compose `build.args` on conflict.
40+
pub build_args: Vec<String>,
41+
/// Suppress build output (`-q/--quiet`).
42+
pub quiet: bool,
43+
}
7544

45+
impl Engine {
7646
/// Build (or rebuild) images for services that have a `build:` block.
7747
///
7848
/// If `target_services` is empty, every service with a build config is built.
@@ -81,6 +51,18 @@ impl Engine {
8151
&self,
8252
file: &crate::compose::types::ComposeFile,
8353
target_services: &[String],
54+
) -> Result<()> {
55+
self.build_all_with_options(file, target_services, &BuildOptions::default())
56+
.await
57+
}
58+
59+
/// Build service images with `docker compose build`-style overrides
60+
/// (`--no-cache`, `--pull`, `--build-arg`, `--quiet`).
61+
pub async fn build_all_with_options(
62+
&self,
63+
file: &crate::compose::types::ComposeFile,
64+
target_services: &[String],
65+
opts: &BuildOptions,
8466
) -> Result<()> {
8567
let names: Vec<String> = if target_services.is_empty() {
8668
file.services.keys().cloned().collect()
@@ -96,7 +78,7 @@ impl Engine {
9678
for name in &names {
9779
let service = &file.services[name];
9880
if service.build.is_some() {
99-
self.build_service(name, service, file).await?;
81+
self.build_service(name, service, file, opts).await?;
10082
}
10183
}
10284
Ok(())
@@ -107,6 +89,7 @@ impl Engine {
10789
service_name: &str,
10890
service: &Service,
10991
file: &crate::compose::types::ComposeFile,
92+
opts: &BuildOptions,
11093
) -> Result<()> {
11194
let build = match &service.build {
11295
Some(b) => b,
@@ -178,6 +161,16 @@ impl Engine {
178161
let value = v.unwrap_or_else(|| std::env::var(&k).unwrap_or_default());
179162
build_args.insert(k, value);
180163
}
164+
// CLI `--build-arg KEY=VAL` overrides the compose `build.args`. A bare
165+
// `KEY` (no `=`) takes its value from the process environment, matching
166+
// docker compose.
167+
for entry in &opts.build_args {
168+
let (k, v) = match entry.split_once('=') {
169+
Some((k, v)) => (k.to_string(), v.to_string()),
170+
None => (entry.clone(), std::env::var(entry).unwrap_or_default()),
171+
};
172+
build_args.insert(k, v);
173+
}
181174

182175
let mut labels: std::collections::HashMap<String, String> =
183176
std::collections::HashMap::new();
@@ -227,10 +220,10 @@ impl Engine {
227220
let mut qs = format!(
228221
"t={}&rm=true&nocache={}",
229222
urlencoded(&tag),
230-
build.no_cache()
223+
build.no_cache() || opts.no_cache
231224
);
232225
qs.push_str(&format!("&dockerfile={}", urlencoded(&dockerfile_name)));
233-
if build.pull() {
226+
if build.pull() || opts.pull {
234227
qs.push_str("&pull=true");
235228
}
236229
if let Some(p) = &platform {
@@ -295,7 +288,7 @@ impl Engine {
295288
while let Some(result) = stream.next().await {
296289
match result {
297290
Ok(output) => {
298-
if !output.stream.is_empty() {
291+
if !opts.quiet && !output.stream.is_empty() {
299292
print!("{}", output.stream);
300293
}
301294
if let Some(err) = output.error_detail.and_then(|e| e.message) {
@@ -385,39 +378,11 @@ fn is_remote_context(context: &str) -> bool {
385378
context.contains("://") || context.starts_with("git@")
386379
}
387380

388-
/// Map a compose `pull_policy:` value to the libpod images/pull `policy`
389-
/// parameter. `if_not_present` is the spec alias for `missing`; `build` falls
390-
/// back to `missing` here (its build behavior is handled by the caller). Returns
391-
/// `None` for an unrecognized value so the caller can warn and default.
392-
pub(super) fn libpod_pull_policy(policy: Option<&str>) -> Option<&'static str> {
393-
match policy {
394-
Some("always") => Some("always"),
395-
Some("newer") => Some("newer"),
396-
Some("never") => Some("never"),
397-
None | Some("missing") | Some("if_not_present") | Some("build") => Some("missing"),
398-
Some(_) => None,
399-
}
400-
}
401-
402381
#[cfg(test)]
403382
mod tests {
404-
use super::{is_remote_context, libpod_pull_policy, Engine};
383+
use super::{is_remote_context, Engine};
405384
use crate::libpod::Client;
406385

407-
#[test]
408-
fn pull_policy_maps_every_spec_value() {
409-
assert_eq!(libpod_pull_policy(Some("always")), Some("always"));
410-
assert_eq!(libpod_pull_policy(Some("newer")), Some("newer"));
411-
assert_eq!(libpod_pull_policy(Some("never")), Some("never"));
412-
assert_eq!(libpod_pull_policy(Some("missing")), Some("missing"));
413-
// `if_not_present` is the spec alias for `missing`.
414-
assert_eq!(libpod_pull_policy(Some("if_not_present")), Some("missing"));
415-
assert_eq!(libpod_pull_policy(Some("build")), Some("missing"));
416-
assert_eq!(libpod_pull_policy(None), Some("missing"));
417-
// Unknown values are reported (None) so the caller warns.
418-
assert_eq!(libpod_pull_policy(Some("bogus")), None);
419-
}
420-
421386
fn engine(base: std::path::PathBuf) -> Engine {
422387
Engine::with_base_dir(Client::new("/nonexistent.sock"), "p".into(), base)
423388
}

internal/engine/build/pull.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Image pull from a registry (the non-build half of image acquisition).
2+
3+
use futures_util::StreamExt;
4+
use tracing::{debug, info, warn};
5+
6+
use crate::compose::types::Service;
7+
use crate::error::{ComposeError, Result};
8+
use crate::libpod::types::image::ImagePullProgress;
9+
use crate::libpod::{urlencoded, API_PREFIX};
10+
11+
use super::super::Engine;
12+
13+
impl Engine {
14+
pub(in crate::engine) async fn pull_image(&self, service: &Service) -> Result<()> {
15+
let image = match &service.image {
16+
Some(img) => img.clone(),
17+
None => return Ok(()),
18+
};
19+
20+
info!("pulling {image}");
21+
22+
let requested = service.pull_policy.as_deref();
23+
let pull_policy = libpod_pull_policy(requested).unwrap_or_else(|| {
24+
warn!(
25+
"unknown pull_policy '{}', defaulting to 'missing'",
26+
requested.unwrap_or_default()
27+
);
28+
"missing"
29+
});
30+
let mut query = format!("reference={}&policy={}", urlencoded(&image), pull_policy);
31+
if let Some(platform) = &service.platform {
32+
query.push_str(&format!("&platform={}", urlencoded(platform)));
33+
}
34+
35+
let path = format!("{API_PREFIX}/images/pull?{query}");
36+
let resp = self
37+
.client
38+
.post_empty_stream(&path)
39+
.await
40+
.map_err(ComposeError::Podman)?;
41+
let mut stream = crate::libpod::parse_json_lines::<ImagePullProgress>(resp.into_body());
42+
43+
while let Some(result) = stream.next().await {
44+
match result {
45+
Ok(progress) => {
46+
if !progress.stream.is_empty() {
47+
debug!("{}", progress.stream.trim_end());
48+
}
49+
if !progress.error.is_empty() {
50+
warn!("pull error: {}", progress.error);
51+
}
52+
}
53+
Err(e) => warn!("pull warning: {e}"),
54+
}
55+
}
56+
57+
Ok(())
58+
}
59+
}
60+
61+
/// Map a compose `pull_policy:` value to the libpod images/pull `policy`
62+
/// parameter. `if_not_present` is the spec alias for `missing`; `build` falls
63+
/// back to `missing` here (its build behavior is handled by the caller). Returns
64+
/// `None` for an unrecognized value so the caller can warn and default.
65+
pub(in crate::engine) fn libpod_pull_policy(policy: Option<&str>) -> Option<&'static str> {
66+
match policy {
67+
Some("always") => Some("always"),
68+
Some("newer") => Some("newer"),
69+
Some("never") => Some("never"),
70+
None | Some("missing") | Some("if_not_present") | Some("build") => Some("missing"),
71+
Some(_) => None,
72+
}
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::libpod_pull_policy;
78+
79+
#[test]
80+
fn pull_policy_maps_every_spec_value() {
81+
assert_eq!(libpod_pull_policy(Some("always")), Some("always"));
82+
assert_eq!(libpod_pull_policy(Some("newer")), Some("newer"));
83+
assert_eq!(libpod_pull_policy(Some("never")), Some("never"));
84+
assert_eq!(libpod_pull_policy(Some("missing")), Some("missing"));
85+
// `if_not_present` is the spec alias for `missing`.
86+
assert_eq!(libpod_pull_policy(Some("if_not_present")), Some("missing"));
87+
assert_eq!(libpod_pull_policy(Some("build")), Some("missing"));
88+
assert_eq!(libpod_pull_policy(None), Some("missing"));
89+
// Unknown values are reported (None) so the caller warns.
90+
assert_eq!(libpod_pull_policy(Some("bogus")), None);
91+
}
92+
}

internal/engine/lifecycle/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,10 @@ impl Engine {
214214

215215
let policy = service.pull_policy.as_deref().unwrap_or("missing");
216216
match (service.build.is_some(), policy) {
217-
(true, _) => self.build_service(name, service, file).await?,
217+
(true, _) => {
218+
self.build_service(name, service, file, &crate::engine::BuildOptions::default())
219+
.await?
220+
}
218221
(false, "never") => {}
219222
(false, _) => self.pull_image(service).await?,
220223
}

internal/engine/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
mod build;
66
mod container;
77
mod copy;
8+
pub use build::BuildOptions;
89
pub use lifecycle::RunOptions;
910
pub use lock::ProjectLock;
1011
pub use query::ExecOptions;

internal/engine/watch/mod.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,13 @@ impl Engine {
256256
None => return Ok(()),
257257
};
258258
info!("rebuilding {service_name}");
259-
self.build_service(service_name, service, file).await?;
259+
self.build_service(
260+
service_name,
261+
service,
262+
file,
263+
&crate::engine::BuildOptions::default(),
264+
)
265+
.await?;
260266
let container_name = self.container_name(service_name, service);
261267
self.create_and_start(&container_name, service_name, service, file)
262268
.await

internal/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pub use compose::{
2828
collect_diagnostics, parse_file, parse_file_with_env_files, parse_files_with_env_files,
2929
parse_str, parse_str_raw, resolve_levels, resolve_order,
3030
};
31-
pub use engine::{is_safe_project_name, Engine, ExecOptions, ProjectLock, RunOptions};
31+
pub use engine::{
32+
is_safe_project_name, BuildOptions, Engine, ExecOptions, ProjectLock, RunOptions,
33+
};
3234
pub use error::{ComposeError, Result};
3335
pub use libpod::Client;
3436

internal/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,26 @@ async fn run() -> podup::Result<()> {
335335
services,
336336
timeout: _,
337337
} => engine.stop(&file, &services).await?,
338-
Commands::Build { services } => engine.build_all(&file, &services).await?,
338+
Commands::Build {
339+
no_cache,
340+
pull,
341+
build_arg,
342+
quiet,
343+
services,
344+
} => {
345+
engine
346+
.build_all_with_options(
347+
&file,
348+
&services,
349+
&podup::BuildOptions {
350+
no_cache,
351+
pull,
352+
build_args: build_arg,
353+
quiet,
354+
},
355+
)
356+
.await?
357+
}
339358
Commands::Rm { force, services } => engine.rm(&file, &services, force).await?,
340359
Commands::Kill { signal, services } => engine.kill(&file, &services, &signal).await?,
341360
Commands::Pause { services } => engine.pause(&file, &services).await?,

0 commit comments

Comments
 (0)