From a2da4e085c6320b5a2a46e3533bc636027b690b9 Mon Sep 17 00:00:00 2001 From: Derek Date: Mon, 16 Nov 2026 04:03:29 +1100 Subject: [PATCH] fix(tests): replace the filebeat test archive with synthetic cases Remove tests/fixtures/filebeat/filebeat-testdata.tar.gz and scripts/filebeat/fetch_testdata.py. The filebeat integration tests, the Kafka e2e test and the PGO workload now replay 31 lines in tests/fixtures/filebeat/cases.yaml (12 Umbrella, 8 IOS, 11 Meraki). Each case carries the fields its output must have. The e2e test pairs outputs to cases by a root _case field instead of by position. Cargo.toml drops flate2 and tar, which only the archive reader used. The PGO workload cycles 31 events where it cycled about 370 lines. --- .gitleaks.toml | 3 +- Cargo.lock | 33 - Cargo.toml | 9 +- docs/internals.md | 4 +- pipelines/filebeat/README.md | 11 +- pipelines/filebeat/filebeat.vrl | 4 +- scripts/filebeat/fetch_testdata.py | 93 --- scripts/filebeat/prologue.vrl | 4 +- scripts/pgo-workload.sh | 8 +- src/bin/pgo_driver.rs | 37 +- tests/TESTING.md | 2 +- tests/common/filebeat.rs | 464 +++--------- tests/e2e.rs | 2 +- tests/e2e/filebeat_kafka.rs | 155 ++-- tests/fixtures/filebeat/README.md | 66 +- tests/fixtures/filebeat/cases.yaml | 715 ++++++++++++++++++ .../filebeat/filebeat-testdata.tar.gz | Bin 50952 -> 0 bytes tests/integration.rs | 4 +- tests/integration/direct_transport.rs | 2 +- tests/integration/filebeat_pipeline.rs | 152 ++-- 20 files changed, 979 insertions(+), 789 deletions(-) delete mode 100644 scripts/filebeat/fetch_testdata.py create mode 100644 tests/fixtures/filebeat/cases.yaml delete mode 100644 tests/fixtures/filebeat/filebeat-testdata.tar.gz diff --git a/.gitleaks.toml b/.gitleaks.toml index 6a7d39c..a9d6be3 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -9,8 +9,7 @@ useDefault = true [allowlist] -description = "Bundled filebeat-compat pipeline data - ported public Vector templates and pinned elastic/integrations test fixtures, not credentials (e.g. the Meraki event subtype '8021x_eap_failure' pattern-matches a key rule)" +description = "Bundled filebeat-compat pipeline data - ported public Vector templates, not credentials (e.g. the Meraki event subtype '8021x_eap_failure' pattern-matches a key rule)" paths = [ '''pipelines/filebeat/filebeat\.vrl''', - '''tests/fixtures/filebeat/filebeat-testdata\.tar\.gz''', ] diff --git a/Cargo.lock b/Cargo.lock index 87d6dc3..8490bb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,7 +997,6 @@ dependencies = [ "csv", "dotenvy", "figment", - "flate2", "maxminddb", "metrics", "rdkafka", @@ -1008,7 +1007,6 @@ dependencies = [ "serde", "serde_json", "sonic-rs", - "tar", "temp-env", "tempfile", "thiserror", @@ -1310,16 +1308,6 @@ dependencies = [ "flate2", ] -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -4848,17 +4836,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "temp-env" version = "0.3.6" @@ -6139,16 +6116,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xxhash-rust" version = "0.8.19" diff --git a/Cargo.toml b/Cargo.toml index 0b645f8..49a9b2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,23 +125,16 @@ tikv-jemallocator = { version = ">=0.7.0, <0.8", optional = true } # Direct rdkafka for the pgo-driver bin only. The wrapper uses scalo's Kafka transport. rdkafka = { version = ">=0.39, <0.40", features = ["tokio", "cmake-build"], optional = true } -# The pgo-driver bin reads the filebeat corpus archive in memory; same ranges as -# the dev-dependencies so cargo unifies to one of each. -flate2 = { version = ">=1.1.10, <2", optional = true } -tar = { version = ">=0.4.46, <0.5", optional = true } [features] default = [] jemalloc = ["dep:tikv-jemallocator"] -pgo-driver = ["dep:rdkafka", "dep:flate2", "dep:tar"] +pgo-driver = ["dep:rdkafka"] enrichment-mmdb = ["dep:maxminddb"] enrichment-sqlite = ["dep:rusqlite"] [dev-dependencies] tempfile = ">=3.27.0, <4" -# Reads the committed filebeat corpus tar.gz in memory. -flate2 = ">=1.1.10, <2" -tar = ">=0.4.46, <0.5" # A dev-dependency, not a crate feature, so CI's `features: default` still builds # the broker-free pipeline tests. scalo = { version = ">=2.14.30, <3", features = [ diff --git a/docs/internals.md b/docs/internals.md index 7bf48a5..c0d24f7 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -70,8 +70,8 @@ pure-VRL file plus its `timezones.csv` enrichment table. It is convenience data, not engine capability: opting in means pointing the standard `transforms.dir` and `enrichment_tables` config at those files. The engine carries no filebeat-specific code, and the integration tests -(`tests/integration/filebeat_pipeline.rs`) drive the bundle with the real -elastic/integrations pipeline test corpus, which doubles as a full-engine +(`tests/integration/filebeat_pipeline.rs`) drive the bundle with the synthetic +cases in `tests/fixtures/filebeat/cases.yaml`, which double as a full-engine workload. INTERIM: elastic compatibility is being replaced by `dfe-transform-elastic` diff --git a/pipelines/filebeat/README.md b/pipelines/filebeat/README.md index 5b568da..b02bee3 100644 --- a/pipelines/filebeat/README.md +++ b/pipelines/filebeat/README.md @@ -3,8 +3,8 @@ A pre-canned, opt-in VRL pipeline that keeps DFE 2.1 filebeat feeds working on dfe-transform-vrl. It is pure VRL plus one CSV enrichment table - no binary capability, no config surface. Use it if you want it, ignore it if -you do not. It also doubles as a real-data integration test workload for -the engine (`tests/integration/filebeat_pipeline.rs`). +you do not. It also doubles as an integration test workload for the engine +(`tests/integration/filebeat_pipeline.rs`). > **INTERIM.** Elastic compatibility is being replaced by > `dfe-transform-elastic` (Rust-native). New integrations should NOT @@ -89,7 +89,7 @@ still error the event out (dropped and counted), matching the 2.1 port routes slash-bearing zone names through `parse_timestamp`'s `timezone:` argument (DST-aware via the tz database). -## Known limitations (verified against the elastic golden corpus) +## Known limitations - `user_agent` classification follows VRL's parser, which differs cosmetically from elastic's ("Mac OSX" vs "Mac OS X"). A @@ -109,9 +109,8 @@ still error the event out (dropped and counted), matching the 2.1 Generated from dfe-vector-templates `origin/main` @ `96eb306` by `scripts/filebeat/extract.py` (verifies the seven umbrella templates are identical before collapsing them, strips geoip, unescapes Vector `$$`, -prepends `scripts/filebeat/prologue.vrl`). Validated line-by-line against -the elastic/integrations pipeline test corpus @ `c7bc530` - see -`tests/fixtures/filebeat/README.md`. +prepends `scripts/filebeat/prologue.vrl`). The tests replay the synthetic +lines in `tests/fixtures/filebeat/cases.yaml`. Treat `filebeat.vrl` as generated-then-patched: fixes belong here (it is the shipping artefact), but substantial rework should go back through the diff --git a/pipelines/filebeat/filebeat.vrl b/pipelines/filebeat/filebeat.vrl index 23bec9c..d463c96 100644 --- a/pipelines/filebeat/filebeat.vrl +++ b/pipelines/filebeat/filebeat.vrl @@ -63,8 +63,8 @@ if match(_msg, r'^<134>1 \d{9,10}\.\d+ ') { } else { # Cisco Umbrella exports are CSV; DFE 2.1 carried the log type in the # S3 object path. Honour an upstream-provided .log.file.path first, - # then fall back to column-shape detection (counts verified against - # the elastic/integrations pipeline test corpus). + # then fall back to column-shape detection (the umbrella cases in + # tests/fixtures/filebeat/cases.yaml cover each log type). _path = string(.log.file.path) ?? "" if contains(_path, "auditlogs") { _umbrella_type = "auditlogs" diff --git a/scripts/filebeat/fetch_testdata.py b/scripts/filebeat/fetch_testdata.py deleted file mode 100644 index 28d12e9..0000000 --- a/scripts/filebeat/fetch_testdata.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Fetch the cisco filebeat pipeline test corpora from elastic/integrations -at a pinned SHA using the gh CLI (authenticated, read-only contents API), -for refreshing tests/fixtures/filebeat/filebeat-testdata.tar.gz. - -Usage: - python3 scripts/filebeat/fetch_testdata.py - tar -czf tests/fixtures/filebeat/filebeat-testdata.tar.gz \ - -C cisco_umbrella cisco_ios cisco_meraki LICENSE.txt - -Keep SHA aligned with the dfe-transform-elastic testdata pin unless the -bundled VRL is regenerated against a newer template vintage (see -tests/fixtures/filebeat/README.md). -""" - -from __future__ import annotations - -import base64 -import json -import subprocess -import sys -from pathlib import Path - -REPO = "elastic/integrations" -SHA = "c7bc53023288c7d34afd631c0c3e3552f6219419" - -DIRS = { - "cisco_umbrella/log": "packages/cisco_umbrella/data_stream/log/_dev/test/pipeline", - "cisco_ios/log": "packages/cisco_ios/data_stream/log/_dev/test/pipeline", - "cisco_meraki/log": "packages/cisco_meraki/data_stream/log/_dev/test/pipeline", -} - -EXTRAS = { - "LICENSE.txt": "LICENSE.txt", - "licenses/Elastic-2.0.txt": "licenses/Elastic-2.0.txt", - "cisco_umbrella/manifest.yml": "packages/cisco_umbrella/manifest.yml", - "cisco_ios/manifest.yml": "packages/cisco_ios/manifest.yml", - "cisco_meraki/manifest.yml": "packages/cisco_meraki/manifest.yml", -} - - -def gh_api(path: str) -> bytes: - res = subprocess.run( - ["gh", "api", path], - capture_output=True, - check=False, - ) - if res.returncode != 0: - print(f"FAIL: gh api {path}: {res.stderr.decode('utf-8', 'replace')}", - file=sys.stderr) - sys.exit(1) - return res.stdout - - -def fetch_file(repo_path: str, dest: Path) -> int: - obj = json.loads(gh_api(f"repos/{REPO}/contents/{repo_path}?ref={SHA}")) - if obj.get("encoding") != "base64": - print(f"FAIL: unexpected encoding for {repo_path}: " - f"{obj.get('encoding')}", file=sys.stderr) - sys.exit(1) - data = base64.b64decode(obj["content"]) - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(data) - return len(data) - - -def main() -> None: - if len(sys.argv) != 2: - print(__doc__, file=sys.stderr) - sys.exit(2) - out = Path(sys.argv[1]) - total = 0 - n = 0 - for local_dir, repo_dir in DIRS.items(): - listing = json.loads(gh_api(f"repos/{REPO}/contents/{repo_dir}?ref={SHA}")) - for entry in listing: - if entry["type"] != "file": - continue - size = fetch_file( - f"{repo_dir}/{entry['name']}", out / local_dir / entry["name"] - ) - total += size - n += 1 - print(f" {local_dir}/{entry['name']} {size}B") - for local_path, repo_path in EXTRAS.items(): - size = fetch_file(repo_path, out / local_path) - total += size - n += 1 - print(f" {local_path} {size}B") - print(f"fetched {n} files, {total} bytes, pinned {SHA}") - - -if __name__ == "__main__": - main() diff --git a/scripts/filebeat/prologue.vrl b/scripts/filebeat/prologue.vrl index b804168..062065d 100644 --- a/scripts/filebeat/prologue.vrl +++ b/scripts/filebeat/prologue.vrl @@ -63,8 +63,8 @@ if match(_msg, r'^<134>1 \d{9,10}\.\d+ ') { } else { # Cisco Umbrella exports are CSV; DFE 2.1 carried the log type in the # S3 object path. Honour an upstream-provided .log.file.path first, - # then fall back to column-shape detection (counts verified against - # the elastic/integrations pipeline test corpus). + # then fall back to column-shape detection (the umbrella cases in + # tests/fixtures/filebeat/cases.yaml cover each log type). _path = string(.log.file.path) ?? "" if contains(_path, "auditlogs") { _umbrella_type = "auditlogs" diff --git a/scripts/pgo-workload.sh b/scripts/pgo-workload.sh index 20bff06..2a6f0ea 100755 --- a/scripts/pgo-workload.sh +++ b/scripts/pgo-workload.sh @@ -10,8 +10,8 @@ # Usage: # scripts/pgo-workload.sh # -# Runs the shipped filebeat pipeline (pipelines/filebeat) over the committed -# elastic/integrations corpus, so a PGO-instrumented binary profiles the hot +# Runs the shipped filebeat pipeline (pipelines/filebeat) over the synthetic +# cases in tests/fixtures/filebeat/cases.yaml, so a PGO-instrumented binary profiles the hot # path it ships with: Kafka consume -> JSON parse -> VRL (parse_groks, # parse_timestamp, the timezones lookup) -> JSON serialise -> Kafka produce. # @@ -34,7 +34,7 @@ # - Writes an ephemeral wrapper config pointing at pipelines/filebeat # - Starts the passed-in wrapper binary in background # - Waits for the wrapper's /readyz to return 200 -# - Runs pgo-driver, which produces the corpus for the configured duration +# - Runs pgo-driver, which produces the case events for the configured duration # and exits non-zero unless the wrapper's /metrics shows delivered records # and fewer errors than deliveries -- so a workload that transformed # nothing fails here instead of profiling an idle binary @@ -295,7 +295,7 @@ if ! PGO_DRIVER_DURATION_SECS="$DURATION" \ PGO_DRIVER_RPS="${PGO_DRIVER_RPS:-5000}" \ PGO_DRIVER_METRICS_ADDR="127.0.0.1:$METRICS_PORT" \ "$PGO_DRIVER_PATH"; then - echo "error: the workload did not transform and deliver the corpus" >&2 + echo "error: the workload did not transform and deliver the cases" >&2 tail -100 "$WORK_DIR/wrapper.log" >&2 exit 1 fi diff --git a/src/bin/pgo_driver.rs b/src/bin/pgo_driver.rs index f6f07ba..9a47f60 100644 --- a/src/bin/pgo_driver.rs +++ b/src/bin/pgo_driver.rs @@ -8,7 +8,7 @@ //! PGO workload driver for `dfe-transform-vrl`. //! -//! Replays the elastic/integrations filebeat corpus onto the source topic, as +//! Replays the synthetic filebeat cases onto the source topic, as //! the input events the bundled filebeat pipeline reads, so a PGO-instrumented //! `dfe-transform-vrl` profiles the pipeline it ships with: Kafka consume -> //! JSON parse -> `parse_groks`, `parse_timestamp` and the timezones lookup -> @@ -45,9 +45,9 @@ clippy::cast_sign_loss )] -// The same corpus reader and input-event shape the filebeat test suites use. +// The same case loader and input-event shape the filebeat test suites use. #[path = "../../tests/common/filebeat.rs"] -mod filebeat_corpus; +mod filebeat_cases; use std::env; use std::sync::Arc; @@ -58,7 +58,7 @@ use rdkafka::ClientConfig; use rdkafka::producer::{FutureProducer, FutureRecord, Producer}; use tokio::time::{MissedTickBehavior, interval}; -use crate::filebeat_corpus as fb; +use crate::filebeat_cases as fb; /// How often the producer tops the sent count up to the configured rate. const PRODUCE_TICK: Duration = Duration::from_millis(10); @@ -68,11 +68,10 @@ async fn main() { let cfg = Config::from_env(); println!("pgo-driver starting: {cfg:#?}"); - let events = corpus_events(); + let events = case_events(); println!( - "pgo-driver: replaying {} filebeat corpus events from {} logs", - events.len(), - fb::all_logs().len() + "pgo-driver: replaying {} filebeat case events", + events.len() ); let producer: FutureProducer = match ClientConfig::new() @@ -178,20 +177,16 @@ fn env_parsed(key: &str, default: T) -> T { } // =========================================================================== -// Corpus +// Cases // =========================================================================== -/// Every corpus line as the input event the bundled pipeline reads. -fn corpus_events() -> Vec> { - let mut events = Vec::new(); - for log in fb::all_logs() { - let conf = fb::corpus_conf(log); - for line in fb::corpus_lines(log) { - let event = fb::input_event(&line, conf.as_ref()); - events.push(serde_json::to_vec(&event).expect("a corpus event serialises")); - } - } - assert!(!events.is_empty(), "the filebeat corpus has no events"); +/// Every case line as the input event the bundled pipeline reads. +fn case_events() -> Vec> { + let events: Vec> = fb::cases() + .iter() + .map(|case| serde_json::to_vec(&fb::case_event(case)).expect("a case event serialises")) + .collect(); + assert!(!events.is_empty(), "the filebeat cases file has no events"); events } @@ -228,7 +223,7 @@ impl Stats { } } -/// Queue corpus events at `cfg.rps` until `deadline`, cycling the corpus. +/// Queue case events at `cfg.rps` until `deadline`, cycling through them. async fn produce( producer: &FutureProducer, cfg: &Config, diff --git a/tests/TESTING.md b/tests/TESTING.md index d5d6abb..7d29fa7 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -8,7 +8,7 @@ Unit tests run without any external infrastructure, embedded in the source files cargo nextest run --lib ``` -The integration tests in `tests/` run without a broker: VRL compilation, transforms, config, enrichment, the filebeat corpus, the direct transport on local ports, and the pipeline over scalo's in-memory transport. +The integration tests in `tests/` run without a broker: VRL compilation, transforms, config, enrichment, the filebeat cases, the direct transport on local ports, and the pipeline over scalo's in-memory transport. The core tier is every test that is not `#[ignore]`: unit, integration, smoke, and the e2e tests that use a broker container scalo starts (`filebeat_kafka`, `held_acks`, `kafka_tls`). PR and push runs use it, and so does `hyperi-ci check`. diff --git a/tests/common/filebeat.rs b/tests/common/filebeat.rs index 98c9b3f..4af03dd 100644 --- a/tests/common/filebeat.rs +++ b/tests/common/filebeat.rs @@ -1,164 +1,108 @@ // Project: dfe-transform-vrl // File: tests/common/filebeat.rs -// Purpose: The filebeat corpus and its golden comparison, shared by both suites +// Purpose: The filebeat test cases and their comparison, shared by every suite that replays them // Language: Rust // // License: BUSL-1.1 // Copyright: (c) 2026 HYPERI PTY LIMITED -// A missing fixture is unrecoverable and has no caller to report it to, so the -// reader panics rather than threading a Result through every test. +// A missing or malformed case file is unrecoverable and has no caller to +// report it to, so the loader panics rather than threading a Result through +// every test. #![allow(dead_code, clippy::panic)] -//! The elastic/integrations filebeat corpus, and what counts as agreeing -//! with it. +//! The synthetic filebeat cases in `tests/fixtures/filebeat/cases.yaml`. //! -//! Two suites drive the same bundled pipeline over the same data: -//! `tests/integration/filebeat_pipeline.rs` runs it in process, and +//! Three suites drive the bundled pipeline over the same cases: +//! `tests/integration/filebeat_pipeline.rs` runs it in process, //! `tests/e2e/filebeat_kafka.rs` runs it through the app binary and a real -//! broker. They must agree on what a match IS, or the second one silently -//! grades on a different curve -- so the corpus reader, the ignore list, the -//! accepted divergences and the comparison all live here. -//! -//! The archive is read into memory and NEVER unpacked to the working tree: -//! it carries Elastic-licensed data whose terms travel with it. +//! broker, and `src/bin/pgo_driver.rs` replays the lines as a load. They must +//! agree on what a match IS, so the loader, the input shape and the +//! comparison all live here. -use std::collections::BTreeMap; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::OnceLock; +use serde::Deserialize; + /// A repo-relative path, resolved against this crate's root. pub fn repo_path(rel: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join(rel) } // ========================================================================= -// The corpus +// The cases // ========================================================================= -/// Every umbrella log in the corpus, in the order the suites replay them. -pub const UMBRELLA_LOGS: &[&str] = &[ - "cisco_umbrella/log/test-umbrella-auditlogs.log", - "cisco_umbrella/log/test-umbrella-cloudfirewalllogs.log", - "cisco_umbrella/log/test-umbrella-dlplogs.log", - "cisco_umbrella/log/test-umbrella-dnslogs.log", - "cisco_umbrella/log/test-umbrella-intrusionlogs.log", - "cisco_umbrella/log/test-umbrella-iplogs.log", - "cisco_umbrella/log/test-umbrella-proxylogs.log", -]; - -/// Every `cisco_ios` log in the corpus. -pub const IOS_LOGS: &[&str] = &[ - "cisco_ios/log/test-asr920.log", - "cisco_ios/log/test-badauth.log", - "cisco_ios/log/test-cisco-ios.log", - "cisco_ios/log/test-date-format-tzoffset.log", - "cisco_ios/log/test-date-format.log", - "cisco_ios/log/test-syslog-header.log", - "cisco_ios/log/test-syslog.log", - "cisco_ios/log/test-tzoffset.log", -]; - -/// Every `cisco_meraki` log in the corpus. -pub const MERAKI_LOGS: &[&str] = &[ - "cisco_meraki/log/test-airmarshal-events.log", - "cisco_meraki/log/test-events.log", - "cisco_meraki/log/test-flows.log", - "cisco_meraki/log/test-ip-flow.log", - "cisco_meraki/log/test-security-events.log", - "cisco_meraki/log/test-urls.log", -]; - -/// Every log file the corpus carries, all three modules. -pub fn all_logs() -> Vec<&'static str> { - let mut out = Vec::with_capacity(UMBRELLA_LOGS.len() + IOS_LOGS.len() + MERAKI_LOGS.len()); - out.extend_from_slice(UMBRELLA_LOGS); - out.extend_from_slice(IOS_LOGS); - out.extend_from_slice(MERAKI_LOGS); - out -} - -/// The committed corpus archive, read once and held in memory. -pub fn corpus() -> &'static BTreeMap> { - static DATA: OnceLock>> = OnceLock::new(); - DATA.get_or_init(|| { - let path = repo_path("tests/fixtures/filebeat/filebeat-testdata.tar.gz"); - let file = std::fs::File::open(&path).expect("fixtures archive must exist"); - let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file)); - let mut map = BTreeMap::new(); - for entry in archive.entries().expect("read fixtures tar") { - let mut entry = entry.expect("fixtures tar entry"); - if !entry.header().entry_type().is_file() { - continue; - } - let name = entry - .path() - .expect("entry path") - .to_string_lossy() - .trim_start_matches("./") - .to_string(); - let mut buf = Vec::new(); - std::io::Read::read_to_end(&mut entry, &mut buf).expect("read entry"); - map.insert(name, buf); +/// The filebeat module a case's line belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Module { + Meraki, + Ios, + Umbrella, +} + +/// One input line and what the pipeline must make of it. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Case { + /// Unique across the file. The e2e suite carries it through the broker to + /// pair each output with its case. + pub name: String, + pub module: Module, + /// The `_conf` object the DFE 2.1 deployment injected (`tz_offset`). + #[serde(default)] + pub conf: Option, + /// The raw log line, as the `message` field. + pub line: String, + /// A subset of the transformed event: every leaf named here must be + /// present and equal in the output. Fields the case does not name are not + /// checked. + pub expect: serde_json::Value, +} + +/// Every case in the file, read once. +pub fn cases() -> &'static [Case] { + static CASES: OnceLock> = OnceLock::new(); + CASES.get_or_init(|| { + let path = repo_path("tests/fixtures/filebeat/cases.yaml"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let cases: Vec = + scalo::yaml::from_str(&text, "cases.yaml", scalo::yaml::Profile::Data) + .unwrap_or_else(|e| panic!("parse cases.yaml: {e}")); + let mut seen = HashSet::new(); + for case in &cases { + assert!( + seen.insert(case.name.as_str()), + "duplicate case name {}", + case.name + ); } - assert!(!map.is_empty(), "fixtures archive is empty"); - map + cases }) } -/// One corpus member as text. -pub fn corpus_text(name: &str) -> String { - let bytes = corpus() - .get(name) - .unwrap_or_else(|| panic!("fixture {name} missing from archive")); - String::from_utf8_lossy(bytes).into_owned() -} - -/// The non-blank lines of one corpus log. -pub fn corpus_lines(name: &str) -> Vec { - corpus_text(name) - .lines() - .filter(|l| !l.trim().is_empty()) - .map(str::to_string) - .collect() -} - -/// The golden events for one corpus log. -pub fn corpus_expected(name: &str) -> Vec { - let doc: serde_json::Value = - serde_json::from_str(&corpus_text(name)).expect("expected.json parses"); - doc.get("expected") - .and_then(|e| e.as_array()) - .expect("expected array") - .clone() +/// The cases for one module. +pub fn cases_for(module: Module) -> Vec<&'static Case> { + cases().iter().filter(|c| c.module == module).collect() } -/// The `fields._conf` object for a corpus file (`tz_offset`, `tz_map`, ...): -/// `-config.yml` wins, else the module's `test-common-config.yml` -/// (mirrors the elastic test harness). -pub fn corpus_conf(log_name: &str) -> Option { - let per_file = format!("{log_name}-config.yml"); - let dir = log_name.rsplit_once('/').map_or("", |(d, _)| d); - let common = format!("{dir}/test-common-config.yml"); - for candidate in [per_file, common] { - if let Some(bytes) = corpus().get(&candidate) { - let text = std::str::from_utf8(bytes).expect("config yml is UTF-8"); - let doc: serde_json::Value = - scalo::yaml::from_str(text, &candidate, scalo::yaml::Profile::Data) - .expect("config yml parses"); - if let Some(conf) = doc.get("fields").and_then(|f| f.get("_conf")) { - return Some(conf.clone()); - } - } - } - None +/// One case by name. +pub fn case(name: &str) -> &'static Case { + cases() + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("no case named {name}")) } /// A DFE 2.1-shaped input event: `{message, tags, timestamp}` plus the /// `_conf` object the 2.1 deployment injected (`tz_offset`, `tz_map`). /// -/// This is the shape a transform reads off `{source}_land`, so both the -/// in-process suite and the broker round-trip build their input with it. +/// This is the shape a transform reads off `{source}_land`, so every suite +/// builds its input with it. pub fn input_event(message: &str, conf: Option<&serde_json::Value>) -> serde_json::Value { let mut obj = serde_json::json!({ "message": message, @@ -171,6 +115,11 @@ pub fn input_event(message: &str, conf: Option<&serde_json::Value>) -> serde_jso obj } +/// The input event for one case. +pub fn case_event(case: &Case) -> serde_json::Value { + input_event(&case.line, case.conf.as_ref()) +} + /// An IOS access-list line whose body is 5000 spaces, on which onig panics /// inside the filebeat program's grok. pub fn ios_grok_panic_line() -> String { @@ -181,198 +130,21 @@ pub fn ios_grok_panic_line() -> String { } // ========================================================================= -// What counts as agreeing with the goldens +// What counts as agreeing with a case // ========================================================================= -/// Paths where the port deliberately diverges from the elastic goldens. -/// geoip enrichment is stripped (dfe-loader owns geoip), event.ingested -/// is elastic-harness-dynamic, log.file.path carries our routing seed. -pub const IGNORED_PATHS: &[&str] = &[ - "source.geo", - "source.as", - "destination.geo", - "destination.as", - "client.geo", - "client.as", - "event.ingested", - "log.file.path", - // VRL's parse_user_agent (woothee/ua-parser) classifies differently - // from elastic's user_agent processor ("Mac OSX" vs "Mac OS X", - // device "pc" vs "Mac"). Engine-library parity, not a port defect; - // user_agent.original stays asserted. - "user_agent.name", - "user_agent.os", - "user_agent.device", - "user_agent.version", - "user_agent.major", - "user_agent.minor", - "user_agent.patch", -]; - -fn ignored(path: &str) -> bool { - IGNORED_PATHS - .iter() - .any(|p| path == *p || path.starts_with(&format!("{p}."))) -} - -/// Narrow per-line divergences from the goldens, each with a cause that is -/// library parity or an elastic-integration feature the 2.1 templates -/// never had (see pipelines/filebeat/README.md, Limitations). -pub const ACCEPTED_MISMATCHES: &[(&str, usize, &str)] = &[ - // VRL's public-suffix data includes the PSL private section - // (*.servicebus.windows.net); elastic's registered_domain processor - // uses the ICANN section only. - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 6, - "dns.question.registered_domain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 6, - "dns.question.subdomain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 6, - "dns.question.top_level_domain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 9, - "dns.question.registered_domain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 9, - "dns.question.subdomain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 9, - "dns.question.top_level_domain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 10, - "dns.question.registered_domain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 10, - "dns.question.subdomain", - ), - ( - "cisco_umbrella/log/test-umbrella-dnslogs.log", - 10, - "dns.question.top_level_domain", - ), - // VRL parse_url normalises an empty path to "/"; elastic keeps "". - ( - "cisco_umbrella/log/test-umbrella-dlplogs.log", - 0, - "url.path", - ), - // VRL is_nullish(" ") is true, so the template skips user_agent - // entirely for whitespace-only UA strings; elastic still emits - // user_agent.original " " with name "Other". Library semantics. - ( - "cisco_umbrella/log/test-umbrella-proxylogs.log", - 7, - "user_agent", - ), - ( - "cisco_umbrella/log/test-umbrella-proxylogs.log", - 8, - "user_agent", - ), - ( - "cisco_umbrella/log/test-umbrella-proxylogs.log", - 9, - "user_agent", - ), - ( - "cisco_umbrella/log/test-umbrella-proxylogs.log", - 10, - "user_agent", - ), - ( - "cisco_umbrella/log/test-umbrella-proxylogs.log", - 11, - "user_agent", - ), -]; - -fn accepted_mismatch(file: &str, idx: usize, path: &str) -> bool { - ACCEPTED_MISMATCHES - .iter() - .any(|(f, i, p)| *f == file && *i == idx && *p == path) -} - -/// Whether a corpus file's timestamps may differ in the year alone. -/// -/// `cisco_ios` only: its classic "Feb 8 04:00:48" format carries no year, so -/// the parser infers the CURRENT year while the goldens embed their -/// generation year. -pub fn allows_year_swap(log_name: &str) -> bool { - log_name.starts_with("cisco_ios/") -} - -/// Timestamps match at millisecond precision, with the year-swap tolerance -/// applied when the corpus file has no year to parse. -fn timestamps_match(ours: &str, exp: &str, allow_year_swap: bool) -> bool { - use chrono::{DateTime, Datelike, SubsecRound}; - let (Ok(a), Ok(b)) = ( - DateTime::parse_from_rfc3339(ours), - DateTime::parse_from_rfc3339(exp), - ) else { - return false; - }; - let a = a.trunc_subsecs(3); - let b = b.trunc_subsecs(3); - if a == b { - return true; - } - if !allow_year_swap { - return false; - } - let (Some(a), Some(b)) = (a.with_year(2000), b.with_year(2000)) else { - return false; - }; - a == b -} - -/// Canonical query-string compare: same key -> multiset of values, -/// independent of parameter order (the 2.1 template rebuilds url.query -/// from a VRL object, which alphabetises; elastic keeps raw order). -fn query_params(q: &str) -> BTreeMap> { - let mut map: BTreeMap> = BTreeMap::new(); - for pair in q.split('&').filter(|s| !s.is_empty()) { - let (k, v) = pair.split_once('=').unwrap_or((pair, "")); - map.entry(k.to_string()).or_default().push(v.to_string()); - } - for values in map.values_mut() { - values.sort(); - } - map -} - -/// Compare one transformed event against its elastic golden: every leaf in -/// `expected` (bar the documented ignores) must be present and equal in -/// ours. Extra fields on our side (DFE 2.1 event carriage like `_conf`, -/// `timestamp`) are fine. The root `tags` array compares as a set (the -/// 2.1 template seeds tags in a different order). +/// Compare one transformed event against a case's `expect`: every leaf in +/// `expected` must be present and equal in `ours`. Extra fields on our side +/// (DFE 2.1 event carriage like `_conf`, `timestamp`) are fine. The root +/// `tags` array compares as a set, because the 2.1 template seeds tags in a +/// fixed but arbitrary order. pub fn diff_expected( path: &str, - exp: &serde_json::Value, + expected: &serde_json::Value, ours: &serde_json::Value, - allow_year_swap: bool, problems: &mut Vec, ) { - if ignored(path) { - return; - } - match exp { + match expected { serde_json::Value::Object(map) => { for (k, v) in map { let child = if path.is_empty() { @@ -381,85 +153,45 @@ pub fn diff_expected( format!("{path}.{k}") }; match ours.get(k) { - Some(o) => diff_expected(&child, v, o, allow_year_swap, problems), - None => { - if !ignored(&child) { - problems.push(format!("{child}: missing (expected {v})")); - } - } + Some(o) => diff_expected(&child, v, o, problems), + None => problems.push(format!("{child}: missing (expected {v})")), } } } serde_json::Value::Array(items) if path == "tags" => { - let ours_set: Vec<&serde_json::Value> = ours - .as_array() - .map(|a| a.iter().collect()) - .unwrap_or_default(); + let have = ours.as_array().map(Vec::as_slice).unwrap_or_default(); for item in items { - if !ours_set.contains(&item) { + if !have.contains(item) { problems.push(format!("tags: missing {item}")); } } } serde_json::Value::Array(items) => match ours.as_array() { - Some(ours_arr) if ours_arr.len() == items.len() => { - for (i, (e, o)) in items.iter().zip(ours_arr).enumerate() { - diff_expected(&format!("{path}[{i}]"), e, o, allow_year_swap, problems); + Some(have) if have.len() == items.len() => { + for (i, (e, o)) in items.iter().zip(have).enumerate() { + diff_expected(&format!("{path}[{i}]"), e, o, problems); } } - _ => problems.push(format!("{path}: expected array {exp}, got {ours}")), + _ => problems.push(format!("{path}: expected array {expected}, got {ours}")), }, leaf => { - if path == "@timestamp" || path.ends_with(".@timestamp") { - let ours_s = ours.as_str().unwrap_or_default(); - let exp_s = leaf.as_str().unwrap_or_default(); - if !timestamps_match(ours_s, exp_s, allow_year_swap) { - problems.push(format!("{path}: expected {exp_s}, got {ours_s}")); - } - } else if path == "url.query" { - let ours_s = ours.as_str().unwrap_or_default(); - let exp_s = leaf.as_str().unwrap_or_default(); - if query_params(ours_s) != query_params(exp_s) { - problems.push(format!( - "{path}: params differ, expected {exp_s}, got {ours_s}" - )); - } - } else if ours != leaf { + if ours != leaf { problems.push(format!("{path}: expected {leaf}, got {ours}")); } } } } -/// Everything wrong with `ours` as the transform of `log_name[idx]`, after -/// the documented ignores and that line's accepted divergences are removed. -/// Empty means it agrees with the golden. -pub fn problems_against_golden( - log_name: &str, - idx: usize, - expected: &serde_json::Value, - ours: &serde_json::Value, -) -> Vec { +/// Everything wrong with `ours` as the transform of `case`. Empty means it +/// agrees. +pub fn problems_for(case: &Case, ours: &serde_json::Value) -> Vec { let mut problems = Vec::new(); - diff_expected( - "", - expected, - ours, - allows_year_swap(log_name), - &mut problems, - ); + diff_expected("", &case.expect, ours, &mut problems); problems - .into_iter() - .filter(|p| { - let path = p.split(':').next().unwrap_or_default(); - !accepted_mismatch(log_name, idx, path) - }) - .collect() } /// Whether the pipeline routed this event to the shape-unmatched catch-all. -/// Every corpus line belongs to a known module, so this must never fire on -/// one. +/// Every case belongs to a known module, so this must never fire on one. pub fn is_unmatched(event: &serde_json::Value) -> bool { event .get("tags") diff --git a/tests/e2e.rs b/tests/e2e.rs index 678472e..6dbaef1 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -24,7 +24,7 @@ mod common; #[allow(clippy::unwrap_used, clippy::expect_used)] #[path = "common/filebeat.rs"] -mod filebeat_corpus; +mod filebeat_cases; #[allow(clippy::expect_used, clippy::panic)] #[path = "e2e/filebeat_kafka.rs"] diff --git a/tests/e2e/filebeat_kafka.rs b/tests/e2e/filebeat_kafka.rs index d0d1181..c3b97b2 100644 --- a/tests/e2e/filebeat_kafka.rs +++ b/tests/e2e/filebeat_kafka.rs @@ -1,15 +1,15 @@ // Project: dfe-transform-vrl // File: tests/e2e/filebeat_kafka.rs -// Purpose: The filebeat corpus through the app and a broker +// Purpose: The filebeat cases through the app and a broker // Language: Rust // // License: BUSL-1.1 // Copyright: (c) 2026 HYPERI PTY LIMITED -//! The transform hop of the filebeat chain, proved on real data. +//! The transform hop of the filebeat chain, proved on synthetic cases. //! //! ```text -//! filebeat corpus -> filebeat_land -> dfe-transform-vrl -> filebeat_load +//! filebeat cases -> filebeat_land -> dfe-transform-vrl -> filebeat_load //! ``` //! //! What makes this an acceptance test rather than another unit test: @@ -20,9 +20,9 @@ //! - The topics are the real source-bound names. `filebeat_land` in and //! `filebeat_load` out is what a `filebeat` Source compiles to, and the //! app derives the source name back out of the input topic. -//! - The data is the elastic/integrations corpus, and the output is graded -//! against its golden events by the same comparison the in-process suite -//! uses (`tests/common/filebeat.rs`). +//! - The data is `tests/fixtures/filebeat/cases.yaml`, and each output is +//! graded against the fields its case names by the same comparison the +//! in-process suite uses (`tests/common/filebeat.rs`). //! //! The broker is a container this test starts and drops, never a live one: //! `filebeat_land` on a shared broker is somebody else's data. @@ -37,7 +37,7 @@ use scalo::transport::kafka::{KafkaAdmin, KafkaConfig, KafkaProfile, KafkaTransp use scalo::transport::{TransportBase, TransportReceiver, TransportSender}; use crate::common::{self, KafkaTestConfig}; -use crate::filebeat_corpus as fb; +use crate::filebeat_cases as fb; /// The source this instance is bound to, and the topics that follow from it. const SOURCE: &str = "filebeat"; @@ -52,41 +52,26 @@ const UNMATCHED_PROBE: &str = "ws21 probe: a line no filebeat module claims"; /// consumer group. Debug builds spend most of it in the VRL compiler. const READY_TIMEOUT: Duration = Duration::from_secs(240); -/// How long the corpus gets to arrive on the sink topic once the app is ready. +/// How long the cases get to arrive on the sink topic once the app is ready. const DRAIN_TIMEOUT: Duration = Duration::from_secs(180); -/// One golden event, and the corpus line it grades. -struct Golden { - log: &'static str, - idx: usize, - event: serde_json::Value, -} - -/// Every corpus line as the app will read it off `filebeat_land`, paired with -/// the goldens they must produce. -fn corpus_workload() -> (Vec, Vec) { - let mut inputs = Vec::new(); - let mut goldens = Vec::new(); - - for log in fb::all_logs() { - let lines = fb::corpus_lines(log); - let expected = fb::corpus_expected(&format!("{log}-expected.json")); - assert_eq!( - lines.len(), - expected.len(), - "{log}: {} input lines but {} golden events", - lines.len(), - expected.len() - ); - let conf = fb::corpus_conf(log); - for (idx, (line, event)) in lines.iter().zip(expected).enumerate() { - inputs.push(fb::input_event(line, conf.as_ref())); - goldens.push(Golden { log, idx, event }); - } - } - +/// Every case as the app will read it off `filebeat_land`, plus the shape- +/// unmatched probe. +/// +/// Each case event carries its name in a root `_case` field. The pipeline keeps +/// root fields on every branch, so the name comes out the far side and pairs +/// an output with its case whatever order the broker returns them in. +fn workload() -> Vec { + let mut inputs: Vec = fb::cases() + .iter() + .map(|case| { + let mut event = fb::case_event(case); + event["_case"] = serde_json::json!(case.name); + event + }) + .collect(); inputs.push(fb::input_event(UNMATCHED_PROBE, None)); - (inputs, goldens) + inputs } fn kafka_config(kf: &KafkaTestConfig, topics: &[&str], group: &str) -> KafkaConfig { @@ -212,70 +197,43 @@ async fn drain( out } -/// Match every golden to an output event, consuming each output once. -/// -/// The sink topic has one partition, so the output usually arrives in the -/// order it was produced; that position is tried first and the scan is the -/// fallback. Returns the goldens that nothing produced, each with the -/// closest candidate's problems. -fn match_goldens(goldens: &[Golden], outputs: &[serde_json::Value]) -> Vec { - let mut used = vec![false; outputs.len()]; - let mut unmatched = Vec::new(); - - for (position, golden) in goldens.iter().enumerate() { - let mut order: Vec = Vec::with_capacity(outputs.len()); - if position < outputs.len() { - order.push(position); - } - order.extend((0..outputs.len()).filter(|i| *i != position)); - - let mut closest: Option> = None; - let mut matched = false; - for candidate in order { - if used[candidate] { - continue; +/// Grade every case against the output event that carries its name. +/// Returns one line per case that has no output or whose output disagrees. +fn grade_cases(outputs: &[serde_json::Value]) -> Vec { + let mut failures = Vec::new(); + for case in fb::cases() { + let found = outputs + .iter() + .find(|e| e.get("_case").and_then(serde_json::Value::as_str) == Some(&case.name)); + match found { + None => failures.push(format!("{}: no output event carried its name", case.name)), + Some(event) if fb::is_unmatched(event) => { + failures.push(format!("{}: routed to filebeat_unmatched", case.name)); } - let problems = fb::problems_against_golden( - golden.log, - golden.idx, - &golden.event, - &outputs[candidate], - ); - if problems.is_empty() { - used[candidate] = true; - matched = true; - break; - } - if closest.as_ref().is_none_or(|c| problems.len() < c.len()) { - closest = Some(problems); + Some(event) => { + let problems = fb::problems_for(case, event); + if !problems.is_empty() { + failures.push(format!("{}: {}", case.name, problems.join("; "))); + } } } - - if !matched { - let detail = closest.map_or_else( - || "no output event was left to compare against".to_string(), - |problems| problems.join("; "), - ); - unmatched.push(format!("{}[{}]: {detail}", golden.log, golden.idx)); - } } - unmatched + failures } #[tokio::test] -async fn filebeat_corpus_round_trips_through_kafka() { - let Some(env) = common::KafkaTestEnv::hermetic("filebeat-corpus-round-trip").await else { +async fn filebeat_cases_round_trip_through_kafka() { + let Some(env) = common::KafkaTestEnv::hermetic("filebeat-cases-round-trip").await else { common::require_service_in_ci("Kafka", "no Docker to start a broker container"); eprintln!("SKIP: no Docker, so this test cannot own a broker."); return; }; let kf = env.config(); - let (inputs, goldens) = corpus_workload(); + let inputs = workload(); eprintln!( - "replaying {} corpus lines from {} logs through {LAND_TOPIC} -> {LOAD_TOPIC}", - inputs.len(), - fb::all_logs().len() + "replaying {} events through {LAND_TOPIC} -> {LOAD_TOPIC}", + inputs.len() ); let admin = @@ -382,17 +340,14 @@ async fn filebeat_corpus_round_trips_through_kafka() { outputs[probe_at] ); - // Assertion 3 -- every corpus line agrees with its elastic golden, after - // the port's documented divergences. - let mut corpus_outputs = outputs; - corpus_outputs.remove(probe_at); - let unmatched = match_goldens(&goldens, &corpus_outputs); + // Assertion 3 -- every case agrees with the fields it names. + let failures = grade_cases(&outputs); assert!( - unmatched.is_empty(), - "{} of {} corpus events did not match their golden:\n{}", - unmatched.len(), - goldens.len(), - unmatched.join("\n") + failures.is_empty(), + "{} of {} cases did not match:\n{}", + failures.len(), + fb::cases().len(), + failures.join("\n") ); // Assertion 4 -- all three module branches actually ran. A pipeline that @@ -400,7 +355,7 @@ async fn filebeat_corpus_round_trips_through_kafka() { // everything through unmatched. let branch_ran = |module: &str, probe: &dyn Fn(&serde_json::Value) -> bool| { assert!( - corpus_outputs.iter().any(probe), + outputs.iter().any(probe), "no output event came from the {module} branch" ); }; diff --git a/tests/fixtures/filebeat/README.md b/tests/fixtures/filebeat/README.md index c903900..29ef5c8 100644 --- a/tests/fixtures/filebeat/README.md +++ b/tests/fixtures/filebeat/README.md @@ -1,61 +1,15 @@ -# Filebeat pipeline test corpus +# Filebeat pipeline test cases -Real filebeat/Elastic-integration pipeline test data used by -`tests/integration/filebeat_pipeline.rs` to exercise the bundled INTERIM -filebeat-compat pipeline (`pipelines/filebeat/filebeat.vrl`). +`cases.yaml` holds the synthetic log lines that `tests/integration/filebeat_pipeline.rs`, `tests/e2e/filebeat_kafka.rs` and the PGO workload (`src/bin/pgo_driver.rs`) replay through the bundled filebeat-compat pipeline (`pipelines/filebeat/filebeat.vrl`). The lines are written for this repo. Hosts, addresses and names come from the documentation ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`, `example.*`). Earlier revisions replayed third-party sample data, which is no longer part of the repo. -## Contents +Each case has: -`filebeat-testdata.tar.gz` (read in-memory at test time, never unpacked to -the working tree): +- `name`: unique, and carried through the broker in the e2e suite to pair an output with its case. +- `module`: `meraki`, `ios` or `umbrella`. +- `conf`: optional `_conf` object the DFE 2.1 deployment injected (`tz_offset`). +- `line`: the raw log line, which becomes `message`. +- `expect`: the fields the output must carry. Every leaf named must be present and equal. Fields not named are not checked, and the root `tags` array compares as a set. -- `cisco_umbrella/log/` -- raw Cisco Umbrella export samples for all seven - log types (audit, cloud firewall, dlp, dns, intrusion, ip, proxy) plus - the upstream `-expected.json` golden outputs and `-config.yml` knobs. -- `cisco_ios/log/` -- Cisco IOS syslog samples (with and without syslog - priority headers, several date formats and tz offsets) plus goldens. -- `cisco_meraki/log/` -- Cisco Meraki syslog samples (flows, events, - airmarshal, security, urls, ip-flows) plus goldens. -- `cisco_*/manifest.yml` -- upstream package manifests (package versions, - provenance). -- `LICENSE.txt` -- upstream root licence pointer. -- `licenses/Elastic-2.0.txt` -- the full Elastic License 2.0 text the - pointer refers to (redistribution requires shipping the terms). +## Adding a case -## Provenance - -- Source repo: `github.com/elastic/integrations` -- Commit: `c7bc53023288c7d34afd631c0c3e3552f6219419` (pinned in - `scripts/filebeat/fetch_testdata.py` -- the same vintage the DFE 2.1 VRL - templates were generated against) -- Paths: `packages/cisco_{umbrella,ios,meraki}/data_stream/log/_dev/test/pipeline/` -- Licence: Elastic License 2.0 (test data redistributed unmodified for - compatibility testing, see `LICENSE.txt` inside the archive) - -## Refreshing - -Re-fetch at a new pin with the gh CLI (contents API, read-only), then -re-create the archive: - -```bash -python3 scripts/filebeat/fetch_testdata.py /tmp/filebeat-fetch # edit SHA first -tar --sort=name --owner=0 --group=0 --numeric-owner \ - --mtime='2026-08-18 00:00:00 UTC' \ - -czf tests/fixtures/filebeat/filebeat-testdata.tar.gz \ - -C /tmp/filebeat-fetch \ - cisco_umbrella cisco_ios cisco_meraki LICENSE.txt licenses -``` - -Those tar flags make the archive byte-reproducible, so a refresh only churns -the blob when content actually changed. Re-validate the -`ACCEPTED_MISMATCHES` line indices in `tests/common/filebeat.rs` after any -refresh - they are keyed by line position within each log file. - -## Golden-output caveats - -The `-expected.json` files are the outputs of the ELASTIC ingest pipelines -at that pin. This repo's VRL is the DFE 2.1 port, which deliberately -differs: geoip enrichment is stripped (dfe-loader owns geoip), `related.*` -/ ECS versions may lag the newest integration revisions, and events carry -DFE 2.1 conventions (`_conf.tz_offset`, tag seeding). Tests therefore -assert on stable parsed fields, not whole-object equality. +Write a line for the module, run it through the pipeline, read the output, and put the fields you have checked by hand into `expect`. A Cisco IOS line with no year in its timestamp gets the current year from the parser, so leave `@timestamp` out of `expect` for those. diff --git a/tests/fixtures/filebeat/cases.yaml b/tests/fixtures/filebeat/cases.yaml new file mode 100644 index 0000000..f7d6eee --- /dev/null +++ b/tests/fixtures/filebeat/cases.yaml @@ -0,0 +1,715 @@ +# Synthetic filebeat cases, replayed by the integration and e2e suites and the PGO workload. +# See README.md in this directory for the shape of a case. +- name: umbrella-dns-allowed + module: umbrella + line: |- + "2026-03-04 09:15:22","alice.example","alice.example,Laptop 17","192.0.2.10","198.51.100.7","Allowed","1 (A)","NOERROR","www.example.net.","Business Services,Search Engines","Office Policy","AD Users, Roaming Computers","" + expect: + '@timestamp': '2026-03-04T09:15:22Z' + observer: + vendor: Cisco + product: Umbrella + type: dns + log: + file: + path: /filebeat/cisco_umbrella/dnslogs + event: + action: dns-request-Allowed + category: network + kind: event + type: + - allowed + - connection + dns: + type: query + response_code: NOERROR + question: + name: www.example.net + registered_domain: example.net + subdomain: www + top_level_domain: net + type: 1 (A) + source: + ip: 192.0.2.10 + nat: + ip: 198.51.100.7 + cisco: + umbrella: + identity: alice.example + tags: + - preserve_original_event + +- name: umbrella-dns-blocked-aaaa + module: umbrella + line: |- + "2026-03-04 09:16:02","alice.example","alice.example,Laptop 17","192.0.2.10","198.51.100.8","Blocked","28 (AAAA)","NOERROR","ads.tracker.example.org.","Advertisements,Block List","Office Policy","Roaming Computers","BlockedCategories" + expect: + '@timestamp': '2026-03-04T09:16:02Z' + event: + action: dns-request-Blocked + type: + - denied + - connection + dns: + question: + name: ads.tracker.example.org + registered_domain: example.org + subdomain: ads.tracker + top_level_domain: org + type: 28 (AAAA) + cisco: + umbrella: + categories: + - Advertisements + - Block List + blocked_categories: + - BlockedCategories + +- name: umbrella-dns-nxdomain-mx + module: umbrella + line: |- + "2026-03-04 09:17:40","Site Router","Site Router","192.0.2.1","203.0.113.53","Allowed","15 (MX)","NXDOMAIN","mail.corp.example.co.uk.","Email","Default Policy","Networks","" + expect: + '@timestamp': '2026-03-04T09:17:40Z' + dns: + response_code: NXDOMAIN + question: + name: mail.corp.example.co.uk + registered_domain: example.co.uk + subdomain: mail.corp + top_level_domain: co.uk + type: 15 (MX) + source: + ip: 192.0.2.1 + +- name: umbrella-dns-txt + module: umbrella + line: |- + "2026-03-05 21:02:11","bob.example","bob.example,Desktop 4","192.0.2.44","198.51.100.9","Allowed","16 (TXT)","NOERROR","_dmarc.example.com.","Infrastructure","Office Policy","AD Users","" + expect: + '@timestamp': '2026-03-05T21:02:11Z' + dns: + question: + name: _dmarc.example.com + registered_domain: example.com + subdomain: _dmarc + type: 16 (TXT) + +- name: umbrella-proxy-allowed-chrome + module: umbrella + line: |- + "2026-03-04 10:01:15","Branch Network","192.0.2.77","198.51.100.31","203.0.113.80","","ALLOWED","https://files.example.net/reports/q1.pdf","https://portal.example.net/home","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36","200","612","20480","","","","","","","","","","Networks" + expect: + '@timestamp': '2026-03-04T10:01:15Z' + observer: + type: proxy + event: + type: + - allowed + - connection + source: + ip: 192.0.2.77 + nat: + ip: 198.51.100.31 + destination: + ip: 203.0.113.80 + http: + request: + bytes: 612 + referrer: https://portal.example.net/home + response: + bytes: 20480 + status_code: 200 + url: + original: https://files.example.net/reports/q1.pdf + scheme: https + domain: files.example.net + port: 443 + path: /reports/q1.pdf + extension: pdf + user_agent: + original: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 + name: Chrome + version: 122.0.0.0 + +- name: umbrella-proxy-blocked-query + module: umbrella + line: |- + "2026-03-04 10:02:49","Branch Network","192.0.2.78","198.51.100.31","203.0.113.81","","BLOCKED","http://download.example.org/setup.exe?src=mail&id=42","","curl/8.5.0","403","310","0","","","","","","","","","","Networks" + expect: + '@timestamp': '2026-03-04T10:02:49Z' + event: + type: + - denied + - connection + http: + response: + status_code: 403 + url: + scheme: http + domain: download.example.org + port: 80 + path: /setup.exe + extension: exe + query: id=42&src=mail + user_agent: + original: curl/8.5.0 + +- name: umbrella-ip-allowed + module: umbrella + line: |- + "2026-03-04 11:20:46","alice.example","192.0.2.10","51234","198.51.100.60","8443","Business Services" + expect: + '@timestamp': '2026-03-04T11:20:46Z' + observer: + type: firewall + event: + type: + - connection + source: + ip: 192.0.2.10 + port: 51234 + destination: + ip: 198.51.100.60 + port: 8443 + +- name: umbrella-ip-zero-ports + module: umbrella + line: |- + "2026-03-04 11:20:47","alice.example","192.0.2.10","0","198.51.100.61","0","Infrastructure" + expect: + source: + port: 0 + destination: + ip: 198.51.100.61 + port: 0 + +- name: umbrella-audit-update + module: umbrella + line: |- + "7000000001","2026-02-11 14:30:05","admin@example.com","Admin User", "policies", "update","198.51.100.200","enabled: false","enabled: true" + expect: + '@timestamp': '2026-02-11T14:30:05Z' + event: + id: '7000000001' + action: update + category: configuration + type: + - change + user: + email: admin@example.com + name: Admin User + source: + ip: 198.51.100.200 + cisco: + umbrella: + audit: + type: policies + before_values: + enabled: 'false' + after_values: + enabled: 'true' + +- name: umbrella-cloudfirewall-plain + module: umbrella + line: |- + 2026-03-04 12:00:01,[900000001],Branch Tunnel,Office Tunnel Device,OUTBOUND,6,512,192.0.2.20,,198.51.100.90,,lhr1.edc,14,ALLOW + expect: + '@timestamp': '2026-03-04T12:00:01Z' + observer: + type: firewall + event: + action: fw-connection-ALLOW + type: + - allowed + - connection + network: + direction: outbound + transport: '6' + source: + ip: 192.0.2.20 + bytes: 512 + destination: + ip: 198.51.100.90 + rule: + id: '14' + cisco: + umbrella: + datacenter: lhr1.edc + +- name: umbrella-cloudfirewall-quoted + module: umbrella + line: |- + "2026-03-04 12:00:09","[900000001]","Branch Tunnel", "Office Tunnel Device","INBOUND","17","96","192.0.2.21","","198.51.100.91", "","lhr1.edc","9","BLOCK","example.com,example.net","12,34" + expect: + '@timestamp': '2026-03-04T12:00:09Z' + event: + action: fw-connection-BLOCK + type: + - denied + - connection + network: + direction: inbound + transport: '17' + rule: + id: '9' + related: + hosts: + - example.com + - example.net + cisco: + umbrella: + fqdns: + - example.com + - example.net + destination_lists_id: 12,34 + +- name: umbrella-intrusion + module: umbrella + line: |- + "2026-03-04 12:30:00","Branch Firewall","Network Tunnels","1","21000","EXAMPLE-SERVER overflow attempt against test service","4455","HIGH","Attempted Administrator Privilege Gain","cve-2020-0001","TCP","40000","198.51.100.5","41234","192.0.2.30","443","Would Block" + expect: + '@timestamp': '2026-03-04T12:30:00Z' + observer: + type: idps + event: + kind: alert + action: ips-Would-Block + category: + - network + - intrusion_detection + network: + protocol: TCP + source: + ip: 198.51.100.5 + port: 41234 + destination: + ip: 192.0.2.30 + port: 443 + cisco: + umbrella: + gid: '1' + sid: '21000' + severity: HIGH + +- name: umbrella-dlp + module: umbrella + line: |- + "2026-03-04 12:45:30","Real Time","aaaaaaaa-0000-4000-8000-000000000001_bbbbbbbb-0000-4000-8000-000000000002","CRITICAL","Network1","","budget.xlsx","ExampleDrive","https://drive.example.net/upload","BLOCK","rule-7","classification-9","classifier-9.1","text/html","512","0000000000000000000000000000000000000000000000000000000000000000", "Confidential" + expect: + '@timestamp': '2026-03-04T12:45:30Z' + observer: + type: dlp + event: + action: dlp-BLOCK + category: + - network + - file + file: + name: budget.xlsx + size: 512 + mime_type: text/html + rule: + name: rule-7 + network: + application: ExampleDrive + url: + domain: drive.example.net + path: /upload + cisco: + umbrella: + severity: CRITICAL + +- name: ios-acl-host-header-tcp + module: ios + conf: {"tz_offset": "UTC"} + line: |- + Mar 5 02:11:08 192.0.2.1 12345: Mar 5 02:11:07.512: %SEC-6-IPACCESSLOGP: list 120 denied tcp 198.51.100.20(40012) -> 203.0.113.9(443), 3 packets + expect: + observer: + vendor: Cisco + product: IOS + type: firewall + cisco: + ios: + access_list: '120' + facility: SEC + sequence: '12345' + event: + action: deny + code: IPACCESSLOGP + severity: 6 + timezone: UTC + type: + - info + - denied + log: + level: informational + network: + transport: tcp + type: ipv4 + packets: 3 + source: + ip: 198.51.100.20 + port: 40012 + packets: 3 + destination: + ip: 203.0.113.9 + port: 443 + message: list 120 denied tcp 198.51.100.20(40012) -> 203.0.113.9(443), 3 packets + tags: + - preserve_original_event + +- name: ios-acl-named-icmp + module: ios + conf: {"tz_offset": "UTC"} + line: |- + Mar 6 02:11:08 192.0.2.1 12346: Mar 6 02:11:07.512: %SEC-6-IPACCESSLOGDP: list EDGE-IN denied icmp 198.51.100.21 -> 192.0.2.1 (8/0), 2 packets + expect: + cisco: + ios: + access_list: EDGE-IN + facility: SEC + event: + action: deny + code: IPACCESSLOGDP + icmp: + type: '8' + code: '0' + network: + transport: icmp + packets: 2 + source: + ip: 198.51.100.21 + destination: + ip: 192.0.2.1 + +- name: ios-acl-ipv6 + module: ios + conf: {"tz_offset": "UTC"} + line: |- + Mar 7 08:00:00 192.0.2.1 12350: Mar 7 08:00:00.001: %IPV6-6-ACCESSLOGP: list ACL-V6-IN/10 permitted tcp 2001:db8:10::5(51000) -> 2001:db8:20::7(22), 4 packets + expect: + cisco: + ios: + access_list: ACL-V6-IN/10 + facility: IPV6 + event: + action: allow + code: ACCESSLOGP + network: + transport: tcp + type: ipv6 + source: + ip: 2001:db8:10::5 + port: 51000 + destination: + ip: 2001:db8:20::7 + port: 22 + +- name: ios-config-change + module: ios + conf: {"tz_offset": "UTC"} + line: |- + <189>4021: Mar 5 2026 10:22:09.113: %SYS-5-CONFIG_I: Configured from console by admin on vty1 (192.0.2.55) + expect: + '@timestamp': '2026-03-05T10:22:09.113Z' + cisco: + ios: + facility: SYS + message_count: 4021 + event: + code: CONFIG_I + sequence: 4021 + severity: 5 + log: + level: notification + syslog: + priority: 189 + message: Configured from console by admin on vty1 (192.0.2.55) + +- name: ios-link-down-hostname + module: ios + conf: {"tz_offset": "UTC"} + line: |- + <190>: edge-sw02: Mar 5 2026 10:30:00.004: %LINK-3-UPDOWN: Interface GigabitEthernet0/1, changed state to down + expect: + '@timestamp': '2026-03-05T10:30:00.004Z' + cisco: + ios: + facility: LINK + event: + code: UPDOWN + severity: 3 + log: + level: error + syslog: + hostname: edge-sw02 + priority: 190 + message: Interface GigabitEthernet0/1, changed state to down + +- name: ios-ntp-tz-abbreviation + module: ios + conf: {"tz_offset": "UTC"} + line: |- + <191>7001: core-rt1: Mar 5 2026 10:31:12.000 CEST: NTP Core (NOTICE): Clock synchronization lost. + expect: + event: + sequence: 7001 + log: + syslog: + hostname: core-rt1 + priority: 191 + message: 'NTP Core (NOTICE): Clock synchronization lost.' + +- name: ios-date-no-fraction-offset + module: ios + conf: {"tz_offset": "-0600"} + line: |- + <190>7002: sw01: Mar 8 2026 22:11:43: %FOO-6-BAR: Synthetic message for the date format. + expect: + '@timestamp': '2026-03-09T04:11:43Z' + event: + code: BAR + timezone: '-0600' + log: + syslog: + hostname: sw01 + message: Synthetic message for the date format. + +- name: ios-md5-badauth + module: ios + conf: {"tz_offset": "UTC"} + line: |- + <190>9100: Mar 9 20:07:53.081: %TCP-6-BADAUTH: No MD5 digest from 192.0.2.50(60001) to 198.51.100.18(179) (RST) tableid - 0 + expect: + cisco: + ios: + facility: TCP + tableid: '0' + event: + code: BADAUTH + sequence: 9100 + source: + ip: 192.0.2.50 + port: 60001 + destination: + ip: 198.51.100.18 + port: 179 + +- name: meraki-flows-allow + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000000.100000001 MX_branch1 flows allow src=192.0.2.15 dst=198.51.100.44 mac=AA:BB:CC:00:11:22 protocol=udp sport=51000 dport=5060 + expect: + '@timestamp': '2026-02-25T06:13:20.100000001Z' + cisco_meraki: + event_type: flows + event_subtype: flow_allowed + event: + action: layer3-firewall-allowed-flow + category: + - network + network: + protocol: udp + observer: + hostname: MX_branch1 + source: + ip: 192.0.2.15 + mac: AA-BB-CC-00-11-22 + port: 51000 + destination: + ip: 198.51.100.44 + port: 5060 + tags: + - preserve_original_event + - forwarded + +- name: meraki-flows-pattern + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000001.200000002 MX_branch1 flows src=192.0.2.16 dst=198.51.100.45 protocol=tcp sport=44000 dport=8080 pattern: 1 all + expect: + '@timestamp': '2026-02-25T06:13:21.200000002Z' + cisco_meraki: + event_subtype: ip_session_initiated + firewall: + pattern: 1 all + event: + action: ip-session-initiated + network: + protocol: tcp + source: + ip: 192.0.2.16 + port: 44000 + destination: + ip: 198.51.100.45 + port: 8080 + +- name: meraki-flows-icmp6 + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000002.300000003 AP_lobby flows allow src=fe80::aaaa:bbbb:cccc:1 dst=ff02::1:ff00:2 mac=AA:BB:CC:00:11:33 protocol=icmp6 type=135 + expect: + network: + protocol: icmp6 + observer: + hostname: AP_lobby + source: + ip: fe80::aaaa:bbbb:cccc:1 + mac: AA-BB-CC-00-11-33 + destination: + ip: ff02::1:ff00:2 + +- name: meraki-urls-agent + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000010.400000004 MX_branch1 urls src=192.0.2.15:52000 dst=198.51.100.46:443 mac=AA:BB:CC:00:11:22 agent='Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0' request: GET https://www.example.net/index.html + expect: + cisco_meraki: + event_type: urls + event_subtype: http_access + event: + action: http-access + category: + - network + - web + http: + request: + method: GET + url: + original: https://www.example.net/index.html + scheme: https + domain: www.example.net + port: 443 + path: /index.html + extension: html + registered_domain: example.net + subdomain: www + user_agent: + name: Firefox + version: '123.0' + +- name: meraki-urls-no-agent + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000011.500000005 MX_branch1 urls src=192.0.2.15:52001 dst=198.51.100.47:80 mac=AA:BB:CC:00:11:22 request: GET http://www.example.org/robots.txt + expect: + source: + ip: 192.0.2.15 + port: 52001 + url: + original: http://www.example.org/robots.txt + scheme: http + port: 80 + extension: txt + cisco_meraki: + urls: + mac: AA-BB-CC-00-11-22 + +- name: meraki-events-wpa-auth + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000020.600000006 MR_floor2 events type=wpa_auth radio='1' vap='2' client_mac='AA:BB:CC:00:11:44' aid='1000000001' + expect: + cisco_meraki: + event_type: events + event_subtype: wpa_auth + wpa_auth: + client_mac: AA:BB:CC:00:11:44 + aid: '1000000001' + event: + action: wifi-wpa-authentication + category: + - network + - authentication + log: + syslog: + priority: 134 + observer: + hostname: MR_floor2 + +- name: meraki-events-dfs + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000021.700000007 MR_floor2 events type=dfs_event channel='100' radio='1' + expect: + cisco_meraki: + event_subtype: dfs_event + dfs_event: + channel: '100' + radio: '1' + event: + action: dynamic-frequency-selection-detected + +- name: meraki-ip-flow-start + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000030.800000008 MX100 ip_flow_start src=192.0.2.15 dst=198.51.100.48 protocol=tcp sport=34000 dport=443 translated_src_ip=203.0.113.2 translated_port=34000 + expect: + cisco_meraki: + event_type: ip_flow_start + network: + protocol: tcp + source: + port: 34000 + destination: + ip: 198.51.100.48 + port: 443 + +- name: meraki-security-ids + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000040.900000009 MX84 security_event ids_alerted signature=1:99999:1 priority=2 timestamp=1772000040.890000 direction=ingress protocol=tcp/ip src=198.51.100.49:80 dst=192.0.2.15:56000 decision=allowed message: EXAMPLE-WEB synthetic signature for testing + expect: + cisco_meraki: + event_type: security_event + event_subtype: ids_alerted + security: + signature: 1:99999:1 + decision: allowed + event: + action: ids-signature-matched + category: + - network + - intrusion_detection + network: + direction: ingress + protocol: tcp/ip + source: + ip: 198.51.100.49 + port: 80 + destination: + ip: 192.0.2.15 + port: 56000 + message: EXAMPLE-WEB synthetic signature for testing + +- name: meraki-airmarshal-rogue + module: meraki + conf: {"tz_offset": "UTC"} + line: |- + <134>1 1772000050.000000010 MX84_5 airmarshal_events type=rogue_ssid_detected ssid='' bssid='AA:BB:CC:DD:EE:01' src='AA:BB:CC:DD:EE:01' dst='FF:FF:FF:FF:FF:FF' wired_mac='AA:BB:CC:DD:EE:02' vlan_id='0' channel='6' rssi='30' fc_type='0' fc_subtype='8' + expect: + cisco_meraki: + event_type: airmarshal_events + event_subtype: rogue_ssid_detected + bssid: AA:BB:CC:DD:EE:01 + channel: '6' + event: + action: rogue-ssid-detected + source: + mac: AA-BB-CC-DD-EE-01 + destination: + mac: FF-FF-FF-FF-FF-FF + network: + vlan: + id: '0' diff --git a/tests/fixtures/filebeat/filebeat-testdata.tar.gz b/tests/fixtures/filebeat/filebeat-testdata.tar.gz deleted file mode 100644 index 001a9b266dfb24a28e0bb5edc880181a6c2401f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50952 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� 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 RlT6%tv3;?xfMMnSt diff --git a/tests/integration.rs b/tests/integration.rs index 6ab34c7..17db4f0 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -37,12 +37,12 @@ mod config; #[path = "integration/enrichment.rs"] mod enrichment; -// Shared with the e2e binary, which grades the same corpus through a real +// Shared with the e2e binary, which grades the same cases through a real // broker. Declared here rather than under `common/mod.rs` so the integration // binary does not pull in the container helpers it never uses. #[allow(clippy::unwrap_used, clippy::expect_used)] #[path = "common/filebeat.rs"] -mod filebeat_corpus; +mod filebeat_cases; #[allow(clippy::unwrap_used, clippy::expect_used)] #[path = "integration/enrichment_contract.rs"] diff --git a/tests/integration/direct_transport.rs b/tests/integration/direct_transport.rs index 3b92ee1..14a3246 100644 --- a/tests/integration/direct_transport.rs +++ b/tests/integration/direct_transport.rs @@ -37,7 +37,7 @@ use scalo::worker::{AdaptiveWorkerPool, BatchEngine, WorkerPoolConfig}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use crate::filebeat_corpus as fb; +use crate::filebeat_cases as fb; use crate::offline::binary; use crate::ports; diff --git a/tests/integration/filebeat_pipeline.rs b/tests/integration/filebeat_pipeline.rs index 51afdc1..26731d6 100644 --- a/tests/integration/filebeat_pipeline.rs +++ b/tests/integration/filebeat_pipeline.rs @@ -10,14 +10,13 @@ //! (`pipelines/filebeat/filebeat.vrl`), ported from the DFE 2.1 Vector //! templates (dfe-vector-templates 14x family). //! -//! The corpus tests drive the pipeline with REAL filebeat test data from -//! elastic/integrations (pinned SHA, see -//! `tests/fixtures/filebeat/README.md`), read in memory from the committed -//! tar.gz at test time. +//! The case tests drive the pipeline with the synthetic lines in +//! `tests/fixtures/filebeat/cases.yaml` and check each output against the +//! subset of fields its case names. //! //! This runs the pipeline IN PROCESS. `tests/e2e/filebeat_kafka.rs` runs the -//! same corpus through the app binary and a real broker; both grade against -//! the shared comparison in `tests/common/filebeat.rs`. +//! same cases through the app binary and a real broker; both grade with the +//! shared comparison in `tests/common/filebeat.rs`. use std::sync::OnceLock; @@ -28,7 +27,7 @@ use dfe_transform_vrl::enrichment::EnrichmentRegistry; use vrl::compiler::Program; use vrl::value::Value; -use crate::filebeat_corpus as fb; +use crate::filebeat_cases as fb; /// Compile the bundled pipeline with the bundled timezones table. /// @@ -91,23 +90,27 @@ fn bundled_filebeat_pipeline_compiles() { } // ========================================================================= -// Module detection smoke tests (single real lines per module) +// Module detection smoke tests (one line per module) // ========================================================================= -const MERAKI_LINE: &str = "<134>1 1647478988.289402144 MX84_4 flows allow src=10.0.2.170 \ - dst=10.0.0.34 mac=00:7C:2D:BD:76:F2 protocol=udp sport=54841 dport=15600"; +fn meraki_line() -> &'static str { + &fb::case("meraki-flows-allow").line +} -const IOS_LINE: &str = "Feb 8 04:00:48 192.168.100.2 585917: Feb 8 04:00:47.272: \ - %SEC-6-IPACCESSLOGRP: list 177 denied igmp 192.168.100.197 -> 224.0.0.22, 1 packet"; +fn ios_line() -> &'static str { + &fb::case("ios-acl-host-header-tcp").line +} -const UMBRELLA_LINE: &str = r#""2020-07-23 23:49:54","elasticuser","elasticuser,Elastic Machine","192.168.1.1","81.2.69.144","Allowed","1 (A)","NOERROR","www.elastic.co.","Software/Technology,Business Services,Application","Test Policy Name","AD Users, Roaming Computers","""#; +fn umbrella_line() -> &'static str { + &fb::case("umbrella-dns-allowed").line +} const UNMATCHED_LINE: &str = "completely unrecognisable payload"; #[test] fn detects_and_parses_meraki_flows_line() { let program = compile_filebeat(); - let mut value = event(MERAKI_LINE, Some("UTC")); + let mut value = event(meraki_line(), Some("UTC")); run_vrl(program, &mut value).expect("meraki flows line must transform"); assert_eq!( str_field(&value, "cisco_meraki.event_type").as_deref(), @@ -124,7 +127,7 @@ fn detects_and_parses_meraki_flows_line() { #[test] fn detects_and_parses_cisco_ios_line() { let program = compile_filebeat(); - let mut value = event(IOS_LINE, Some("UTC")); + let mut value = event(ios_line(), Some("UTC")); run_vrl(program, &mut value).expect("cisco ios line must transform"); assert_eq!( str_field(&value, "observer.product").as_deref(), @@ -136,7 +139,7 @@ fn detects_and_parses_cisco_ios_line() { #[test] fn detects_and_parses_umbrella_dns_line() { let program = compile_filebeat(); - let mut value = event(UMBRELLA_LINE, None); + let mut value = event(umbrella_line(), None); run_vrl(program, &mut value).expect("umbrella dns line must transform"); assert_eq!( str_field(&value, "observer.product").as_deref(), @@ -172,23 +175,25 @@ fn unmatched_event_is_tagged_and_passed_through() { // Event carriage // ========================================================================= -/// One real line per branch, for tests that care which branch ran rather than +/// One line per branch, for tests that care which branch ran rather than /// what it produced. -const BRANCH_LINES: &[(&str, &str)] = &[ - ("meraki", MERAKI_LINE), - ("ios", IOS_LINE), - ("umbrella", UMBRELLA_LINE), - ("unmatched", UNMATCHED_LINE), -]; +fn branch_lines() -> [(&'static str, &'static str); 4] { + [ + ("meraki", meraki_line()), + ("ios", ios_line()), + ("umbrella", umbrella_line()), + ("unmatched", UNMATCHED_LINE), + ] +} #[test] fn a_root_field_survives_every_branch_but_a_tag_does_not() { - // A corpus replay marks its own events so a shared cluster's older rows + // A case replay marks its own events so a shared cluster's older rows // are not counted as this run's. `tags` cannot carry that marker: every // module branch assigns `.tags` outright before it does anything else, so // a root field is the only carriage that comes out the far side. let program = compile_filebeat(); - for (branch, line) in BRANCH_LINES { + for (branch, line) in branch_lines() { let mut input = fb::input_event(line, Some(&serde_json::json!({ "tz_offset": "UTC" }))); input["_e2e"] = serde_json::json!({ "run": "ws21" }); input["tags"] = serde_json::json!(["ws21"]); @@ -200,7 +205,7 @@ fn a_root_field_survives_every_branch_but_a_tag_does_not() { Some("ws21"), "the {branch} branch dropped the root marker: {value:?}" ); - if *branch != "unmatched" { + if branch != "unmatched" { let kept_the_tag = field(&value, "tags") .and_then(Value::as_array) .is_some_and(|tags| tags.contains(&Value::from("ws21"))); @@ -213,101 +218,70 @@ fn a_root_field_survives_every_branch_but_a_tag_does_not() { } // ========================================================================= -// Real-data corpus (elastic/integrations pipeline test data, pinned; see -// tests/fixtures/filebeat/README.md) +// Synthetic cases (tests/fixtures/filebeat/cases.yaml) // ========================================================================= -/// Run every line of a corpus log through the pipeline and compare against -/// the goldens. Returns human-readable problem strings (empty = pass). -fn run_corpus_file(log_name: &str) -> Vec { +/// Run every case of one module through the pipeline and compare against +/// what each names. Returns human-readable problem strings (empty = pass). +fn run_cases(module: fb::Module) -> Vec { let program = compile_filebeat(); - let lines = fb::corpus_lines(log_name); - let expected = fb::corpus_expected(&format!("{log_name}-expected.json")); - let conf = fb::corpus_conf(log_name); let mut problems = Vec::new(); - if lines.len() != expected.len() { - problems.push(format!( - "{log_name}: {} input lines but {} golden events", - lines.len(), - expected.len() - )); - } - - for (idx, (line, exp)) in lines.iter().zip(&expected).enumerate() { - let mut value = event_with_conf(line, conf.as_ref()); + for case in fb::cases_for(module) { + let mut value = Value::from(fb::case_event(case)); if let Err(e) = run_vrl(program, &mut value) { - problems.push(format!("{log_name}[{idx}]: pipeline failed: {e}")); + problems.push(format!("{}: pipeline failed: {e}", case.name)); continue; } let ours = serde_json::to_value(&value).expect("event serialises"); if fb::is_unmatched(&ours) { - problems.push(format!("{log_name}[{idx}]: routed to filebeat_unmatched")); + problems.push(format!("{}: routed to filebeat_unmatched", case.name)); continue; } problems.extend( - fb::problems_against_golden(log_name, idx, exp, &ours) + fb::problems_for(case, &ours) .into_iter() - .map(|p| format!("{log_name}[{idx}]: {p}")), + .map(|p| format!("{}: {p}", case.name)), ); } problems } -fn assert_corpus_clean(log_names: &[&str]) { - let mut problems = Vec::new(); - for name in log_names { - problems.extend(run_corpus_file(name)); +#[test] +fn cases_cover_each_module_and_say_what_they_expect() { + for module in [fb::Module::Meraki, fb::Module::Ios, fb::Module::Umbrella] { + assert!(!fb::cases_for(module).is_empty(), "no cases for {module:?}"); } + for case in fb::cases() { + assert!( + case.expect.as_object().is_some_and(|o| !o.is_empty()), + "case {} expects nothing, so it can never fail", + case.name + ); + } +} + +fn assert_cases_clean(module: fb::Module) { + let problems = run_cases(module); assert!( problems.is_empty(), - "corpus mismatches ({}):\n{}", + "case mismatches ({}):\n{}", problems.len(), problems.join("\n") ); } #[test] -fn corpus_umbrella_dnslogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-dnslogs.log"]); -} - -#[test] -fn corpus_umbrella_proxylogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-proxylogs.log"]); -} - -#[test] -fn corpus_umbrella_auditlogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-auditlogs.log"]); -} - -#[test] -fn corpus_umbrella_iplogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-iplogs.log"]); -} - -#[test] -fn corpus_umbrella_dlplogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-dlplogs.log"]); -} - -#[test] -fn corpus_umbrella_intrusionlogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-intrusionlogs.log"]); -} - -#[test] -fn corpus_umbrella_cloudfirewalllogs() { - assert_corpus_clean(&["cisco_umbrella/log/test-umbrella-cloudfirewalllogs.log"]); +fn cases_umbrella() { + assert_cases_clean(fb::Module::Umbrella); } #[test] -fn corpus_cisco_ios() { - assert_corpus_clean(fb::IOS_LOGS); +fn cases_cisco_ios() { + assert_cases_clean(fb::Module::Ios); } #[test] -fn corpus_cisco_meraki() { - assert_corpus_clean(fb::MERAKI_LOGS); +fn cases_cisco_meraki() { + assert_cases_clean(fb::Module::Meraki); }