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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "podup"
version = "5.10.0"
version = "5.10.1"
edition = "2021"
rust-version = "1.85"
# podup is a binary that happens to build a library, not a library that ships a
Expand Down
14 changes: 14 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
podup (5.10.1) unstable; urgency=medium

* podup now talks to Podman as a libpod client. Its request lines were
written in absolute form, which made Podman apply the Docker-compatible
behaviour to every call; the behaviour podup relied on is now asked for
explicitly (stop and restart timeouts, anonymous volume removal, the
wait after a kill, `cp` ownership, `top` columns, logs of TTY
containers, `events` names, build layer cache, image format and names).
* A short image name in a Containerfile `FROM` is resolved through
`registries.conf`, as `podman build` does, instead of being forced to
Docker Hub.

-- Jaro-c <75870284+Jaro-c@users.noreply.github.com> Fri, 25 Sep 2026 05:36:18 -0500

podup (5.10.0) unstable; urgency=medium

* `up` orders a service after the services it references through
Expand Down
110 changes: 110 additions & 0 deletions internal/engine/build/body_plan.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! Decide how the build context reaches libpod: a remote `remote=` URL
//! (no body), or a streamed context tar.
//!
//! Split out of `service.rs` so the dispatching loop there stays under
//! the source-line limit. The companion to
//! [`super::Engine::build_service`]: the body plan, the resolved
//! Dockerfile name and the in-tar build secret specs come back from
//! one call and feed straight into the `POST /libpod/build?` below.

use std::path::PathBuf;

use crate::compose::types::{BuildConfig, ComposeFile, Service};
use crate::error::{ComposeError, Result};

use super::context::INLINE_DOCKERFILE_NAME;
use super::stream::ContextSource;
use super::tags::is_remote_context;
use super::{BodyPlan, Engine};

/// What the request body carries, plus the companion values needed
/// to build it: the resolved Dockerfile name and the in-tar build
/// secret specs.
pub(in crate::engine) struct BuildPlan {
/// Either `Empty` (remote context, no body) or `Stream { ... }`
/// for the local-directory tar path.
pub(in crate::engine) body: BodyPlan,
/// Dockerfile name as the libpod endpoint should see it.
pub(in crate::engine) dockerfile: String,
/// In-tar secret specs the build endpoint should mount.
pub(in crate::engine) secrets: Vec<String>,
}

/// Decide the build request body and the Dockerfile name.
///
/// Remote (`git://`/`https://`/`git@`) contexts are cloned server-side
/// by Podman via the `remote` query parameter, so there is no local
/// directory to tar. Tar-only features (inline Dockerfile, in-tar
/// build secrets) do not apply and are warned about. Local contexts
/// pre-validate the directory exists so a missing context surfaces
/// here, with the resolved path, rather than as a bare `io error`
/// from the tar walk; resolve `build.secrets` to in-tar files so the
/// libpod endpoint sees the form it expects.
#[allow(clippy::too_many_arguments)]
pub(in crate::engine) fn plan_build(
engine: &Engine,
service_name: &str,
_service: &Service,
file: &ComposeFile,
build: &BuildConfig,
context_str: &str,
) -> Result<BuildPlan> {
if is_remote_context(context_str) {
tracing::info!("building from remote context {context_str}");
if build.dockerfile_inline().is_some() {
tracing::warn!("build.dockerfile_inline is ignored for a remote build context");
}
if !build.secrets().is_empty() {
tracing::warn!("build.secrets are ignored for a remote build context");
}
let dockerfile = build.dockerfile().unwrap_or("Dockerfile").to_string();
return Ok(BuildPlan {
body: BodyPlan::Empty,
dockerfile,
secrets: Vec::new(),
});
}
let context_path: PathBuf = engine.base_dir.join(context_str);
if let Err(e) = std::fs::metadata(&context_path) {
return Err(ComposeError::BuildContext {
service: service_name.to_string(),
path: context_path.display().to_string(),
source: e,
});
}
tracing::info!("building from {}", context_path.display());

let (secret_files, secrets) = engine.resolve_build_secrets(build, file)?;

// The context tar is streamed to the socket (see the POST below),
// never buffered, so a multi-gigabyte context doesn't inflate RSS.
// Decide the source and the dockerfile name here; the blocking tar
// walk happens while the request body is being sent.
let (source, dockerfile) = match build.dockerfile_inline() {
Some(inline) => (
ContextSource::Inline(inline.to_string()),
INLINE_DOCKERFILE_NAME.to_string(),
),
None => {
let df = match build.dockerfile() {
Some(name) => name.to_string(),
None if !context_path.join("Dockerfile").is_file()
&& context_path.join("Containerfile").is_file() =>
{
"Containerfile".to_string()
}
None => "Dockerfile".to_string(),
};
(ContextSource::Dockerfile(df.clone()), df)
}
};
Ok(BuildPlan {
body: BodyPlan::Stream {
context: context_path,
source,
secrets: secret_files,
},
dockerfile,
secrets,
})
}
109 changes: 109 additions & 0 deletions internal/engine/build/build_board_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

#[cfg(unix)]
mod tests {
use std::sync::{Arc, Mutex};

use crate::engine::fake_podman::{self, FakeReply};
use crate::engine::Engine;
use crate::ui::progress::capture::Capture;
Expand Down Expand Up @@ -234,6 +236,113 @@ services:
);
}
}

/// A build with no `image:` field falls back to `<project>-<service>:latest`
/// as its primary tag. The print paths (the `Image` board row, the
/// `Building`/`Built` verbs, the `STEP n/m:` line prefix, the
/// `apply_extra_tags` skip) keep using that un-normalised form; only
/// the `t=` parameter on `/libpod/build` (and the source-side path of
/// `POST /libpod/images/{}/tag`) carries the docker.io canonical
/// form. Without the split, every board would get a second
/// `Image docker.io/library/proj-app:latest` row inserted on top of
/// the seeded `Image proj-app:latest` row (#1914).
#[tokio::test]
async fn build_row_keeps_unnormalised_tag_while_query_carries_the_canonical_form() {
// No `image:` so `primary_build_tag` falls back to
// `<project>-<service>:latest`. The fake returns a one-chunk
// success stream so `build_service` reaches the success path.
let dir = tempfile::tempdir().expect("tempdir");
let ctx_path = dir.path().to_path_buf();
std::fs::write(
ctx_path.join("Dockerfile"),
b"FROM docker.io/library/alpine:3.20\nRUN echo hi\nCMD [\"echo\",\"hi\"]\n",
)
.expect("write Dockerfile");
let requests: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let closure_requests = requests.clone();
let fake = fake_podman::start_replying(move |method, target| {
closure_requests
.lock()
.unwrap()
.push(format!("{method} {target}"));
if method == "POST" && target.contains("/build?") {
FakeReply::ChunkedEnd(vec![
"{\"stream\":\"--> sha256:1111111111111111111111111111111111111111111111111111111111111111\\n\"}\n".to_string(),
"{\"stream\":\"Successfully tagged proj-app:latest\\n\"}\n".to_string(),
])
} else if method == "POST" && target.contains("/images/") && target.contains("/tag") {
FakeReply::Body(200, String::new())
} else {
FakeReply::Body(404, r#"{"message":"not found"}"#.to_string())
}
});
let engine = Engine::with_base_dir(fake.client(), "proj".into(), ctx_path);

let file = crate::parse_str("services:\n app:\n build:\n context: .\n")
.expect("the fixture parses");

let capture = Capture::start();
engine
.build_all_with_options(&file, &[], &crate::engine::BuildOptions::default())
.await
.expect("a build the fake accepts succeeds");

// Print path: the seeded and worked rows carry the
// un-normalised `<project>-<service>:latest` name. The bug would
// insert a second `Image docker.io/library/proj-app:latest` row
// on top of the seeded one and report both.
let names = capture.names();
assert_eq!(
names,
vec!["proj-app:latest"],
"the printed row name must be the un-normalised <project>-<service>:latest \
and never a second row under the docker.io canonical name: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("docker.io")),
"no print path may show the docker.io canonical form: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("localhost")),
"no print path may show the libpod `localhost/...` form: {names:?}"
);

// Verb path: every transition the board sees carries the same
// un-normalised name. A `Building` on `docker.io/library/...`
// would mean a second row opened under the wire name.
let verbs: Vec<(Kind, String, String)> = capture
.verbs()
.into_iter()
.filter(|(_, name, _)| name.contains("app") || name.contains("library"))
.collect();
for (kind, name, _verb) in &verbs {
assert_eq!(
name, "proj-app:latest",
"verb path used the wire tag instead of the un-normalised form: {kind:?} {name:?}"
);
}

// Wire path: the `t=` parameter on `/libpod/build` carries the
// docker.io canonical form. The compat build handler did this
// through `NormalizeToDockerHub`; the libpod path has to do it
// itself.
let requests = requests.lock().unwrap().clone();
let build_target = requests
.iter()
.find(|r| r.starts_with("POST ") && r.contains("/build?"))
.expect("a /build request was issued");
assert!(
build_target.contains("t=docker.io%2Flibrary%2Fproj-app%3Alatest"),
"the build query must carry the canonical docker.io name in t=: {build_target}"
);
// And never the un-normalised form on the wire - that is the
// exact regression this commit undoes.
assert!(
!build_target.contains("t=proj-app%3Alatest")
&& !build_target.contains("t=proj-app:latest"),
"the build query must not carry the un-normalised form in t=: {build_target}"
);
}
}

#[cfg(not(unix))]
Expand Down
76 changes: 76 additions & 0 deletions internal/engine/build/build_query_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,82 @@ mod tests {
);
}

/// `layers=true` must be present on the build query. The Docker
/// compat handler used to default `layers` to true; the libpod
/// handler defaults it to false. podup's user-facing behaviour
/// is "the second build of the same Containerfile reuses the
/// cache", which requires `layers=true`. Sent once, not twice
/// (Podman rejects a duplicate param).
#[tokio::test]
async fn build_query_carries_layers_true() {
let (_dir, _fake, engine, _ctx, requests) = start_capture("proj");

let file = crate::parse_str(
"services:\n app:\n image: proj/img:1\n build:\n context: .\n",
)
.unwrap();
engine
.build_all_with_options(&file, &[], &crate::engine::BuildOptions::default())
.await
.expect("a build the fake accepts succeeds");

let requests = requests.lock().unwrap().clone();
let target = build_target(&requests);
let query = target
.split_once('?')
.expect("the build target carries a query string");

let layers_count = exact_param_count(query.1, "layers=true");
assert_eq!(
layers_count, 1,
"the build query must carry `layers=true` exactly once, found {layers_count}: {query:?}"
);
}

/// `outputformat=application/vnd.docker.distribution.manifest.v2+json`
/// must be present on the build query. The libpod handler defaults
/// the manifest format to OCI, which does not reuse the layer cache
/// on a subsequent build of the same Containerfile and drops
/// `HEALTHCHECK` from the image config. Measured on 2026-09-24
/// against Podman 5.7.0 by building the same Containerfile twice
/// through `/v5.0.0/libpod/build`: `layers=true` alone prints zero
/// `Using cache` lines on the second build; the same query with
/// `outputformat=application/vnd.docker.distribution.manifest.v2+json`
/// appended prints two. Sent once, not twice (Podman rejects a
/// duplicate param).
#[tokio::test]
async fn build_query_carries_docker_distribution_outputformat() {
let (_dir, _fake, engine, _ctx, requests) = start_capture("proj");

let file = crate::parse_str(
"services:\n app:\n image: proj/img:1\n build:\n context: .\n",
)
.unwrap();
engine
.build_all_with_options(&file, &[], &crate::engine::BuildOptions::default())
.await
.expect("a build the fake accepts succeeds");

let requests = requests.lock().unwrap().clone();
let target = build_target(&requests);
let query = target
.split_once('?')
.expect("the build target carries a query string");

// The exact-once count pins the wire shape so a future change
// cannot accidentally send the parameter twice and have Podman
// reject the request.
let outputformat_count = exact_param_count(
query.1,
"outputformat=application%2Fvnd.docker.distribution.manifest.v2%2Bjson",
);
assert_eq!(
outputformat_count, 1,
"the build query must carry the docker-distribution outputformat exactly once, \
found {outputformat_count}: {query:?}"
);
}

/// The two labels are url-encoded into `key=value` form, just like every
/// other value in this query string. A label value containing characters
/// Podman's parser rejects when raw (`:`, `+`, `&`) must reach the
Expand Down
Loading
Loading