From f5b235f5f93e5525587cdd6e7fe468fc7f2744c2 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 8 Jun 2026 12:22:57 +0000 Subject: [PATCH] feat(ssl-certs): add --ssl-certs flag to bundle CA certificates into images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enterprise environments that intercept HTTPS traffic with corporate proxies need a custom CA certificate trusted both during the build (so dnf/apt can reach repos) and at runtime (so the agent API calls work). The flag accepts an optional FILE; omitting the value triggers auto-discovery across 7 common Linux CA bundle paths. Certs are installed in the final image via update-ca-trust (DNF) or update-ca-certificates (Ubuntu), and persist into the final sandbox image via the FROM system AS final inheritance. To test auto-discover mode (uses the host's CA bundle if present): openshell-image-builder --ssl-certs= myimage:latest To test explicit file mode: openshell-image-builder \ --ssl-certs /etc/ssl/certs/ca-certificates.crt \ myimage:latest To verify the cert landed in the final image — Ubuntu base (default): podman run --rm myimage:latest -c \ 'test -f /usr/local/share/ca-certificates/system-ca.crt && echo found' To test with other base images, create a config.toml first. DNF-based images (Fedora, UBI, Hummingbird) store the cert under the pki trust path instead. Example configs and the verification command: # Fedora echo '[openshell_image_builder.base_image] image = "fedora" tag = "latest"' > /tmp/myconfig/config.toml # UBI echo '[openshell_image_builder.base_image] image = "ubi" tag = "latest"' > /tmp/myconfig/config.toml # Hummingbird echo '[openshell_image_builder.base_image] image = "hummingbird" tag = "latest-builder"' > /tmp/myconfig/config.toml Then build and verify: openshell-image-builder \ --config /tmp/myconfig \ --ssl-certs /etc/ssl/certs/ca-certificates.crt \ myimage:latest podman run --rm myimage:latest -c \ 'test -f /etc/pki/ca-trust/source/anchors/system-ca.crt && echo found' To verify error on a missing file: openshell-image-builder --ssl-certs /nonexistent/bundle.crt myimage:latest # exits non-zero with an OS error Closes #62 Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Philippe Martin --- .agents/skills/add-cli-flag/SKILL.md | 3 + README.md | 63 ++++++++++ src/certs.rs | 179 +++++++++++++++++++++++++++ src/containerfile.rs | 117 +++++++++++++++-- src/main.rs | 133 ++++++++++++++++++++ tests/fixtures/test-ca.crt | 20 +++ tests/integration_test.rs | 122 ++++++++++++++++++ 7 files changed, 629 insertions(+), 8 deletions(-) create mode 100644 src/certs.rs create mode 100644 tests/fixtures/test-ca.crt diff --git a/.agents/skills/add-cli-flag/SKILL.md b/.agents/skills/add-cli-flag/SKILL.md index 2ff436a..2beee8f 100644 --- a/.agents/skills/add-cli-flag/SKILL.md +++ b/.agents/skills/add-cli-flag/SKILL.md @@ -16,6 +16,7 @@ The existing flags are the canonical reference: - `--agent` / `--inference` — enum flags backed by `ValueEnum`, gated by a compatibility check in `run()` - `--endpoint` — `Option` that overrides a provider URL, validated early in `run()`, flows into `resolve_base_url()` and `stage_agent_settings()` - `--model` — `Option` threaded through `stage_agent_settings()` and `agent.env_vars()` +- `--ssl-certs` — `Option` with `num_args = 0..=1` and `default_missing_value = ""` (optional value flag); converted to `Option>` before being passed to `run()` ## Step 1 — declare the argument in the `Cli` struct (`src/main.rs`) @@ -46,6 +47,7 @@ Then extend the `run()` signature: fn run( ... my_flag: Option<&str>, // add here + ssl_certs: Option>, runner: &dyn build::Runner, ) -> Result<(), Box> { ``` @@ -108,6 +110,7 @@ fn run_with_my_flag_succeeds() { None, // endpoint None, // model Some("my-value"), // my_flag + None, // ssl_certs &FakeRunner(0), ); assert!(result.is_ok(), "expected Ok, got {result:?}"); diff --git a/README.md b/README.md index fea3084..5dbf13c 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,68 @@ Use `-v` (info) or `-vv` (debug) to increase log verbosity — useful for tracin openshell-image-builder -v myimage:latest ``` +## Enterprise environments + +### Corporate proxy support (`--ssl-certs`) + +In environments where outbound HTTPS traffic is intercepted by a corporate proxy (e.g. Netskope, Zscaler, or a custom MITM proxy), `dnf install` and `apt-get install` fail during the build because the proxy presents a self-signed or corporate-issued certificate that the container doesn't trust. + +Use `--ssl-certs` to copy a CA bundle into the build context and install it in the image. The certificate is trusted both **during the build** (so package installation succeeds) and **at runtime** (so the agent can reach its LLM backend through the same proxy). + +**Auto-discover** — the tool searches for a CA bundle in common system locations and uses the first one it finds. Use `--ssl-certs=` (with a trailing `=` and no value) so that the image tag is not mistaken for the certificate path: + +```sh +openshell-image-builder --ssl-certs= myimage:latest +``` + +Paths searched, in order: + +| Distribution | Path | +| ------------ | ---- | +| Debian / Ubuntu / Gentoo | `/etc/ssl/certs/ca-certificates.crt` | +| Fedora / RHEL 6 | `/etc/pki/tls/certs/ca-bundle.crt` | +| Fedora / RHEL 7+ / CentOS / Rocky / Alma | `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem` | +| OpenSUSE | `/etc/ssl/ca-bundle.pem` | +| SUSE / older | `/etc/ssl/certs/ca-bundle.crt` | +| Alpine | `/etc/ssl/cert.pem` | +| Arch | `/etc/ca-certificates/extracted/tls-ca-bundle.pem` | + +If none of the above paths exist, the build proceeds without adding any certificates. + +**Explicit file** — point directly to a specific CA bundle. The build fails immediately if the file does not exist: + +```sh +openshell-image-builder --ssl-certs /etc/pki/tls/certs/ca-bundle.crt myimage:latest +``` + +#### How it works + +The CA bundle is copied into the build context and installed in the image's system trust store during the `system` stage, before any packages are installed: + +- **Fedora / UBI / Hummingbird** — copied to `/etc/pki/ca-trust/source/anchors/system-ca.crt`, then `update-ca-trust` is run before `dnf install`. +- **Ubuntu** — copied to `/usr/local/share/ca-certificates/system-ca.crt`, then `update-ca-certificates` is run after `apt-get install` (Ubuntu mirrors use HTTP, so the cert is not needed for package installation itself, but is available to the agent at runtime). + +Because the `final` image stage inherits the full filesystem from `system`, the CA bundle and updated trust database are present in the running sandbox. + +#### Example — agent build behind a corporate proxy + +```sh +# Auto-discover the host CA bundle and build with Claude Code +# (--ssl-certs is followed by another --flag, so no trailing = needed) +openshell-image-builder \ + --ssl-certs \ + --agent claude \ + --inference anthropic \ + myimage:latest + +# Or point to a specific bundle +openshell-image-builder \ + --ssl-certs /usr/local/share/ca-certificates/my-corp-ca.crt \ + --agent claude \ + --inference anthropic \ + myimage:latest +``` + ## Installing an agent Pass `--agent` to install an agent into the image. @@ -500,6 +562,7 @@ openshell-image-builder [OPTIONS] | `--inference ` | Inference server the agent will connect to (`anthropic`, `vertexai`, `ollama`, `openai`) | | `--endpoint ` | Override the inference provider's default endpoint URL (see [Custom endpoint](#custom-endpoint---endpoint)) | | `--model ` | Default model for the agent to use (see [Default model](#default-model---model)) | +| `--ssl-certs=[FILE]` | Install system CA certificates in the image (see [Corporate proxy support](#corporate-proxy-support---ssl-certs)). `--ssl-certs=` auto-discovers from common system paths; `--ssl-certs /path/to/bundle.crt` uses that specific file (fails if not found). | | `-v` / `-vv` | Increase log verbosity (info / debug) | ## Examples diff --git a/src/certs.rs b/src/certs.rs new file mode 100644 index 0000000..649e48d --- /dev/null +++ b/src/certs.rs @@ -0,0 +1,179 @@ +// Copyright (C) 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use std::io; +use std::path::Path; + +/// Ordered list of common CA bundle paths across Linux distributions. +pub const SYSTEM_CA_CERT_PATHS: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/ca-bundle.pem", + "/etc/ssl/certs/ca-bundle.crt", + "/etc/ssl/cert.pem", + "/etc/ca-certificates/extracted/tls-ca-bundle.pem", +]; + +/// Tries each path in order; returns the content of the first non-empty regular file found. +pub fn find_system_ca_certificates(cert_paths: &[&str]) -> Option> { + for path in cert_paths { + let p = Path::new(path); + if !p.is_file() { + continue; + } + if let Ok(content) = std::fs::read(p) + && !content.is_empty() + { + return Some(content); + } + } + None +} + +fn write_cert_to_context(context_dir: &Path, content: &[u8]) -> io::Result<()> { + let certs_dir = context_dir.join("certs"); + std::fs::create_dir_all(&certs_dir)?; + std::fs::write(certs_dir.join("system-ca.crt"), content) +} + +/// Auto-discover mode: copies the first found bundle to `/certs/system-ca.crt`. +/// Returns `true` if a cert was found and copied, `false` if none found. +pub fn copy_from_paths(context_dir: &Path, cert_paths: &[&str]) -> io::Result { + match find_system_ca_certificates(cert_paths) { + None => Ok(false), + Some(content) => { + write_cert_to_context(context_dir, &content)?; + Ok(true) + } + } +} + +/// Specific-file mode: reads from `path` and copies to `/certs/system-ca.crt`. +/// Returns an error if the file doesn't exist or can't be read. +pub fn copy_from_file(context_dir: &Path, path: &Path) -> io::Result<()> { + let content = std::fs::read(path)?; + write_cert_to_context(context_dir, &content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_returns_none_for_empty_paths() { + assert!(find_system_ca_certificates(&[]).is_none()); + } + + #[test] + fn find_reads_first_existing_path() { + let dir = tempfile::tempdir().unwrap(); + let cert_path = dir.path().join("bundle.crt"); + std::fs::write(&cert_path, b"CERT_DATA").unwrap(); + let path_str = cert_path.to_string_lossy().into_owned(); + let result = find_system_ca_certificates(&[path_str.as_str()]); + assert_eq!(result, Some(b"CERT_DATA".to_vec())); + } + + #[test] + fn find_skips_directories() { + let dir = tempfile::tempdir().unwrap(); + let subdir = dir.path().join("subdir"); + std::fs::create_dir(&subdir).unwrap(); + let cert = dir.path().join("bundle.crt"); + std::fs::write(&cert, b"REAL_CERT").unwrap(); + let dir_str = subdir.to_string_lossy().into_owned(); + let cert_str = cert.to_string_lossy().into_owned(); + let result = find_system_ca_certificates(&[dir_str.as_str(), cert_str.as_str()]); + assert_eq!(result, Some(b"REAL_CERT".to_vec())); + } + + #[test] + fn find_skips_empty_files() { + let dir = tempfile::tempdir().unwrap(); + let empty = dir.path().join("empty.crt"); + let real = dir.path().join("real.crt"); + std::fs::write(&empty, b"").unwrap(); + std::fs::write(&real, b"REAL_CERT").unwrap(); + let empty_str = empty.to_string_lossy().into_owned(); + let real_str = real.to_string_lossy().into_owned(); + let result = find_system_ca_certificates(&[empty_str.as_str(), real_str.as_str()]); + assert_eq!(result, Some(b"REAL_CERT".to_vec())); + } + + #[test] + fn find_falls_through_to_second_path_when_first_missing() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real.crt"); + std::fs::write(&real, b"SECOND_CERT").unwrap(); + let real_str = real.to_string_lossy().into_owned(); + let result = find_system_ca_certificates(&["/nonexistent/path.crt", real_str.as_str()]); + assert_eq!(result, Some(b"SECOND_CERT".to_vec())); + } + + #[test] + fn copy_from_paths_returns_false_when_no_certs_found() { + let ctx = tempfile::tempdir().unwrap(); + let copied = copy_from_paths(ctx.path(), &[]).unwrap(); + assert!(!copied); + assert!(!ctx.path().join("certs").exists()); + } + + #[test] + fn copy_from_paths_creates_certs_dir_and_file() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("bundle.crt"); + std::fs::write(&cert, b"MY_CERT").unwrap(); + let cert_str = cert.to_string_lossy().into_owned(); + + let ctx = tempfile::tempdir().unwrap(); + copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap(); + + assert!(ctx.path().join("certs").join("system-ca.crt").exists()); + } + + #[test] + fn copy_from_paths_returns_true_when_cert_found() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("bundle.crt"); + std::fs::write(&cert, b"MY_CERT").unwrap(); + let cert_str = cert.to_string_lossy().into_owned(); + + let ctx = tempfile::tempdir().unwrap(); + let copied = copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap(); + assert!(copied); + } + + #[test] + fn copy_from_file_writes_content_correctly() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("bundle.crt"); + std::fs::write(&cert, b"CUSTOM_CERT").unwrap(); + + let ctx = tempfile::tempdir().unwrap(); + copy_from_file(ctx.path(), &cert).unwrap(); + + let written = std::fs::read(ctx.path().join("certs").join("system-ca.crt")).unwrap(); + assert_eq!(written, b"CUSTOM_CERT"); + } + + #[test] + fn copy_from_file_errors_when_file_missing() { + let ctx = tempfile::tempdir().unwrap(); + let result = copy_from_file(ctx.path(), Path::new("/nonexistent/bundle.crt")); + assert!(result.is_err()); + } +} diff --git a/src/containerfile.rs b/src/containerfile.rs index 127151b..a0020f4 100644 --- a/src/containerfile.rs +++ b/src/containerfile.rs @@ -44,6 +44,7 @@ pub fn generate( with_agent_settings: bool, skill_names: &[String], env_vars: &HashMap, + with_ca_certs: bool, ) -> Result { let tag = &config.base_image.tag; let system_stage = match config.base_image.image.as_str() { @@ -65,6 +66,7 @@ pub fn generate( "traceroute", "which", ], + with_ca_certs, ), "ubi" => dnf_system_stage( "registry.access.redhat.com/ubi10/ubi", @@ -80,6 +82,7 @@ pub fn generate( "procps-ng", "which", ], + with_ca_certs, ), "hummingbird" => dnf_system_stage( "registry.access.redhat.com/hi/core-runtime", @@ -92,8 +95,9 @@ pub fn generate( "which", "tar", ], + with_ca_certs, ), - "ubuntu" => ubuntu_system_stage(tag), + "ubuntu" => ubuntu_system_stage(tag, with_ca_certs), image => { return Err(ContainerfileError::NotSupported { image: image.to_string(), @@ -183,7 +187,12 @@ fn features_section(features: &[StagedFeature]) -> String { out } -fn ubuntu_system_stage(tag: &str) -> String { +fn ubuntu_system_stage(tag: &str, with_ca_certs: bool) -> String { + let ca_cert_section = if with_ca_certs { + "COPY certs/system-ca.crt /usr/local/share/ca-certificates/system-ca.crt\nRUN update-ca-certificates\n\n" + } else { + "" + }; format!( r#"# System base FROM docker.io/library/ubuntu:{tag} AS system @@ -208,18 +217,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ traceroute \ && rm -rf /var/lib/apt/lists/* -RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \ +{ca_cert_section}RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \ groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox "# ) } -fn dnf_system_stage(base_image: &str, tag: &str, packages: &[&str]) -> String { +fn dnf_system_stage(base_image: &str, tag: &str, packages: &[&str], with_ca_certs: bool) -> String { let pkg_lines = packages .iter() .map(|p| format!(" {p} \\")) .collect::>() .join("\n"); + let ca_cert_section = if with_ca_certs { + "COPY certs/system-ca.crt /etc/pki/ca-trust/source/anchors/system-ca.crt\nRUN update-ca-trust\n\n" + } else { + "" + }; format!( r#"# System base FROM {base_image}:{tag} AS system @@ -227,7 +241,7 @@ WORKDIR /sandbox # Core system dependencies USER 0 -RUN dnf install -y --setopt=install_weak_deps=False \ +{ca_cert_section}RUN dnf install -y --setopt=install_weak_deps=False \ {pkg_lines} && dnf clean all @@ -306,9 +320,14 @@ mod tests { with_agent_settings, skill_names, &HashMap::new(), + false, ) } + fn build_cf_with_ca_certs(config: &Config) -> String { + generate(config, None, &[], false, &[], &HashMap::new(), true).unwrap() + } + fn ubuntu_config(tag: &str) -> Config { Config { version: 1, @@ -875,7 +894,8 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars).unwrap(); + let content = + generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); assert!(content.contains("ENV ANTHROPIC_BASE_URL=\"https://proxy.example.com\"")); } @@ -886,7 +906,8 @@ mod tests { "ANTHROPIC_BASE_URL".to_string(), "https://proxy.example.com".to_string(), ); - let content = generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars).unwrap(); + let content = + generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); let user_pos = content.find("USER sandbox").unwrap(); let env_pos = content.find("ENV ANTHROPIC_BASE_URL=").unwrap(); assert!( @@ -906,9 +927,89 @@ mod tests { let mut vars = HashMap::new(); vars.insert("Z_VAR".to_string(), "z".to_string()); vars.insert("A_VAR".to_string(), "a".to_string()); - let content = generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars).unwrap(); + let content = + generate(&ubuntu_config("24.04"), None, &[], false, &[], &vars, false).unwrap(); let a_pos = content.find("ENV A_VAR=").unwrap(); let z_pos = content.find("ENV Z_VAR=").unwrap(); assert!(a_pos < z_pos, "env vars must be sorted alphabetically"); } + + // CA cert tests + + #[test] + fn ca_certs_omitted_when_false() { + for content in [ + build_cf(&ubuntu_config("24.04"), None, &[], false, &[]).unwrap(), + build_cf(&fedora_config(), None, &[], false, &[]).unwrap(), + build_cf(&ubi_config(), None, &[], false, &[]).unwrap(), + build_cf(&hummingbird_config(), None, &[], false, &[]).unwrap(), + ] { + assert!( + !content.contains("COPY certs/"), + "unexpected cert COPY: {content}" + ); + } + } + + #[test] + fn dnf_ca_certs_included_when_true() { + for content in [ + build_cf_with_ca_certs(&fedora_config()), + build_cf_with_ca_certs(&ubi_config()), + build_cf_with_ca_certs(&hummingbird_config()), + ] { + assert!( + content.contains( + "COPY certs/system-ca.crt /etc/pki/ca-trust/source/anchors/system-ca.crt" + ), + "missing COPY instruction: {content}" + ); + assert!( + content.contains("RUN update-ca-trust"), + "missing update-ca-trust: {content}" + ); + } + } + + #[test] + fn ubuntu_ca_certs_included_when_true() { + let content = build_cf_with_ca_certs(&ubuntu_config("24.04")); + assert!( + content.contains( + "COPY certs/system-ca.crt /usr/local/share/ca-certificates/system-ca.crt" + ), + "missing COPY instruction: {content}" + ); + assert!( + content.contains("RUN update-ca-certificates"), + "missing update-ca-certificates: {content}" + ); + } + + #[test] + fn dnf_ca_cert_appears_before_dnf_install() { + for content in [ + build_cf_with_ca_certs(&fedora_config()), + build_cf_with_ca_certs(&ubi_config()), + build_cf_with_ca_certs(&hummingbird_config()), + ] { + let cert_pos = content.find("COPY certs/system-ca.crt").unwrap(); + let dnf_pos = content.find("RUN dnf install").unwrap(); + assert!( + cert_pos < dnf_pos, + "CA cert COPY must appear before dnf install" + ); + } + } + + #[test] + fn ubuntu_ca_cert_appears_after_apt_install() { + let content = build_cf_with_ca_certs(&ubuntu_config("24.04")); + let apt_pos = content.find("RUN apt-get update").unwrap(); + let cert_pos = content.find("COPY certs/system-ca.crt").unwrap(); + assert!( + cert_pos > apt_pos, + "CA cert COPY must appear after apt-get install" + ); + } } diff --git a/src/main.rs b/src/main.rs index 89bda7f..dc78d59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod agent; mod build; +mod certs; mod config; mod containerfile; mod feature; @@ -61,6 +62,16 @@ struct Cli { endpoint: Option, #[arg(long, help = "Default model for the agent to use")] model: Option, + #[arg( + long = "ssl-certs", + value_name = "FILE", + num_args = 0..=1, + default_missing_value = "", + help = "Copy system CA certificates into the build context and install them \ + in the final image. Without FILE, auto-discovers from common system \ + paths. With FILE, uses that specific bundle (fails if not found)." + )] + ssl_certs: Option, } fn main() { @@ -73,6 +84,13 @@ fn main() { _ => LevelFilter::Debug, }; env_logger::Builder::new().filter_level(log_level).init(); + let ssl_certs = cli.ssl_certs.map(|s| { + if s.is_empty() { + None + } else { + Some(std::path::PathBuf::from(s)) + } + }); if let Err(e) = run( &cli.tag, cli.config, @@ -81,6 +99,7 @@ fn main() { cli.inference, cli.endpoint.as_deref(), cli.model.as_deref(), + ssl_certs, &build::PodmanRunner, ) { eprintln!("Error: {e}"); @@ -97,6 +116,7 @@ fn run( inference_kind: Option, endpoint: Option<&str>, model: Option<&str>, + ssl_certs: Option>, runner: &dyn build::Runner, ) -> Result<(), Box> { if endpoint.is_some() && inference_kind == Some(inference::InferenceKind::VertexAi) { @@ -146,6 +166,14 @@ fn run( workspace.as_ref(), )?; std::fs::write(context_dir.path().join("policy.yaml"), policy_yaml)?; + let ca_certs_copied = match ssl_certs { + None => false, + Some(None) => certs::copy_from_paths(context_dir.path(), certs::SYSTEM_CA_CERT_PATHS)?, + Some(Some(path)) => { + certs::copy_from_file(context_dir.path(), &path)?; + true + } + }; let output = containerfile::generate( &config, agent.as_deref(), @@ -153,6 +181,7 @@ fn run( has_agent_settings, &skill_names, &agent_env_vars, + ca_certs_copied, )?; build::build(&output, tag, runner, context_dir.path())?; Ok(()) @@ -374,6 +403,24 @@ mod tests { } } + // Reads the Containerfile written to the `-f ` temp file and stores its content. + struct ContainerfileCapture(std::sync::Mutex); + + impl build::Runner for ContainerfileCapture { + fn run(&self, cmd: &mut Command) -> std::io::Result { + let args: Vec<_> = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + if let Some(idx) = args.iter().position(|a| a == "-f") { + if let Some(path) = args.get(idx + 1) { + *self.0.lock().unwrap() = std::fs::read_to_string(path).unwrap_or_default(); + } + } + Ok(Command::new("sh").args(["-c", "exit 0"]).status()?) + } + } + #[test] fn version_matches_cargo_toml() { let cmd = Cli::command(); @@ -798,6 +845,7 @@ mod tests { None, None, None, + None, &FakeRunner(0), ); assert!(result.is_ok(), "expected Ok, got {result:?}"); @@ -814,6 +862,7 @@ mod tests { None, None, None, + None, &FakeRunner(0), ); assert!(result.is_ok(), "expected Ok, got {result:?}"); @@ -830,6 +879,7 @@ mod tests { Some(inference::InferenceKind::Anthropic), None, None, + None, &FakeRunner(0), ); assert!(result.is_ok(), "expected Ok, got {result:?}"); @@ -846,6 +896,7 @@ mod tests { Some(inference::InferenceKind::Ollama), None, None, + None, &FakeRunner(0), ); assert!(result.is_err()); @@ -868,6 +919,7 @@ mod tests { None, None, None, + None, &FakeRunner(1), ); assert!(result.is_err()); @@ -884,6 +936,7 @@ mod tests { Some(inference::InferenceKind::VertexAi), Some("https://my-vertex-proxy.example.com"), None, + None, &FakeRunner(0), ); assert!(result.is_err()); @@ -906,6 +959,7 @@ mod tests { Some(inference::InferenceKind::Anthropic), None, Some("claude-opus-4-5"), + None, &FakeRunner(0), ); assert!(result.is_ok(), "expected Ok, got {result:?}"); @@ -1064,6 +1118,7 @@ mod tests { Some(inference::InferenceKind::OpenAi), None, None, + None, &FakeRunner(0), ); assert!(result.is_err()); @@ -1214,4 +1269,82 @@ mod tests { let yaml_ws = build_policy(BASE_POLICY_YAML, None, None, None, Some(&ws)).unwrap(); assert_eq!(yaml_no_ws, yaml_ws); } + + // ssl_certs / run() tests + + #[test] + fn run_with_ssl_certs_auto_discover_no_certs_found_succeeds() { + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + tmp.path(), + None, + None, + None, + None, + Some(None), + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_ssl_certs_specific_file_succeeds() { + let tmp = tempfile::tempdir().unwrap(); + let cert = tmp.path().join("bundle.crt"); + std::fs::write(&cert, b"FAKE_CERT_DATA").unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + tmp.path(), + None, + None, + None, + None, + Some(Some(cert)), + &FakeRunner(0), + ); + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[test] + fn run_with_ssl_certs_specific_file_missing_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = run( + "test:latest", + Some(tmp.path().to_path_buf()), + tmp.path(), + None, + None, + None, + None, + Some(Some(PathBuf::from("/nonexistent/bundle.crt"))), + &FakeRunner(0), + ); + assert!(result.is_err()); + } + + #[test] + fn run_without_ssl_certs_containerfile_has_no_cert_copy() { + let tmp = tempfile::tempdir().unwrap(); + let capture = ContainerfileCapture(std::sync::Mutex::new(String::new())); + run( + "test:latest", + Some(tmp.path().to_path_buf()), + tmp.path(), + None, + None, + None, + None, + None, + &capture, + ) + .unwrap(); + let cf = capture.0.into_inner().unwrap(); + assert!( + !cf.contains("COPY certs/"), + "Containerfile must not contain cert COPY when --ssl-certs is not passed" + ); + } } diff --git a/tests/fixtures/test-ca.crt b/tests/fixtures/test-ca.crt new file mode 100644 index 0000000..7d006cf --- /dev/null +++ b/tests/fixtures/test-ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjmgAwIBAgIUEclwuz8SP34+gISEKvEdMlnl67MwDQYJKoZIhvcNAQEL +BQAwODEQMA4GA1UEAwwHVGVzdCBDQTEXMBUGA1UECgwOT3BlblNoZWxsIFRlc3Qx +CzAJBgNVBAYTAlVTMB4XDTI2MDYwODEyMTcwM1oXDTM2MDYwNTEyMTcwM1owODEQ +MA4GA1UEAwwHVGVzdCBDQTEXMBUGA1UECgwOT3BlblNoZWxsIFRlc3QxCzAJBgNV +BAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Fq5dd/HEKzQ +EtgwmHYeV+fe+3JQN0tezs2JB5EPeSCw5JHasGSBYlgvcseuR9SYNThPug/73Mtr +++ryhmM4hu+2eUpvJOlBcXB7OByZU2lTeObOCTvD9VkOGxBJQ/Pt65UluBgyR/9/ +j/yQF0rgw+MI+lPBOI99soSYkLjw78/F+NOV8Pgcwi2fx8x4upWtYR1ihm0LBFkj +0B2nqXICLTNf1rXK2mK7CiVH9aQnT5nQyF+lCXYYZhHp45uzWzkmfveJqVBpaziQ +QUuUT9SvtTd0VXiZ18ANcN21NXhheg72LNld8iRXlQWAn3ttO8LipRpnudgfVJOp +lVb9Li5AQwIDAQABo1MwUTAdBgNVHQ4EFgQUhnmVd9zLkNt3xnVm7EKH+sfZELYw +HwYDVR0jBBgwFoAUhnmVd9zLkNt3xnVm7EKH+sfZELYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAoKD5NWffIIF6fMWzWTJsqeOLNEM9WcgEqvhU +jGLJxgJiOC0ZxP/Gr0HjtotPAKvm9g+hk0s87JA60LmFyt86mjIYotV2M5SaSUFV +QyTWh3a3AHdhAxDCBIW7b53ny2mKTxN3TuDTZefY+fvbDw5Y9uem7vTrf7HG6K82 +XyYYAwgc43pr+2YoogTP3QOKippcTV7d23e+8mj7peaE89hqfaYYu3rsiWqgzplL +IdmcBAyrfQxur1W+dauAUEgsYOJiqHG+CnliJK3DMYzX1H5Vri9f/dBAu98NP4+A +/r8tYqgAKlBf1O1uuj+ftGSM+i7cJbj2R47SgUl7ONszR6nx2A== +-----END CERTIFICATE----- diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 60803a8..ddb0a86 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -107,6 +107,40 @@ static FEDORA_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static UBI_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static HUMMINGBIRD_OPENCODE_OPENAI_IMAGE: OnceLock = OnceLock::new(); static UBUNTU_OPENCODE_OPENAI_MODEL_IMAGE: OnceLock = OnceLock::new(); +static UBUNTU_SSL_CERTS_IMAGE: OnceLock = OnceLock::new(); +static FEDORA_SSL_CERTS_IMAGE: OnceLock = OnceLock::new(); + +fn ssl_cert_fixture_path() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test-ca.crt") +} + +fn ubuntu_ssl_certs_image() -> &'static str { + UBUNTU_SSL_CERTS_IMAGE.get_or_init(|| { + let cert = ssl_cert_fixture_path(); + let cert_str = cert.to_str().unwrap(); + build_image( + "openshell-test-ubuntu-ssl-certs:integration", + &["--ssl-certs", cert_str], + ) + }) +} + +fn fedora_ssl_certs_image() -> &'static str { + FEDORA_SSL_CERTS_IMAGE.get_or_init(|| { + let config = fedora_config_dir(); + let cert = ssl_cert_fixture_path(); + let cert_str = cert.to_str().unwrap(); + build_image( + "openshell-test-fedora-ssl-certs:integration", + &[ + "--config", + config.path().to_str().unwrap(), + "--ssl-certs", + cert_str, + ], + ) + }) +} fn config_dir_with_agent_settings(agent: &str, files: &[(&str, &str)]) -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); @@ -2319,6 +2353,92 @@ mod endpoint_rejection { } } +// --------------------------------------------------------------------------- +// --ssl-certs integration tests +// --------------------------------------------------------------------------- + +mod ssl_certs { + use super::*; + + #[test] + #[ignore] + fn ubuntu_cert_present_in_image() { + let out = run_in_image( + ubuntu_ssl_certs_image(), + "test -f /usr/local/share/ca-certificates/system-ca.crt", + ); + assert!( + out.status.success(), + "system-ca.crt not found in /usr/local/share/ca-certificates/" + ); + } + + #[test] + #[ignore] + fn ubuntu_cert_contains_pem_data() { + let out = run_in_image( + ubuntu_ssl_certs_image(), + "grep -q 'BEGIN CERTIFICATE' /usr/local/share/ca-certificates/system-ca.crt", + ); + assert!( + out.status.success(), + "system-ca.crt does not contain PEM certificate data" + ); + } + + #[test] + #[ignore] + fn ubuntu_cert_absent_without_flag() { + let out = run_in_image( + ubuntu_image(), + "test -f /usr/local/share/ca-certificates/system-ca.crt", + ); + assert!( + !out.status.success(), + "system-ca.crt should not be present in image built without --ssl-certs" + ); + } + + #[test] + #[ignore] + fn fedora_cert_present_in_image() { + let out = run_in_image( + fedora_ssl_certs_image(), + "test -f /etc/pki/ca-trust/source/anchors/system-ca.crt", + ); + assert!( + out.status.success(), + "system-ca.crt not found in /etc/pki/ca-trust/source/anchors/" + ); + } + + #[test] + #[ignore] + fn fedora_cert_contains_pem_data() { + let out = run_in_image( + fedora_ssl_certs_image(), + "grep -q 'BEGIN CERTIFICATE' /etc/pki/ca-trust/source/anchors/system-ca.crt", + ); + assert!( + out.status.success(), + "system-ca.crt does not contain PEM certificate data" + ); + } + + #[test] + #[ignore] + fn fedora_cert_absent_without_flag() { + let out = run_in_image( + fedora_image(), + "test -f /etc/pki/ca-trust/source/anchors/system-ca.crt", + ); + assert!( + !out.status.success(), + "system-ca.crt should not be present in image built without --ssl-certs" + ); + } +} + // --------------------------------------------------------------------------- // Cleanup — runs when the test process exits, after all tests complete // --------------------------------------------------------------------------- @@ -2374,6 +2494,8 @@ fn cleanup_images() { "openshell-test-ubuntu-claude-vertexai-model:integration", "openshell-test-ubuntu-opencode-anthropic-model:integration", "openshell-test-ubuntu-opencode-ollama-model:integration", + "openshell-test-ubuntu-ssl-certs:integration", + "openshell-test-fedora-ssl-certs:integration", ] { Command::new("podman") .args(["rmi", "--force", tag])