diff --git a/Cargo.lock b/Cargo.lock index 45399dec0..083d7ac75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,9 +689,9 @@ dependencies = [ [[package]] name = "containers-image-proxy" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ca6531917f9b250bf6a1af43603b2e083c192565774451411f9bf4f8bf8f2b" +checksum = "71a4f5afd361728fbc377e8ec4194040cbd733e9171ff6e35ab31a866ccef1a7" dependencies = [ "cap-std-ext", "futures-util", diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 386e2470a..1c385cdd5 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -195,7 +195,7 @@ fn get_sorted_type1_boot_entries_helper( pub(crate) async fn get_container_manifest_and_config( imgref: &String, ) -> Result { - let config = containers_image_proxy::ImageProxyConfig::default(); + let config = crate::deploy::new_proxy_config(); let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; let img = proxy diff --git a/crates/lib/src/boundimage.rs b/crates/lib/src/boundimage.rs index a515740bf..31b4e5fb3 100644 --- a/crates/lib/src/boundimage.rs +++ b/crates/lib/src/boundimage.rs @@ -121,7 +121,8 @@ pub(crate) fn query_bound_images(root: &Dir) -> Result> { impl ResolvedBoundImage { #[context("resolving bound image {}", src.image)] pub(crate) async fn from_image(src: &BoundImage) -> Result { - let proxy = containers_image_proxy::ImageProxy::new().await?; + let config = crate::deploy::new_proxy_config(); + let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?; let img = proxy .open_image(&format!("containers-storage:{}", src.image)) .await?; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 567ba8ddc..d07f190d0 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -26,7 +26,7 @@ use ostree_ext::composefs::fsverity; use ostree_ext::composefs::fsverity::FsVerityHashValue; use ostree_ext::composefs::splitstream::SplitStreamWriter; use ostree_ext::container as ostree_container; -use ostree_ext::containers_image_proxy::ImageProxyConfig; + use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; use ostree_ext::sysroot::SysrootLock; @@ -1551,7 +1551,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } => { let (_td_guard, repo) = new_temp_composefs_repo()?; - let mut proxycfg = ImageProxyConfig::default(); + let mut proxycfg = crate::deploy::new_proxy_config(); let image = if let Some(image) = image { image diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index cd90c64f8..766f7acae 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -32,6 +32,17 @@ use crate::utils::async_task_with_spinner; // TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; +/// Create an ImageProxyConfig with bootc's user agent prefix set. +/// +/// This allows registries to distinguish "image pulls for bootc client runs" +/// from other skopeo/containers-image users. +pub(crate) fn new_proxy_config() -> ostree_ext::containers_image_proxy::ImageProxyConfig { + ostree_ext::containers_image_proxy::ImageProxyConfig { + user_agent_prefix: Some(format!("bootc/{}", env!("CARGO_PKG_VERSION"))), + ..Default::default() + } +} + /// Set on an ostree commit if this is a derived commit const BOOTC_DERIVED_KEY: &str = "bootc.derived"; @@ -87,7 +98,7 @@ pub(crate) async fn new_importer( repo: &ostree::Repo, imgref: &ostree_container::OstreeImageReference, ) -> Result { - let config = Default::default(); + let config = new_proxy_config(); let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?; imp.require_bootable(); Ok(imp) @@ -460,7 +471,7 @@ pub(crate) async fn prepare_for_pull_unified( let ostree_imgref = OstreeImageReference::from(containers_storage_imgref); // Configure the importer to use bootc storage as an additional image store - let mut config = ostree_ext::containers_image_proxy::ImageProxyConfig::default(); + let mut config = new_proxy_config(); let mut cmd = Command::new("skopeo"); // Use the physical path to bootc storage from the Storage struct let storage_path = format!( @@ -1248,6 +1259,23 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> { mod tests { use super::*; + #[test] + fn test_new_proxy_config_user_agent() { + let config = new_proxy_config(); + let prefix = config + .user_agent_prefix + .expect("user_agent_prefix should be set"); + assert!( + prefix.starts_with("bootc/"), + "User agent should start with bootc/" + ); + // Verify the version is present (not just "bootc/") + assert!( + prefix.len() > "bootc/".len(), + "Version should be present after bootc/" + ); + } + #[test] fn test_switch_inplace() -> Result<()> { use cap_std::fs::DirBuilderExt; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 00a49581e..0270eda13 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -932,7 +932,7 @@ async fn install_container( } }; - let proxy_cfg = ostree_container::store::ImageProxyConfig::default(); + let proxy_cfg = crate::deploy::new_proxy_config(); (src_imageref, Some(proxy_cfg)) }; let src_imageref = ostree_container::OstreeImageReference { diff --git a/crates/ostree-ext/Cargo.toml b/crates/ostree-ext/Cargo.toml index 807131b56..cf1f607d6 100644 --- a/crates/ostree-ext/Cargo.toml +++ b/crates/ostree-ext/Cargo.toml @@ -41,7 +41,7 @@ xshell = { workspace = true, optional = true } # Crate-specific dependencies comfy-table = "7.1.1" -containers-image-proxy = "0.9.0" +containers-image-proxy = "0.9.1" flate2 = { features = ["zlib"], default-features = false, version = "1.0.20" } futures-util = "0.3.13" gvariant = "0.5.0" diff --git a/crates/xtask/src/tmt.rs b/crates/xtask/src/tmt.rs index 0dd4634aa..dbd98dac9 100644 --- a/crates/xtask/src/tmt.rs +++ b/crates/xtask/src/tmt.rs @@ -851,7 +851,7 @@ pub(crate) fn update_integration() -> Result<()> { // Define tests in order let mut tests = vec![]; - // Scan for test-*.nu and test-*.sh files in tmt/tests/booted/ + // Scan for test-*.nu, test-*.sh, and test-*.py files in tmt/tests/booted/ let booted_dir = Utf8Path::new("tmt/tests/booted"); for entry in std::fs::read_dir(booted_dir)? { @@ -862,10 +862,11 @@ pub(crate) fn update_integration() -> Result<()> { }; // Extract stem (filename without "test-" prefix and extension) - let Some(stem) = filename - .strip_prefix("test-") - .and_then(|s| s.strip_suffix(".nu").or_else(|| s.strip_suffix(".sh"))) - else { + let Some(stem) = filename.strip_prefix("test-").and_then(|s| { + s.strip_suffix(".nu") + .or_else(|| s.strip_suffix(".sh")) + .or_else(|| s.strip_suffix(".py")) + }) else { continue; }; @@ -894,16 +895,16 @@ pub(crate) fn update_integration() -> Result<()> { .with_context(|| format!("Failed to get relative path for {}", filename))?; // Determine test command based on file extension - let extension = if filename.ends_with(".nu") { - "nu" + let test_command = if filename.ends_with(".nu") { + format!("nu {}", relative_path.display()) } else if filename.ends_with(".sh") { - "bash" + format!("bash {}", relative_path.display()) + } else if filename.ends_with(".py") { + format!("python3 {}", relative_path.display()) } else { anyhow::bail!("Unsupported test file extension: {}", filename); }; - let test_command = format!("{} {}", extension, relative_path.display()); - // Check if test wants bind storage let try_bind_storage = metadata .extra diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 40ca2135e..3d00102a7 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -71,19 +71,19 @@ execute: test: - /tmt/tests/tests/test-22-logically-bound-install -/plan-23-usroverlay: - summary: Execute tests for bootc usrover +/plan-23-install-outside-container: + summary: Execute tests for installing outside of a container discover: how: fmf test: - - /tmt/tests/tests/test-23-usroverlay + - /tmt/tests/tests/test-23-install-outside-container -/plan-23-install-outside-container: - summary: Execute tests for installing outside of a container +/plan-23-usroverlay: + summary: Execute tests for bootc usrover discover: how: fmf test: - - /tmt/tests/tests/test-23-install-outside-container + - /tmt/tests/tests/test-23-usroverlay /plan-24-image-upgrade-reboot: summary: Execute local upgrade tests @@ -166,4 +166,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-33-bib-build + +/plan-34-user-agent: + summary: Verify bootc sends correct User-Agent header to registries + discover: + how: fmf + test: + - /tmt/tests/tests/test-34-user-agent # END GENERATED PLANS diff --git a/tmt/tests/booted/test-user-agent.py b/tmt/tests/booted/test-user-agent.py new file mode 100644 index 000000000..3eedd9d4b --- /dev/null +++ b/tmt/tests/booted/test-user-agent.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +# number: 34 +# tmt: +# summary: Verify bootc sends correct User-Agent header to registries +# duration: 10m +# +""" +Test that bootc sends the correct User-Agent header when pulling images. + +This test starts a mock HTTP registry server, configures it as an insecure +registry, and verifies that bootc's requests include "bootc/" in the User-Agent. + +Note: The --user-agent-prefix feature requires skopeo >= 1.21.0. If the +installed skopeo doesn't support it, this test will be skipped. + +Note: When insecure=true, container tools first attempt TLS then fall back to +plain HTTP. Our HTTP server will receive an invalid TLS handshake first, which +we ignore and continue serving. +""" + +import http.server +import json +import os +import subprocess +import sys +import threading + +# Global to capture the user agent +captured_user_agent = None +server_ready = threading.Event() +request_received = threading.Event() +# Global to store the dynamically allocated port +allocated_port = None + + +def skopeo_supports_user_agent_prefix() -> bool: + """Check if the installed skopeo supports --user-agent-prefix.""" + try: + result = subprocess.run( + ["skopeo", "--help"], + capture_output=True, + text=True, + timeout=10, + ) + return "--user-agent-prefix" in result.stdout + except Exception: + return False + + +def parse_os_release() -> dict[str, str]: + """Parse /usr/lib/os-release into a dictionary.""" + os_release = {} + try: + with open("/usr/lib/os-release") as f: + for line in f: + line = line.strip() + if "=" in line and not line.startswith("#"): + key, _, value = line.partition("=") + # Remove quotes if present + value = value.strip('"\'') + os_release[key] = value + except FileNotFoundError: + pass + return os_release + + +def distro_requires_user_agent_support() -> bool: + """Check if the current distro should have skopeo with --user-agent-prefix. + + Returns True if we're on a distro version that ships skopeo >= 1.21.0, + meaning the test must not be skipped. + """ + os_release = parse_os_release() + distro_id = os_release.get("ID", "") + version_id = os_release.get("VERSION_ID", "") + + try: + version = int(version_id) + except ValueError: + return False + + # Fedora 43+ ships skopeo 1.21.0+ + if distro_id == "fedora" and version >= 43: + return True + + return False + + +class RegistryHandler(http.server.BaseHTTPRequestHandler): + """Mock registry that captures User-Agent and returns 404.""" + + def do_GET(self): + global captured_user_agent + captured_user_agent = self.headers.get("User-Agent", "") + print(f"Request: {self.path}", flush=True) + print(f"User-Agent: {captured_user_agent}", flush=True) + + # Return a registry-style 404 + self.send_response(404) + self.send_header("Content-Type", "application/json") + body = json.dumps({ + "errors": [{"code": "NAME_UNKNOWN", "message": "repository not found"}] + }).encode() + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + # Signal that we received a valid HTTP request + request_received.set() + + def log_message(self, format, *args): + print(format % args, flush=True) + + +class TolerantHTTPServer(http.server.HTTPServer): + """HTTP server that ignores errors from TLS probe attempts.""" + + def handle_error(self, request, client_address): + # Silently ignore errors - these are typically TLS handshake attempts + # that we can't handle. The client will retry with plain HTTP. + print(f"Ignoring error from {client_address} (likely TLS probe)", flush=True) + + +def run_server(): + """Run the mock registry server on a dynamically allocated port.""" + global allocated_port + # Bind to port 0 to let the OS allocate an available port + server = TolerantHTTPServer(("127.0.0.1", 0), RegistryHandler) + allocated_port = server.server_address[1] + server.timeout = 30 + server_ready.set() + # Handle multiple requests - first few may be TLS probes + for _ in range(20): + server.handle_request() + if captured_user_agent: + # Got a valid HTTP request with User-Agent, we're done + break + + +def main(): + # Check if skopeo supports --user-agent-prefix + if not skopeo_supports_user_agent_prefix(): + # Get skopeo version for the skip message + try: + result = subprocess.run( + ["skopeo", "--version"], + capture_output=True, + text=True, + timeout=10, + ) + version = result.stdout.strip() + except Exception: + version = "unknown" + + # On distros that should have new enough skopeo, fail hard + if distro_requires_user_agent_support(): + print(f"ERROR: skopeo ({version}) does not support --user-agent-prefix", flush=True) + print("This distro should have skopeo >= 1.21.0", flush=True) + return 1 + + print(f"SKIP: skopeo ({version}) does not support --user-agent-prefix", flush=True) + print("This feature requires skopeo >= 1.21.0", flush=True) + # Exit 0 to skip the test gracefully + return 0 + + print("=== User-Agent Header Test ===", flush=True) + + # Start server in background thread (port allocated dynamically) + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + # Wait for server to be ready and get the allocated port + if not server_ready.wait(timeout=5): + print("ERROR: Server failed to start", flush=True) + return 1 + + registry = f"127.0.0.1:{allocated_port}" + print(f"Server listening on {registry}", flush=True) + + # Configure insecure registry + registries_conf = f"""[[registry]] +location = "{registry}" +insecure = true +""" + conf_path = "/etc/containers/registries.conf.d/99-test-insecure.conf" + print(f"Writing registries config to {conf_path}", flush=True) + with open(conf_path, "w") as f: + f.write(registries_conf) + print(registries_conf, flush=True) + + try: + + # Test with bootc + print("\n=== Testing with bootc ===", flush=True) + result = subprocess.run( + ["bootc", "switch", "--transport", "registry", f"{registry}/test:latest"], + capture_output=True, + text=True, + timeout=60, + ) + print(f"bootc exit code: {result.returncode}", flush=True) + print(f"bootc stdout: {result.stdout}", flush=True) + print(f"bootc stderr: {result.stderr}", flush=True) + + # Wait for server to receive the HTTP request (after TLS probes) + if not request_received.wait(timeout=10): + print("ERROR: No HTTP request was received by server", flush=True) + return 1 + + # Check result + if not captured_user_agent: + print("ERROR: No User-Agent was captured", flush=True) + return 1 + + print(f"\nCaptured User-Agent: {captured_user_agent}", flush=True) + + if "bootc/" not in captured_user_agent: + print(f"ERROR: User-Agent does not contain 'bootc/'", flush=True) + return 1 + + print("\nSUCCESS: User-Agent contains 'bootc/'", flush=True) + return 0 + + finally: + # Cleanup + if os.path.exists(conf_path): + os.remove(conf_path) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 319918962..4d808880e 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -21,16 +21,16 @@ duration: 30m test: nu booted/test-logically-bound-install.nu -/test-23-usroverlay: - summary: Execute tests for bootc usrover - duration: 30m - test: nu booted/test-usroverlay.nu - /test-23-install-outside-container: summary: Execute tests for installing outside of a container duration: 30m test: nu booted/test-install-outside-container.nu +/test-23-usroverlay: + summary: Execute tests for bootc usrover + duration: 30m + test: nu booted/test-usroverlay.nu + /test-24-image-upgrade-reboot: summary: Execute local upgrade tests duration: 30m @@ -96,3 +96,8 @@ require: - qemu-img test: nu booted/test-bib-build.nu + +/test-34-user-agent: + summary: Verify bootc sends correct User-Agent header to registries + duration: 10m + test: python3 booted/test-user-agent.py