diff --git a/.ci-operator.yaml b/.ci-operator.yaml index e307e5af..284a9100 100644 --- a/.ci-operator.yaml +++ b/.ci-operator.yaml @@ -1,4 +1,4 @@ build_root_image: name: release namespace: openshift - tag: rhel-9-release-golang-1.24-openshift-4.21 + tag: rhel-9-release-golang-1.24-openshift-4.22 diff --git a/Containerfile-exporter b/Containerfile-exporter index 26047258..f8e0f03d 100644 --- a/Containerfile-exporter +++ b/Containerfile-exporter @@ -1,4 +1,4 @@ -FROM registry.ci.openshift.org/ocp/builder:rhel-9-golang-1.24-openshift-4.21 AS go-builder +FROM registry.ci.openshift.org/ocp/builder:rhel-9-golang-1.24-openshift-4.22 AS go-builder WORKDIR /workspace/exporter COPY exporter . @@ -6,7 +6,7 @@ ARG GO_LDFLAGS="" ENV GOEXPERIMENT=strictfipsruntime RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on make build -FROM registry.ci.openshift.org/ocp/4.21:base-rhel9 +FROM registry.ci.openshift.org/ocp/4.22:base-rhel9 COPY --from=go-builder /workspace/exporter/bin/exporter / ENTRYPOINT [ "/exporter" ] \ No newline at end of file diff --git a/Containerfile-extractor b/Containerfile-extractor index b186ca43..18734261 100644 --- a/Containerfile-extractor +++ b/Containerfile-extractor @@ -1,4 +1,4 @@ -FROM registry.ci.openshift.org/ocp/4.21:base-rhel9 AS rust-builder +FROM registry.ci.openshift.org/ocp/4.22:base-rhel9 AS rust-builder ARG TARGETARCH RUN dnf update -y && dnf -y install \ @@ -8,7 +8,7 @@ WORKDIR /workspace/extractor COPY extractor . RUN make TARGETARCH=${TARGETARCH} -FROM registry.ci.openshift.org/ocp/builder:rhel-9-golang-1.24-openshift-4.21 AS go-builder +FROM registry.ci.openshift.org/ocp/builder:rhel-9-golang-1.24-openshift-4.22 AS go-builder WORKDIR /workspace/fingerprints COPY fingerprints . @@ -16,7 +16,7 @@ ARG GO_LDFLAGS="" ENV GOEXPERIMENT=strictfipsruntime RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on make build -FROM registry.ci.openshift.org/ocp/4.21:base-rhel9 +FROM registry.ci.openshift.org/ocp/4.22:base-rhel9 RUN dnf update -y && dnf -y install \ cri-tools diff --git a/docs/prd/prd-0001-container-ids-filtering.md b/docs/prd/prd-0001-container-ids-filtering.md new file mode 100644 index 00000000..c2e2bbe7 --- /dev/null +++ b/docs/prd/prd-0001-container-ids-filtering.md @@ -0,0 +1,332 @@ +# PRD: Container IDs Filtering for Runtime Extraction + +## Overview + +This document describes the changes implemented to support targeted container scanning via a list of container IDs. Previously, the system only scanned all running containers. This enhancement allows clients to request scanning of a specific subset of containers by providing their IDs in a POST request. + +## Motivation + +- **Performance**: Scanning only specific containers reduces resource usage and response time. + +--- + +## Architecture + +### Request Flow +``` +HTTP Client → Exporter (Go) → Extractor Server (Rust) → Coordinator (Rust) → Fingerprints + GET/POST /gather_runtime_info TCP 3000 /coordinator +``` + +### Capabilities +- **Exporter**: Supports `GET /gather_runtime_info?hash=true|false` (scans all) and `POST /gather_runtime_info` (scans specific containers) +- **Extractor Server**: Receives comma-separated container IDs via TCP, spawns coordinator with IDs as argument +- **Coordinator**: Accepts optional `container_ids` positional argument (comma-separated list) + +--- + +## Implemented Changes + +### 1. Exporter HTTP Server (Go) + +**File**: `exporter/cmd/exporter/main.go` + +#### Endpoints + +| Method | Path | Request Body | Description | +|--------|------|--------------|-------------| +| GET | `/gather_runtime_info` | None | Scan all containers | +| POST | `/gather_runtime_info` | JSON object with container IDs | Scan specific containers | + +#### Request Format (POST) +```json +{ + "containerIds": ["abc123...", "def456...", "ghi789..."] +} +``` + +#### Validation Rules +- Request body must be valid JSON +- `containerIds` field must be present and be an array +- Array can be empty (equivalent to scanning all containers) +- Container IDs must be in full CRI-O format, with or without `cri-o://` prefix + +#### Implementation + +1. **JSON request struct**: + ```go + type GatherRuntimeInfoRequest struct { + ContainerIds []string `json:"containerIds"` + } + ``` + +2. **HTTP handler** (`gatherRuntimeInfo`): + - For GET: uses empty `containerIds` slice + - For POST: decodes JSON body and validates `containerIds` field is present + - Passes container IDs to `triggerRuntimeInfoExtraction()` + +3. **`triggerRuntimeInfoExtraction()` function**: + - Accepts `containerIds []string` parameter + - Joins container IDs with commas: `strings.Join(containerIds, ",")` + - Sends payload via TCP + - **Calls `tcpConn.CloseWrite()` to signal EOF** - this is critical because the Rust server uses `read_to_string()` which reads until EOF + +#### TCP Protocol +- Client sends comma-separated container IDs as plain text +- Client calls `CloseWrite()` to signal end of data +- Server responds with path to extracted data + +#### Backward Compatibility +- GET endpoint behavior unchanged (scans all containers) +- GET internally sends an empty string `""` to maintain protocol consistency + +--- + +### 2. Extractor Server (Rust) + +**File**: `extractor/src/bin/extractor_server.rs` + +#### Implementation + +1. **`handle_trigger_extraction()` function**: + - Reads incoming data from TCP stream using `read_to_string()` (reads until EOF) + - Trims the received string + - Passes container IDs as positional argument to coordinator + +2. **Coordinator invocation**: + ```rust + Command::new("/coordinator") + .arg("--log-level") + .arg(log_level) + .arg(container_ids) // comma-separated string, passed even if empty + .output() + ``` + +#### TCP Protocol + +| Direction | Format | Example | +|-----------|--------|---------| +| Request | Plain text (read until EOF) | `abc123,def456` | +| Response | Plain text (path) | `data/out-1234567890\n` | + +--- + +### 3. Coordinator (Rust) + +**File**: `extractor/src/bin/coordinator.rs` + +#### CLI Arguments +```rust +#[arg(short, long)] +log_level: Option + +#[arg(help = "Comma-separated list of container IDs to scan. If absent, all containers are scanned")] +container_ids: Option +``` + +#### Implementation + +1. **Parsing container IDs**: + ```rust + let container_ids: Vec = args + .container_ids + .map(|ids| { + ids.split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect() + }) + .unwrap_or_default(); + ``` + +2. **Container retrieval**: + - Calls `get_containers(container_ids)` passing the parsed vector + - Empty vector means "scan all containers" + +3. **Logging**: + - Logs "Scanning X containers" at info level + +--- + +### 4. Container Module (Rust) + +**File**: `extractor/src/insights_runtime_extractor/container.rs` + +#### Implementation + +1. **`get_containers(container_ids: Vec)` function**: + - Normalizes container IDs by stripping `cri-o://` prefix if present + - Runs `crictl ps -o json -s RUNNING` to get all running containers + - If `container_ids` is not empty, filters to only include matching containers + - Returns `Vec` + +--- + +## Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ HTTP Client │ +└─────────────────────────────────┬───────────────────────────────────────────┘ + │ + POST /gather_runtime_info + Body: {"containerIds": ["abc123", "def456"]} + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXPORTER (Go) - Port 8000 │ +│ │ +│ 1. Parse POST body as JSON │ +│ 2. Validate containerIds field is present │ +│ 3. Send comma-separated containerIds to extractor_server via TCP │ +│ 4. Call CloseWrite() to signal EOF │ +└─────────────────────────────────┬───────────────────────────────────────────┘ + │ + TCP: "abc123,def456" + EOF + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ EXTRACTOR_SERVER (Rust) - Port 3000 │ +│ │ +│ 1. Read String from TCP stream until EOF (read_to_string) │ +│ 2. Execute: /coordinator --log-level info "abc123,def456" │ +└─────────────────────────────────┬───────────────────────────────────────────┘ + │ + Subprocess with args + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ COORDINATOR (Rust) │ +│ │ +│ 1. Parse comma-separated container IDs from argument │ +│ 2. Get all running containers via crictl ps │ +│ 3. Filter to only requested containers (if any specified) │ +│ 4. Scan filtered containers │ +│ 5. Output path to stdout │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## API Specification + +### POST /gather_runtime_info + +#### Request + +**Headers**: +``` +Content-Type: application/json +``` + +**Body**: +```json +{ + "containerIds": ["container-id-1", "container-id-2"] +} +``` + +**Field Descriptions**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| containerIds | string[] | Yes | List of container IDs to scan. Empty array scans all containers. Supports full CRI-O IDs, with or without `cri-o://` prefix. | + +#### Response + +Same as existing GET endpoint response: + +```json +{ + "namespace-1": { + "pod-name-1": { + "container-id-1": { + "os": "rhel", + "osVersion": "8.9", + "kind": "Java", + "kindVersion": "17.0.9", + "kindImplementer": "Red Hat, Inc.", + "runtimes": [ + {"name": "Quarkus", "version": "3.2.0"} + ] + } + } + } +} +``` + +#### Error Responses + +| Status | Condition | Response Body | +|--------|-----------|---------------| +| 400 | Invalid JSON | `{"error": "Invalid JSON in request body"}` | +| 400 | Missing containerIds | `{"error": "containerIds field is required"}` | +| 500 | Extraction failed | Error message from extraction process | + +--- + +### Integration Tests + +1. **End-to-end with specific containers**: + - POST request with known container IDs + - Verify only those containers appear in response + +2. **Empty array behavior**: + - POST with `{"containerIds": []}` should scan all containers + - Result should match GET endpoint + +3. **Non-existent container IDs**: + - POST with IDs that don't match running containers + - Should return empty/partial results gracefully + +4. **Mixed valid/invalid IDs**: + - Some IDs exist, some don't + - Should scan existing ones only + +### E2E Tests + +Add test cases to existing e2e test suite in `runtime-samples/`: +- Test POST endpoint with subset of containers +- Verify filtering works correctly in Kubernetes environment + +--- + +## Backward Compatibility + +| Component | Backward Compatible | Notes | +|-----------|---------------------|-------| +| Exporter | Yes | GET endpoint unchanged | +| Extractor Server | Yes | Empty string treated as "scan all" | +| Coordinator | Yes | Empty/missing argument scans all containers | + +--- + +## Security Considerations + +- No new privileges required +- No shell injection risk (IDs passed as single argument, not executed) +- Logging does not expose sensitive data + +--- + +## Design Decisions + +1. **Container ID format**: Full (64-char) container IDs are supported. The `cri-o://` prefix is automatically stripped if present. + +2. **Maximum number of container IDs**: No hard limit imposed. + +3. **Response for non-existent IDs**: Missing containers are silently skipped. Results are returned for found containers only. + +4. **TCP EOF signaling**: The Go exporter uses `CloseWrite()` to signal EOF because the Rust server uses `read_to_string()` which reads until EOF. + +5. **Filtering approach**: Instead of calling `crictl inspect` for each container ID, the implementation fetches all running containers with `crictl ps` and filters in memory. This is simpler and avoids multiple subprocess calls. + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `exporter/cmd/exporter/main.go` | Add POST handler, JSON parsing, TCP EOF signaling with CloseWrite() | +| `extractor/src/bin/extractor_server.rs` | Read container IDs from TCP stream, pass to coordinator | +| `extractor/src/bin/coordinator.rs` | Accept container_ids positional argument, parse and pass to get_containers | +| `extractor/src/insights_runtime_extractor/container.rs` | Update get_containers to accept Vec and filter containers | diff --git a/exporter/cmd/exporter/main.go b/exporter/cmd/exporter/main.go index 2aef5f8a..5f6b0d6f 100644 --- a/exporter/cmd/exporter/main.go +++ b/exporter/cmd/exporter/main.go @@ -22,10 +22,32 @@ const ( EXTRACTOR_ADDRESS string = "127.0.0.1:3000" ) +// GatherRuntimeInfoRequest represents the JSON body for POST requests +type GatherRuntimeInfoRequest struct { + ContainerIds []string `json:"containerIds"` +} + // gatherRuntimeInfo will trigger a new extraction of runtime info // and reply with a JSON payload func gatherRuntimeInfo(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { + var containerIds []string + + switch r.Method { + case "GET": + // GET scans all containers (empty containerIds) + containerIds = []string{} + case "POST": + var req GatherRuntimeInfoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error": "Invalid JSON in request body"}`, http.StatusBadRequest) + return + } + if req.ContainerIds == nil { + http.Error(w, `{"error": "containerIds field is required"}`, http.StatusBadRequest) + return + } + containerIds = req.ContainerIds + default: http.Error(w, "Method is not supported.", http.StatusNotFound) return } @@ -34,7 +56,8 @@ func gatherRuntimeInfo(w http.ResponseWriter, r *http.Request) { hash := hashParam == "" || hashParam == "true" startTime := time.Now() - dataPath, err := triggerRuntimeInfoExtraction() + dataPath, err := triggerRuntimeInfoExtraction(containerIds) + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -63,7 +86,7 @@ func gatherRuntimeInfo(w http.ResponseWriter, r *http.Request) { w.Write(response) } -func triggerRuntimeInfoExtraction() (string, error) { +func triggerRuntimeInfoExtraction(containerIds []string) (string, error) { conn, err := net.Dial("tcp", EXTRACTOR_ADDRESS) if err != nil { return "", err @@ -71,10 +94,21 @@ func triggerRuntimeInfoExtraction() (string, error) { defer conn.Close() log.Println("Requesting a new runtime extraction") + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return "", fmt.Errorf("failed to get TCP connection") + } + + // Send comma-separated container IDs (empty string if no specific containers requested) + payload := strings.Join(containerIds, ",") // write to TCP connection to trigger a runtime extraction - fmt.Fprintf(conn, "") + fmt.Fprintf(tcpConn, "%s", payload) + // and close the write side to signal EOF to the server + if err := tcpConn.CloseWrite(); err != nil { + return "", fmt.Errorf("failed to close write side: %w", err) + } - dataPath, err := bufio.NewReader(conn).ReadString('\n') + dataPath, err := bufio.NewReader(tcpConn).ReadString('\n') if err != nil { return "", err } diff --git a/extractor/src/bin/coordinator.rs b/extractor/src/bin/coordinator.rs index f82d1aac..83c77f1f 100644 --- a/extractor/src/bin/coordinator.rs +++ b/extractor/src/bin/coordinator.rs @@ -2,7 +2,7 @@ use clap::Parser; use log::info; use std::time::{SystemTime, UNIX_EPOCH}; -use insights_runtime_extractor::{config, file, get_container, get_containers, perms}; +use insights_runtime_extractor::{config, file, get_containers, perms}; #[derive(Parser, Debug)] #[command(about, long_about = None)] @@ -14,8 +14,10 @@ struct Args { )] log_level: Option, - #[arg(help = "ID of the container to scan. If absent, all containers are scanned")] - container_id: Option, + #[arg( + help = "Comma-separated list of container IDs to scan. If absent, all containers are scanned" + )] + container_ids: Option, } fn main() { @@ -43,13 +45,19 @@ fn main() { &exec_dir ); - let containers = match args.container_id { - None => get_containers(), - Some(container_id) => match get_container(&container_id) { - Some(container) => vec![container], - None => vec![], - }, - }; + let container_ids: Vec = args + .container_ids + .map(|ids| { + ids.split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect() + }) + .unwrap_or_default(); + + let containers = get_containers(container_ids); + + info!("Scanning {} containers", containers.len()); for container in containers { info!( diff --git a/extractor/src/bin/extractor_server.rs b/extractor/src/bin/extractor_server.rs index c6f7c868..b3105e4b 100644 --- a/extractor/src/bin/extractor_server.rs +++ b/extractor/src/bin/extractor_server.rs @@ -1,7 +1,7 @@ use clap::Parser; -use log::{error, info, trace}; +use log::{error, info, trace, warn}; use std::fs; -use std::io::Write; +use std::io::{Read, Write}; use std::net::{Shutdown, TcpListener, TcpStream}; use std::process::Command; use std::thread; @@ -60,10 +60,22 @@ fn handle_trigger_extraction(mut stream: TcpStream, log_level: String) { let start = Instant::now(); + // Read container IDs from TCP stream until EOF + let mut buffer = String::new(); + if let Err(e) = stream.read_to_string(&mut buffer) { + warn!("Failed to read from socket; err = {:?}", e); + if let Err(e) = stream.shutdown(Shutdown::Both) { + error!("Failed to shutdown socket; err = {:?}", e); + } + return; + } + let container_ids = buffer.trim().to_string(); + // Execute the "extractor_coordinator" program let output = Command::new("/coordinator") .arg("--log-level") .arg(log_level) + .arg(container_ids) .output(); match output { Ok(output) => { diff --git a/extractor/src/insights_runtime_extractor.rs b/extractor/src/insights_runtime_extractor.rs index acd049ec..e09be686 100644 --- a/extractor/src/insights_runtime_extractor.rs +++ b/extractor/src/insights_runtime_extractor.rs @@ -71,7 +71,7 @@ pub fn scan_container(config: &Config, out: &String, container: &Container) { let leaves = process::get_process_leaves(&root_pid); // fingerprint only the first process - info!("🔎 Fingerprinting {} processes...", leaves.len()); + debug!("🔎 Fingerprinting {} processes...", leaves.len()); if let Some(process) = leaves.get(0) { // create a directory to store this process' fingerprints diff --git a/extractor/src/insights_runtime_extractor/container.rs b/extractor/src/insights_runtime_extractor/container.rs index c7bf0a1a..1cd31f57 100644 --- a/extractor/src/insights_runtime_extractor/container.rs +++ b/extractor/src/insights_runtime_extractor/container.rs @@ -18,58 +18,13 @@ pub struct Container { pub pid: u32, } -pub fn get_container(container_id: &String) -> Option { - info!("🔎 Reading container information with crictl..."); - - let output = Command::new("crictl") - .args(["ps", "-o", "json", "-s", "RUNNING"]) - .output() - .expect("List containers with crictl"); - let json = String::from_utf8(output.stdout).unwrap(); - - let deserialized_containers: Value = serde_json::from_str(&json).unwrap(); - - let container_id = match container_id.strip_prefix("cri-o://") { - Some(container_id) => container_id.to_string(), - None => container_id.to_string(), - }; - - for c in deserialized_containers["containers"].as_array().unwrap() { - let id = c["id"].as_str().unwrap().to_string(); - - match id == container_id { - false => {} - true => { - let pod_namespace = c["labels"]["io.kubernetes.pod.namespace"] - .as_str() - .unwrap() - .to_string(); - let image_ref = c["imageRef"].as_str().unwrap().to_string(); - let name = c["labels"]["io.kubernetes.container.name"] - .as_str() - .unwrap() - .to_string(); - let pod_name = c["labels"]["io.kubernetes.pod.name"] - .as_str() - .unwrap() - .to_string(); - let pid: u32 = get_root_pid(&id); - return Some(Container { - id: "cri-o://".to_owned() + &id, - image_ref, - name, - pod_name, - pod_namespace, - pid, - }); - } - } - } - - None -} +pub fn get_containers(container_ids: Vec) -> Vec { + // Normalize container_ids by stripping the cri-o:// prefix + let ids_to_collect: Vec = container_ids + .iter() + .map(|id| id.strip_prefix("cri-o://").unwrap_or(id).to_string()) + .collect(); -pub fn get_containers() -> Vec { info!("🔎 Reading container information with crictl..."); let output = Command::new("crictl") @@ -85,12 +40,17 @@ pub fn get_containers() -> Vec { let mut containers: Vec = Vec::new(); for c in deserialized_containers["containers"].as_array().unwrap() { + let id = c["id"].as_str().unwrap().to_string(); + + // If ids_to_collect is not empty, skip containers not in the list + if !ids_to_collect.is_empty() && !ids_to_collect.contains(&id) { + continue; + } + let pod_namespace = c["labels"]["io.kubernetes.pod.namespace"] .as_str() .unwrap() .to_string(); - - let id = c["id"].as_str().unwrap().to_string(); let image_ref = c["imageRef"].as_str().unwrap().to_string(); let name = c["labels"]["io.kubernetes.container.name"] .as_str() diff --git a/extractor/src/lib.rs b/extractor/src/lib.rs index d495e98a..5396f916 100644 --- a/extractor/src/lib.rs +++ b/extractor/src/lib.rs @@ -1,7 +1,6 @@ mod insights_runtime_extractor; pub use crate::insights_runtime_extractor::config; -pub use crate::insights_runtime_extractor::container::get_container; pub use crate::insights_runtime_extractor::container::get_containers; pub use crate::insights_runtime_extractor::file; pub use crate::insights_runtime_extractor::perms;