build: use image Created timestamp as source date epoch fallback

When splitting an existing image and no SOURCE_DATE_EPOCH is provided,
use the Created timestamp from the image inspect output instead of the
current time. This makes re-splitting an existing image reproducible
without requiring the caller to explicitly set SOURCE_DATE_EPOCH.

Assisted-by: Claude Opus 4.6
This commit is contained in:
Jonathan Lebon 2026-02-26 16:24:45 -05:00
parent 557c974e53
commit 6905ef92ba
3 changed files with 71 additions and 20 deletions

View file

@ -147,11 +147,6 @@ impl BuildArgs {
pub fn run(args: &BuildArgs) -> Result<()> {
tracing::info!(rootfs = %args.rootfs, "starting build");
let created_epoch = args
.source_date_epoch
.map_or_else(utils::get_current_epoch, Ok)?;
tracing::debug!(created_epoch, "using timestamp");
// load base config from file, string, or use empty default
let parsed = if let Some(path) = &args.config {
tracing::debug!(source = %path, "loading config from file");
@ -167,9 +162,12 @@ pub fn run(args: &BuildArgs) -> Result<()> {
config: oci_image::Config::default(),
annotations: HashMap::new(),
architecture: None,
created: None,
}
};
let created_epoch = resolve_created_epoch(args.source_date_epoch, &parsed)?;
let architecture = args.arch.as_deref().or(parsed.architecture.as_deref());
// get the current arch if not provided, but even if provided, this
// normalizes the arch so that `--arch x86_64` also works
@ -252,6 +250,7 @@ fn parse_config(json_str: &str) -> Result<ParsedConfig> {
config,
annotations: HashMap::new(),
architecture: None,
created: None,
}),
}
}
@ -266,6 +265,8 @@ struct ParsedConfig {
annotations: HashMap<String, String>,
#[serde(rename = "Architecture")]
architecture: Option<String>,
#[serde(rename = "Created")]
created: Option<String>,
}
/// Config input format - either direct OCI config or podman/docker inspect output.
@ -280,6 +281,25 @@ enum ConfigInput {
Direct(oci_image::Config),
}
/// Resolve the created epoch from CLI, config, or current time.
///
/// Priority: explicit `--source-date-epoch` > `Created` from image inspect > current time.
fn resolve_created_epoch(source_date_epoch: Option<u64>, parsed: &ParsedConfig) -> Result<u64> {
if let Some(epoch) = source_date_epoch {
tracing::debug!(epoch, "using source date epoch from CLI/env");
Ok(epoch)
} else if let Some(created) = &parsed.created {
let epoch =
utils::parse_rfc3339_epoch(created).context("parsing image created timestamp")?;
tracing::debug!(epoch, created = %created, "using build date from image config");
Ok(epoch)
} else {
let epoch = utils::get_current_epoch()?;
tracing::debug!(epoch, "using current time as source date epoch");
Ok(epoch)
}
}
/// Build the OCI image configuration from CLI args and a parsed config.
fn build_image_config(
args: &BuildArgs,
@ -525,7 +545,8 @@ mod tests {
"Annotations": {
"org.example.key": "value"
},
"Architecture": "arm64"
"Architecture": "arm64",
"Created": "2023-11-14T22:13:20Z"
}]"#;
let parsed = parse_config(json).unwrap();
@ -542,6 +563,7 @@ mod tests {
Some(&"value".to_string())
);
assert_eq!(parsed.architecture, Some("arm64".to_string()));
assert_eq!(parsed.created, Some("2023-11-14T22:13:20Z".to_string()));
}
#[test]

View file

@ -9,6 +9,13 @@ pub fn get_current_epoch() -> Result<u64> {
.map(|d| d.as_secs())
}
/// Parse an RFC 3339 timestamp string into a Unix epoch (seconds).
pub fn parse_rfc3339_epoch(s: &str) -> Result<u64> {
let dt = chrono::DateTime::parse_from_rfc3339(s)
.with_context(|| format!("parsing RFC 3339 timestamp: {s}"))?;
u64::try_from(dt.timestamp()).with_context(|| format!("timestamp is negative: {s}"))
}
/// Format a byte count as a human-readable string using binary units.
pub fn format_size(bytes: u64) -> String {
const KIB: f64 = 1024.0;
@ -44,6 +51,17 @@ pub fn get_goarch(arch: Option<&str>) -> &str {
mod tests {
use super::*;
#[test]
fn test_parse_rfc3339_epoch() {
assert_eq!(
parse_rfc3339_epoch("2023-11-14T22:13:20Z").unwrap(),
1700000000
);
assert_eq!(parse_rfc3339_epoch("1970-01-01T00:00:00Z").unwrap(), 0);
assert!(parse_rfc3339_epoch("not-a-date").is_err());
assert!(parse_rfc3339_epoch("1969-12-31T23:59:59Z").is_err());
}
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0 B");

View file

@ -1,5 +1,5 @@
#!/bin/bash
# Test that chunked images are reproducible when built with SOURCE_DATE_EPOCH.
# Test that chunked images are reproducible.
set -xeuo pipefail
shopt -s inherit_errexit
@ -17,23 +17,34 @@ trap cleanup EXIT
podman pull "${SOURCE_IMAGE}"
CHUNKAH_CONFIG_STR=$(podman inspect "${SOURCE_IMAGE}")
# run chunkah twice on the same rootfs with a fixed SOURCE_DATE_EPOCH
assert_archives_identical() {
local sum1 sum2
sum1=$(sha256sum out1.ociarchive | cut -d' ' -f1)
sum2=$(sha256sum out2.ociarchive | cut -d' ' -f1)
if [[ "${sum1}" != "${sum2}" ]]; then
echo "ERROR: OCI archives differ between builds"
echo "Build 1: ${sum1}"
echo "Build 2: ${sum2}"
if command -v diffoscope &>/dev/null; then
diffoscope out1.ociarchive out2.ociarchive || true
fi
exit 1
fi
}
# Test 1: reproducible with explicit SOURCE_DATE_EPOCH
for i in 1 2; do
podman run --rm --mount=type=image,src="${SOURCE_IMAGE}",target=/chunkah \
-e CHUNKAH_CONFIG_STR="${CHUNKAH_CONFIG_STR}" \
-e SOURCE_DATE_EPOCH=1700000000 \
"${CHUNKAH_IMG:?}" build -v > "out${i}.ociarchive"
done
assert_archives_identical
# the two OCI archives should be byte-identical
sha1=$(sha256sum out1.ociarchive | cut -d' ' -f1)
sha2=$(sha256sum out2.ociarchive | cut -d' ' -f1)
if [[ "${sha1}" != "${sha2}" ]]; then
echo "ERROR: OCI archives differ between builds"
echo "Build 1: ${sha1}"
echo "Build 2: ${sha2}"
if command -v diffoscope &>/dev/null; then
diffoscope out1.ociarchive out2.ociarchive || true
fi
exit 1
fi
# Test 2: reproducible without SOURCE_DATE_EPOCH (uses image's Created timestamp)
for i in 1 2; do
podman run --rm --mount=type=image,src="${SOURCE_IMAGE}",target=/chunkah \
-e CHUNKAH_CONFIG_STR="${CHUNKAH_CONFIG_STR}" \
"${CHUNKAH_IMG:?}" build -v > "out${i}.ociarchive"
done
assert_archives_identical