From 16c00831d3bfb4bb9cc71901f1897f14f352c844 Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 9 Mar 2026 19:11:20 +0800 Subject: [PATCH 1/2] feat(phase-2): add jq and yq to runtime docker image --- Dockerfile | 6 ++- crates/cli/tests/dockerfile_runtime_tools.rs | 40 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 crates/cli/tests/dockerfile_runtime_tools.rs diff --git a/Dockerfile b/Dockerfile index 1927b0b..4e5534f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,10 @@ RUN cargo build --release FROM alpine:3.23 -# Install CA certificates for HTTPS -RUN apk add --no-cache ca-certificates +# Install runtime dependencies: +# - ca-certificates: HTTPS support +# - jq/yq: lightweight JSON/YAML processing for container workflows +RUN apk add --no-cache ca-certificates jq yq COPY --from=builder /app/target/release/rc /usr/bin/rc COPY --from=builder /app/LICENSE-* /licenses/ diff --git a/crates/cli/tests/dockerfile_runtime_tools.rs b/crates/cli/tests/dockerfile_runtime_tools.rs new file mode 100644 index 0000000..65090e7 --- /dev/null +++ b/crates/cli/tests/dockerfile_runtime_tools.rs @@ -0,0 +1,40 @@ +//! Dockerfile runtime tooling contract tests. +//! +//! These tests prevent regressions where container utility tools expected by +//! users (for example in Kubernetes jobs) are removed from the runtime image. + +use std::path::PathBuf; + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("cli crate has parent directory") + .parent() + .expect("workspace root exists") + .to_path_buf() +} + +fn dockerfile_contents() -> String { + let dockerfile_path = workspace_root().join("Dockerfile"); + std::fs::read_to_string(&dockerfile_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dockerfile_path.display())) +} + +#[test] +fn runtime_image_installs_jq_and_yq() { + let contents = dockerfile_contents(); + let runtime_apk_line = contents + .lines() + .rev() + .find(|line| line.contains("apk add --no-cache")) + .expect("Dockerfile should install runtime packages with apk"); + + assert!( + runtime_apk_line.contains("jq"), + "runtime apk install line should include jq; found: {runtime_apk_line}" + ); + assert!( + runtime_apk_line.contains("yq"), + "runtime apk install line should include yq; found: {runtime_apk_line}" + ); +} From 44551d70c2da9d9f995de0c058a73de81b744535 Mon Sep 17 00:00:00 2001 From: overtrue Date: Mon, 9 Mar 2026 19:17:47 +0800 Subject: [PATCH 2/2] feat(phase-2): harden Dockerfile runtime package contract test --- crates/cli/tests/dockerfile_runtime_tools.rs | 78 +++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/cli/tests/dockerfile_runtime_tools.rs b/crates/cli/tests/dockerfile_runtime_tools.rs index 65090e7..b7b609b 100644 --- a/crates/cli/tests/dockerfile_runtime_tools.rs +++ b/crates/cli/tests/dockerfile_runtime_tools.rs @@ -20,21 +20,81 @@ fn dockerfile_contents() -> String { .unwrap_or_else(|err| panic!("failed to read {}: {err}", dockerfile_path.display())) } +fn runtime_stage_apk_packages(contents: &str) -> Vec { + let lines: Vec<&str> = contents.lines().collect(); + let mut from_indices = lines + .iter() + .enumerate() + .filter_map(|(index, line)| line.trim_start().starts_with("FROM ").then_some(index)); + + let _builder_from = from_indices + .next() + .expect("Dockerfile should contain a builder stage"); + let runtime_from = from_indices + .next() + .expect("Dockerfile should contain a runtime stage"); + + let runtime_end = lines + .iter() + .enumerate() + .skip(runtime_from + 1) + .find_map(|(index, line)| line.trim_start().starts_with("FROM ").then_some(index)) + .unwrap_or(lines.len()); + + let apk_start = (runtime_from..runtime_end) + .find(|&index| lines[index].contains("apk add --no-cache")) + .expect("runtime stage should include an apk add command"); + + let mut command = String::new(); + let mut index = apk_start; + while index < runtime_end { + let line = lines[index].trim_end(); + let (without_backslash, has_backslash) = match line.strip_suffix('\\') { + Some(stripped) => (stripped, true), + None => (line, false), + }; + + if !command.is_empty() { + command.push(' '); + } + command.push_str(without_backslash.trim()); + + if !has_backslash { + break; + } + index += 1; + } + + let tokens: Vec<&str> = command.split_whitespace().collect(); + let apk_index = tokens + .iter() + .position(|token| *token == "apk") + .expect("runtime command should include `apk`"); + assert!( + tokens.len() > apk_index + 1 && tokens[apk_index + 1] == "add", + "runtime apk command should start with `apk add`; found: {command}" + ); + + tokens + .iter() + .skip(apk_index + 2) + .copied() + .filter(|token| !token.starts_with('-')) + .map(ToOwned::to_owned) + .collect() +} + #[test] fn runtime_image_installs_jq_and_yq() { let contents = dockerfile_contents(); - let runtime_apk_line = contents - .lines() - .rev() - .find(|line| line.contains("apk add --no-cache")) - .expect("Dockerfile should install runtime packages with apk"); + let packages = runtime_stage_apk_packages(&contents); assert!( - runtime_apk_line.contains("jq"), - "runtime apk install line should include jq; found: {runtime_apk_line}" + packages.iter().any(|package| package == "jq"), + "runtime apk install command should include jq as a package; found: {packages:?}" ); assert!( - runtime_apk_line.contains("yq"), - "runtime apk install line should include yq; found: {runtime_apk_line}" + packages.iter().any(|package| package == "yq"), + "runtime apk install command should include yq as a package; found: {packages:?}" ); }