diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..42c68ce --- /dev/null +++ b/.github/README.md @@ -0,0 +1,92 @@ +# `.github/` — workflows for this template + +Reusable GitHub Actions workflows callable from any dryvist Cribl pack via +`uses: dryvist/cc-edge-pack-template/.github/workflows/@main`. Caller +workflows in this repo (`test.yml`, `release.yml`, `release-please.yml`) wire +them up to GitHub events. + +## Installation + +These workflows install themselves implicitly when a new pack scaffolds from +`dryvist/cc-edge-pack-template` (`gh repo create --template`). The caller +workflows under `.github/workflows/` are copied verbatim; the reusable +workflows they reference resolve at runtime against +`dryvist/cc-edge-pack-template@main`. + +If you're adding a workflow to a non-template repo manually, copy the relevant +caller from this directory into your repo's `.github/workflows/` and adjust +the `uses:` ref if you want to pin a specific template version (default +`@main` follows the template's main branch). + +## Usage + +Once installed, the workflows trigger automatically: + +- Open a PR touching `default/`, `data/samples/`, `tests/`, or `package.json` + → `test.yml` runs validate + Vitest. +- Push to `main` → `release-please.yml` opens or updates the release PR. +- Merge a release PR → release-please tags the version, which triggers + `release.yml` → builds the `.crbl` and publishes to GitHub Releases. + +No manual invocation is needed. Override defaults by editing the caller +workflow's `with:` block (e.g., bump `cribl_version` to test a specific +upstream Cribl release). + +## Reusable workflows + +### `cribl-pack-test.yml` + +Validates pack structure + runs the TypeScript Vitest suite against a +`cribl/cribl` service container. + +| Input | Required | Default | Purpose | +|---|---|---|---| +| `pack_type` | yes | — | `edge` or `stream` (drives validator naming convention + required-fields assertion) | +| `cribl_version` | no | `latest` | `cribl/cribl` Docker tag | +| `node_version` | no | `lts/*` | Node.js version | +| `yq_version` | no | `4.44.5` | Pinned `mikefarah/yq` version | + +Jobs: + +1. **validate** — installs `yq` (pinned via `dcarbone/install-yq-action`), + lints YAML (`frenck/action-yamllint`, config from `.yamllint.yml`), runs + `scripts/validate-pack-structure.sh`. +2. **test** — sets up Node + pnpm (cached), installs deps, runs Biome lint, + typechecks, waits for Cribl health endpoint + (`iFaxity/wait-on-action`), runs `pnpm run test`. + +### `cribl-pack-release.yml` + +Builds a `.crbl` tarball via `scripts/build-crbl.sh` and publishes it to +GitHub Releases (`softprops/action-gh-release`). + +| Input | Required | Default | Purpose | +|---|---|---|---| +| `additional_files` | no | `''` | Space-separated extra files to include in tarball; `LICENSE` auto-included if present | + +Triggered by tag push in the calling workflow (typically by release-please +when a release PR merges). + +## Caller workflows in this repo + +| File | Trigger | Calls | +|---|---|---| +| `workflows/test.yml` | PR + push to main (paths-filtered) | `cribl-pack-test.yml` (this repo) | +| `workflows/release.yml` | tag push matching `v*` | `cribl-pack-release.yml` (this repo) | +| `workflows/release-please.yml` | push to main | `_release-please.yml` (inherited from `JacobPEvans/.github`) | + +## Updating reusable workflow inputs + +Adding/renaming an input is a breaking change for every consumer pack. Use the +`workflow_call.inputs..default` field to keep the change backward +compatible whenever possible. Bump major version of the workflow only if you +absolutely cannot. + +## Pinning external actions + +Per [`SECURITY.md`](https://github.com/dryvist/.github/blob/main/SECURITY.md): + +- Trusted (`actions/*`, `pnpm/action-setup`, `softprops/action-gh-release`): + semver tag pins (`@v4`, etc.) +- Untrusted (everything else): SHA pins. Renovate (per the org's + `renovate.json`) auto-converts version tags to SHA on first run. diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..2f4d80c --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,25 @@ +name: release-please + +# Inherits the canonical release-please pipeline from JacobPEvans/.github, +# which enforces the org-wide major-bump block and uses the GitHub App token +# so release-please PRs trigger downstream workflows. See +# dryvist/.github/CLAUDE.md for the inheritance chain. + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + if: github.event.repository.is_template == false + uses: JacobPEvans/.github/.github/workflows/_release-please.yml@main + # The inherited workflow's input is named GH_ACTION_JACOBPEVANS_APP_ID for + # historical reasons. dryvist exposes a generic GH_APP_ID org secret and + # forwards it here at the boundary — repo readers only see the generic name. + secrets: + GH_ACTION_JACOBPEVANS_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d21aea8..a1310c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,4 +12,4 @@ permissions: jobs: release: if: github.event.repository.is_template == false - uses: dryvist/.github/.github/workflows/cribl-pack-release.yml@main + uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-release.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5226c1..c751147 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,9 +16,10 @@ permissions: jobs: test: - # Skip on the template repo itself; consumer repos have is_template: false. - if: github.event.repository.is_template == false - uses: dryvist/.github/.github/workflows/cribl-pack-test.yml@main + # Runs on the template too — the template now ships a working demo + # passthrough pipeline + fixture so the harness gets exercised here + # before consumer repos inherit it. + uses: dryvist/cc-edge-pack-template/.github/workflows/cribl-pack-test.yml@main with: # CHANGE per pack: 'edge' for Cribl Edge packs, 'stream' for Cribl Stream packs. pack_type: edge diff --git a/.gitignore b/.gitignore index 67bfb38..e4848ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# macOS .DS_Store *.swp *.bak @@ -5,12 +6,15 @@ # Build artifacts *.crbl -# Python -.venv/ -**/.venv/ -__pycache__/ -*.pyc -.pytest_cache/ +# Node / TypeScript +node_modules/ +**/node_modules/ +dist/ +*.tsbuildinfo + +# Nix / direnv +.direnv/ +.envrc.cache # Editor / OS .idea/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..f9333ae --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{ ".": "0.0.1" } diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..014c177 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,10 @@ +extends: default +rules: + line-length: disable + comments-indentation: disable + comments: disable + truthy: + level: warning + indentation: + level: warning + document-start: disable diff --git a/CLAUDE.md b/CLAUDE.md index 4f72f5c..4d00f98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,93 +1,26 @@ -# CLAUDE.md — guidance for AI assistants working in this repo +## What this repo is -This file is read by Claude Code (and other AI assistants supporting `CLAUDE.md`) on every session. It encodes the guardrails for working in this template — and, by inheritance, in any pack scaffolded from it. +Cribl Edge pack scaffolded from +[`dryvist/cc-edge-pack-template`](https://github.com/dryvist/cc-edge-pack-template). +Eventual purpose: collect Claude Code telemetry (session JSONL transcripts + +OpenTelemetry metrics) and forward to a Cribl Stream worker group. -## Repository Type +## Current state -This is a **template repository** for new Cribl Edge / Stream packs. It is consumed via `gh repo create --template`. The files here become the starting point for every downstream pack. +Ships only the template's demo passthrough pipeline + fixture so the +inherited Vitest harness has something to run end-to-end. Real +`claude-code-otel` and `claude-code-session-logs` pipelines (with eval +functions, real fixtures, captured samples) land in subsequent PRs. -## Generic vs Pack-Specific +## Sources of truth -The single most important rule: **distinguish generic files from pack-specific files.** - -**Generic** (DO NOT modify in pack repos — only in this template): -- `tests/cribl_client.py` -- `tests/conftest.py` -- `tests/test_pipelines.py` -- `tests/test_routes.py` -- `tests/requirements.txt` -- `Makefile` -- `docker-compose.yml` -- `.github/workflows/test.yml` (only the `pack_type:` value changes per pack) -- `.github/workflows/release.yml` - -If you find yourself wanting to modify any of the above in a pack repo, **stop**. Either: - -1. The change belongs in this template (open a PR here, then propagate to packs), or -2. The change should be expressed as fixture data, not code. - -**Pack-specific** (free to modify per-pack): -- `package.json` (name, version, displayName, tags, description) -- `default/pack.yml` (logo) -- `default/inputs.yml` (sources) -- `default/pipelines/route.yml` (routes) -- `default/pipelines//conf.yml` (pipeline functions) -- `default/samples.yml` (sample catalog) -- `data/samples/*.json` (sample events) -- `tests/fixtures//*.json` (test fixtures) -- `README.md` (describe your specific pack) -- `LICENSE` (use Apache-2.0 unless instructed otherwise) - -## Validator Rules (vct-cribl-pack-validator) - -Always enforce these — they are non-negotiable per the [validator skill](https://github.com/VisiCore/vct-cribl-pack-validator): - -| Rule | What it means | +| Layer | Where | |---|---| -| Pack ID format | `cc-edge--io` for Edge, `cc-stream--io` for Stream | -| No pipeline named `main` | All pipelines must have descriptive names | -| All routes use `output: __group` | Never `input_id` (breaks on source rename) | -| All sources have `metadata.datatype` | So route filters can match | -| Filters must be dynamic | Never literal `false` / `0` | -| No hardcoded paths | Use environment variables (`$MY_LOG_PATH`) | -| No hardcoded credentials | Use Cribl secrets | -| PII fields masked | `email`, `username`, `*_id`, `src_ip`, etc. before destinations | - -## Fixture Convention - -When adding tests, follow filesystem convention — no Python edits required: - -``` -tests/fixtures//.json # input -tests/fixtures//.expected.json # optional expected output (partial match) -``` - -The generic `test_pipelines.py` auto-discovers and parametrizes one test per `.json` it finds. If `.expected.json` is missing, the case is a smoke test (asserts non-empty output only). When you add an expected file, the assertions tighten automatically. - -Prefer richer fixtures over richer Python. If the assertion can't be expressed as a partial-match expected event, it probably shouldn't be a test — consider whether it's really a pipeline behavior or something else. - -## Don't Invent — Reuse - -Per the user's rules: - -- **Use existing Cribl tooling** — `cribl pipe`, the management API, official Docker images. Don't reinvent. -- **Use existing third-party Actions** — `softprops/action-gh-release`, `rlespinasse/github-slug-action`, `actions/setup-python`. Don't write custom packaging shell scripts. -- **Use the criblpacks pattern** — that's where `cribl_client.py` came from. When extending, mirror their idioms. -- **Use vct-cribl-pack-validator** — for deep structural validation, not custom-rolled YAML parsers. - -## Workflow - -For any pack work: - -1. `/refresh-repo` then create a worktree for the change (per user's global CLAUDE.md). -2. Modify only pack-specific files (see lists above). -3. `make test` locally before committing. -4. `make validate` before tagging a release. -5. Tag `vX.Y.Z` to trigger the release workflow. +| Org-wide policy (TS-everywhere, Biome, Vitest, secrets, releases) | [`dryvist/.github`](https://github.com/dryvist/.github) | +| Test harness mechanics, file boundary, validator rules | [`dryvist/cc-edge-pack-template/docs/`](https://github.com/dryvist/cc-edge-pack-template/tree/main/docs) | -## When in Doubt +## Top-level rules -- Read [`VisiCore/cc-edge-claude-code-io`](https://github.com/VisiCore/cc-edge-claude-code-io) — the gold-standard reference pack. -- Read [`criblpacks/cribl-palo-alto-networks`](https://github.com/criblpacks/cribl-palo-alto-networks) — Cribl's own test pattern reference. -- Read [`VisiCore/vct-cribl-pack-validator`](https://github.com/VisiCore/vct-cribl-pack-validator) — the authoritative ruleset. -- Don't add scripts. If you're tempted to write a script, ask first whether a Cribl-native or GitHub Action equivalent already exists. +- Don't modify generic files here — open a PR against the template repo and let it propagate. +- Don't tag versions; release-please proposes them via PR. +- Don't write inline scripts in workflows — extract to `scripts/*.sh` or use a community action. diff --git a/Makefile b/Makefile index 8e25fc7..bcad3c8 100644 --- a/Makefile +++ b/Makefile @@ -3,20 +3,15 @@ SHELL := /bin/bash PACK_NAME := $(shell jq -r '.name // "unknown-pack"' package.json 2>/dev/null) PACK_VERSION := $(shell jq -r '.version // "0.0.0"' package.json 2>/dev/null) CRBL_FILE := $(PACK_NAME)-$(PACK_VERSION).crbl -VENV := .venv -.PHONY: help install build docker-up docker-down test validate clean +.PHONY: help install build docker-up docker-down test typecheck lint format validate clean help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' -$(VENV): - python3 -m venv $(VENV) - $(VENV)/bin/pip install --upgrade pip - $(VENV)/bin/pip install -r tests/requirements.txt - -install: $(VENV) ## Create venv and install Python test dependencies +install: ## Install Node test dependencies (pnpm) + cd tests && pnpm install build: ## Build .crbl artifact (mirrors what release.yml does in CI) @INCLUDE="data default package.json README.md"; \ @@ -25,7 +20,7 @@ build: ## Build .crbl artifact (mirrors what release.yml does in CI) @echo "Built: $(CRBL_FILE)" @du -h "$(CRBL_FILE)" -docker-up: ## Start cribl/cribl Docker container (Stream mode supports both Edge and Stream packs) +docker-up: ## Start cribl/cribl Docker container (Stream supports both Edge and Stream packs) docker compose up -d @echo "Waiting for Cribl to be ready..." @for i in $$(seq 1 30); do \ @@ -41,8 +36,17 @@ docker-up: ## Start cribl/cribl Docker container (Stream mode supports both Edge docker-down: ## Stop and remove the Docker container docker compose down -v -test: install ## Run pytest test suite (requires Docker; run 'make docker-up' first) - $(VENV)/bin/python -m pytest tests/ -v +test: ## Run Vitest test suite (requires Docker; run 'make docker-up' first) + cd tests && pnpm run test + +typecheck: ## Type-check TypeScript + cd tests && pnpm run typecheck + +lint: ## Lint with Biome (lint + format check) + cd tests && pnpm run lint + +format: ## Auto-format with Biome + cd tests && pnpm run format validate: build ## Build pack and instruct on validator usage @echo "" @@ -51,7 +55,7 @@ validate: build ## Build pack and instruct on validator usage @echo "To run vct-cribl-pack-validator (27+ structural checks), from the validator repo:" @echo " cd ~/git/vct-cribl-pack-validator/main && claude /validate-pack $$PWD/$(CRBL_FILE)" -clean: ## Remove build artifacts, venv, and stop Docker +clean: ## Remove build artifacts, node_modules, and stop Docker rm -f *.crbl - rm -rf $(VENV) tests/__pycache__ tests/.pytest_cache .pytest_cache + rm -rf tests/node_modules tests/dist -docker compose down -v 2>/dev/null diff --git a/README.md b/README.md index ff8e541..3605fc2 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,50 @@ -# cc-edge-pack-template +# cc-edge-claude-code-io -Template repository for new Cribl Edge / Stream packs. Provides the full DRY scaffolding (test harness, validation, release packaging, Makefile, Docker setup) so per-pack repos only contain pack-specific configuration and fixture data. +Cribl Edge pack for collecting [Claude Code](https://claude.com/claude-code) +telemetry — session JSONL transcripts and OpenTelemetry metrics — and +forwarding it to a Cribl Stream worker group. -This template is built around two existing references: +## Status -- **Layout & convention**: based on [`VisiCore/cc-edge-claude-code-io`](https://github.com/VisiCore/cc-edge-claude-code-io), the gold-standard pack deployed to the Cribl dispensary. -- **Test pattern**: adopts the [criblpacks](https://github.com/criblpacks) approach (Python + Docker + Cribl management API). See [`criblpacks/cribl-palo-alto-networks/test/`](https://github.com/criblpacks/cribl-palo-alto-networks/tree/main/test). - -CI delegates entirely to reusable workflows in [`dryvist/.github`](https://github.com/dryvist/.github). +Initial scaffold. Currently ships only a demo passthrough pipeline inherited +from the [pack template](https://github.com/dryvist/cc-edge-pack-template). +Real pipelines (`claude-code-otel`, `claude-code-session-logs`) and matching +fixtures land in follow-up PRs. ## Installation -Create a new pack repo from this template: - -```sh -gh repo create my-org/cc-edge-mything-io \ - --template dryvist/cc-edge-pack-template \ - --public \ - --clone - -cd cc-edge-mything-io -make install # creates .venv at repo root and installs deps -``` +Install the pack into a Cribl Edge worker group via the Cribl UI: -If you prefer the GitHub UI: navigate to this repo, click **Use this template** → **Create a new repository**. +1. Open Cribl Edge and navigate to **Packs**. +2. Click **Add Pack → Upload**. +3. Select a `.crbl` file from the + [Releases](https://github.com/dryvist/cc-edge-claude-code-io/releases) page. +4. Confirm install and attach the pack to your worker group. ## Usage -After scaffolding from the template: +Once installed, point your Claude Code clients at the pack's source: -1. **Customize `package.json`**: replace `name`, `description`, `displayName`, `tags`. Pack name MUST follow the validator convention `cc-edge--io` (or `cc-stream--io`). -2. **Set the pack type in `.github/workflows/test.yml`**: change `pack_type: edge` to `stream` if this is a Stream pack. -3. **Define your inputs** in `default/inputs.yml`. Every input must declare `metadata.datatype` so route filters can match. -4. **Define your routes** in `default/pipelines/route.yml`. Replace the `REPLACE_*` placeholders. All routes MUST `output: __group` (validator rule). -5. **Define your pipelines** in `default/pipelines//conf.yml`. No pipeline named `main` (validator rule). -6. **Drop sample events** in `data/samples/*.json` and catalog them in `default/samples.yml`. -7. **Author test fixtures** in `tests/fixtures//`: - - `.json` (input) - - `.expected.json` (optional partial-match expected output) -8. **Run locally**: `make docker-up && make test` -9. **Validate**: `make validate` builds the `.crbl` and prints the command to run [`/validate-pack`](https://github.com/VisiCore/vct-cribl-pack-validator) against it. -10. **Push & release**: tag `vX.Y.Z` and the release workflow builds and uploads the `.crbl` to a GitHub release. - -## Layout - -``` -. -├── .github/workflows/ -│ ├── release.yml # Calls dryvist reusable workflow -│ └── test.yml # Calls dryvist reusable workflow -├── data/ -│ └── samples/ # Cribl sample events (referenced by samples.yml) -├── default/ -│ ├── inputs.yml # Source definitions — pack-specific -│ ├── pack.yml # Branding (logo) — pack-specific -│ ├── pipelines/ -│ │ ├── route.yml # Routes — pack-specific -│ │ └── /conf.yml # Pipeline functions — pack-specific -│ └── samples.yml # Sample catalog — pack-specific -├── tests/ -│ ├── conftest.py # GENERIC — never modify -│ ├── cribl_client.py # GENERIC — never modify -│ ├── test_pipelines.py # GENERIC — never modify -│ ├── test_routes.py # GENERIC — never modify -│ ├── requirements.txt # GENERIC — bump versions in template, propagate -│ ├── fixtures/ # Per-pack fixture data -│ │ └── / -│ │ ├── .json # input -│ │ └── .expected.json # expected (optional) -│ └── README.md -├── docker-compose.yml # GENERIC — never modify -├── Makefile # GENERIC — never modify -├── package.json # PACK-SPECIFIC — name, version, tags -├── README.md # PACK-SPECIFIC — describe your pack -├── LICENSE # GENERIC — Apache-2.0 -└── CLAUDE.md # GENERIC — AI assistant guidance -``` +- **Session logs** — file-monitor source watches `$CLAUDE_HOME` for `.jsonl` + transcripts (assistant turns, user prompts, tool decisions). +- **OpenTelemetry** — OTLP/gRPC source on port 4317 (api_request, token_usage, + cost metrics, error events). -The "GENERIC" files are propagated from this template. When the template improves, downstream packs should pull the changes via cherry-pick or by re-running the relevant section. When something is pack-specific, edit it freely in the pack repo. +The pack tags events with Splunk-canonical `sourcetype` / `index` / `datatype` +fields and forwards them upstream via the worker group's default destination. -## API +## Local development -This template doesn't expose a programmatic API. It provides: - -- **CLI surface (Makefile)**: `make help`, `install`, `build`, `docker-up`, `docker-down`, `test`, `validate`, `clean` -- **Test fixture surface**: filesystem convention under `tests/fixtures//.{json,expected.json}` — see `tests/README.md` for details -- **CI surface**: `.github/workflows/test.yml` and `release.yml` — both delegate to `dryvist/.github` reusable workflows - -## Contributing - -This template is the source of truth for shared pack infrastructure across the Cribl pack ecosystem. Changes here propagate to every downstream pack. - -When updating: +```sh +cd tests +pnpm install +pnpm run test # vitest run (requires a Cribl container at localhost:9000) +``` -1. Make changes in this repo on a feature branch. -2. Open a PR against `main`. Note that the template's own CI workflows are gated on `is_template == false`, so they won't run here — verify against a real pack instead. -3. Pick a downstream pack (e.g. `VisiCore/cc-edge-claude-code-io`) and apply the same changes there in a parallel PR. Confirm CI green. -4. Merge both. Document the propagation expectation in the PR description. +See [`dryvist/cc-edge-pack-template/docs/development.md`](https://github.com/dryvist/cc-edge-pack-template/blob/main/docs/development.md) +for the full local-development workflow (Cribl container, fixture authoring, +release process). ## License -Apache-2.0 — see `LICENSE`. - -## References - -- [VisiCore/cc-edge-claude-code-io](https://github.com/VisiCore/cc-edge-claude-code-io) — pilot pack and structural reference -- [VisiCore/vct-cribl-pack-validator](https://github.com/VisiCore/vct-cribl-pack-validator) — Claude Code skill running 27+ structural checks -- [criblpacks](https://github.com/criblpacks) — Cribl's official pack org; we adopt their test pattern -- [dryvist/.github](https://github.com/dryvist/.github) — hosts the reusable workflows this template calls -- [Cribl management API](https://docs.cribl.io/api-reference/) -- [Cribl pack docs](https://docs.cribl.io/stream/packs/) +Apache-2.0 — see [`LICENSE`](LICENSE). diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..26f652a --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,28 @@ +{ + // dryvist canonical Biome config (mirror of dryvist/.github/biome.jsonc). + "$schema": "https://biomejs.dev/schemas/2.3.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/.direnv", + "!**/default", + "!**/data/samples", + "!**/tests/fixtures" + ] + }, + "formatter": { "enabled": true, "useEditorconfig": true }, + "linter": { "enabled": true }, + "json": { + "parser": { + "allowComments": true, + "allowTrailingCommas": true + } + } +} diff --git a/default/pipelines/passthrough/conf.yml b/default/pipelines/passthrough/conf.yml new file mode 100644 index 0000000..54f8347 --- /dev/null +++ b/default/pipelines/passthrough/conf.yml @@ -0,0 +1,20 @@ +output: default +streamtags: [] +groups: {} +asyncFuncTimeout: 1000 +description: >- + Demo passthrough pipeline. Stamps Splunk-canonical fields + (sourcetype, index, datatype) on every event. Replace this + with your real pipeline logic when scaffolding a new pack. +functions: + - id: eval + filter: "true" + disabled: false + conf: + add: + - name: sourcetype + value: "'cribl:demo'" + - name: index + value: "'main'" + - name: datatype + value: "'cribl-demo'" diff --git a/default/pipelines/route.yml b/default/pipelines/route.yml index 0ba8123..5098a82 100644 --- a/default/pipelines/route.yml +++ b/default/pipelines/route.yml @@ -2,19 +2,19 @@ id: default groups: {} comments: [] routes: - # Replace this placeholder route with your real routes. + # Demo route. Replace with your real routes when scaffolding a new pack. # Validator rules: # - id and pipeline must be descriptive (no 'main', no 'route1') # - filter must be a real expression (not literally 'false' / '0') # - output MUST be __group (not input_id) - - id: REPLACE_ROUTE_ID - name: Replace With Route Name + - id: passthrough + name: Demo Passthrough final: true disabled: false - pipeline: REPLACE_PIPELINE_NAME - description: "Placeholder — replace id, name, pipeline, and filter." + pipeline: passthrough + description: "Demo route — replace when scaffolding a new pack." enableOutputExpression: false targetContext: group - filter: "datatype=='replace-with-your-datatype'" + filter: "datatype=='cribl-demo'" clones: [] output: __group diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..53be431 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,70 @@ +# Local development + +## Requirements + +- Node.js 20+ (LTS recommended) +- pnpm 10+ +- Docker (for the local Cribl test container) + +Install Node + pnpm via your package manager of choice. Common paths: + +```sh +# macOS via Homebrew +brew install node pnpm + +# Linux via npm +curl -fsSL https://get.pnpm.io/install.sh | sh - + +# Or via Corepack (ships with Node 16+) +corepack enable pnpm +``` + +## First-time setup + +```sh +git clone https://github.com/dryvist/cc-edge--io.git +cd cc-edge--io +make install # pnpm install in tests/ (also installs lefthook git hooks) +``` + +`make install` runs the `prepare` script in `tests/package.json`, which +installs lefthook git hooks at the repo root. Pre-commit Biome auto-format +fires on every `git commit` going forward. + +## Iterating + +```sh +make docker-up # start cribl/cribl test container +make test # vitest run (typecheck + 11 baseline tests) +make lint # biome check +make format # biome format --write +make typecheck # tsc --noEmit +make docker-down # stop cribl +make clean # purge node_modules, .crbl artifacts, container state +``` + +## Skipping the pre-commit hook + +```sh +LEFTHOOK=0 git commit -m "..." +``` + +Use sparingly — CI runs the same checks and will catch what you skipped. + +## Optional: Nix devShell + +If you use Nix, the org's typescript devShell pins the toolchain (Node, pnpm, +TypeScript, Biome) so it's identical across machines: + +```sh +nix develop github:JacobPEvans/nix-devenv?dir=shells/typescript +``` + +Or via direnv: + +```sh +direnv allow # uses flake.nix at the repo root +``` + +This is purely optional — the standard `brew`/`apt`/`corepack` install path +above works equally well. diff --git a/docs/file-boundary.md b/docs/file-boundary.md new file mode 100644 index 0000000..a87b34a --- /dev/null +++ b/docs/file-boundary.md @@ -0,0 +1,39 @@ +# Generic vs pack-specific files + +The single most important rule for working in any pack scaffolded from this +template: **distinguish generic files from pack-specific files.** + +## Generic — DO NOT modify in pack repos + +Edit only here in the template; updates propagate when packs sync. + +- `tests/cribl-client.ts`, `tests/parse-filter.ts`, `tests/global-setup.ts`, + `tests/test-helpers.ts`, `tests/routes.test.ts`, `tests/pipelines.test.ts`, + `tests/generate-fixtures.ts` +- `tests/package.json`, `tests/tsconfig.json`, `tests/vitest.config.ts`, + `tests/pnpm-lock.yaml` +- `scripts/build-crbl.sh`, `scripts/validate-pack-structure.sh` +- `biome.jsonc`, `.editorconfig`, `lefthook.yml`, `.yamllint.yml` +- `Makefile`, `docker-compose.yml` +- `.github/workflows/cribl-pack-test.yml` (reusable) +- `.github/workflows/cribl-pack-release.yml` (reusable) +- `.github/workflows/test.yml` (caller — only `pack_type:` value changes per pack) +- `.github/workflows/release.yml`, `.github/workflows/release-please.yml` + +If you find yourself wanting to modify any of the above in a pack repo, **stop**. +Either: + +1. The change belongs in this template (open a PR here, then propagate to packs), or +2. The change should be expressed as fixture data (`tests/fixtures//`). + +## Pack-specific — free to modify per-pack + +- `package.json` (name, version, displayName, tags, description) +- `default/pack.yml`, `default/inputs.yml`, `default/samples.yml` +- `default/pipelines/route.yml` +- `default/pipelines//conf.yml` (Eval, Drop, etc.) +- `data/samples/*.json` +- `tests/fixtures//*.json` +- `tests/fixtures/.skip-required-fields` (optional opt-out marker) +- `.release-please-manifest.json`, `release-please-config.json` +- `README.md`, `LICENSE` diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..e3d770a --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,47 @@ +# Release process + +Releases are automated via [release-please](https://github.com/googleapis/release-please). + +## How it works + +1. Merge PRs to `main` using conventional commits (`fix:`, `feat:`, `chore:`, etc.). +2. release-please opens a release PR (or updates an existing one) with the + computed version bump + changelog. +3. Review the release PR. When ready, merge it. +4. The release workflow tags the new version and publishes the `.crbl` artifact + to GitHub Releases. + +## Conventional commit → version bump + +| Commit prefix | Bump | +|---|---| +| `fix:` | patch (`1.2.3` → `1.2.4`) | +| `feat:` | minor (`1.2.3` → `1.3.0`) | +| `BREAKING CHANGE:` footer | **blocked** — see below | + +## Major bumps require manual intervention + +The org-wide release-please workflow forbids automated major bumps (any +`BREAKING CHANGE:` footer would normally trigger one). If you need a major: + +1. Edit `.release-please-manifest.json` directly to bump the major version. +2. Commit + open a PR. +3. release-please picks up the manifest change and proceeds normally. + +This is intentional friction — major bumps should be a human decision, not a +side effect of a stray footer. + +## Don't tag manually + +The release workflow runs only when release-please's bot tags a release. Don't +push tags manually — they'll bypass the changelog automation and produce +inconsistent state. + +## Release artifacts + +Each release publishes: + +- `-.crbl` — versioned tarball +- `.crbl` — "latest" alias (overwritten each release) + +Built by `scripts/build-crbl.sh`, which the reusable release workflow invokes. diff --git a/docs/test-harness.md b/docs/test-harness.md new file mode 100644 index 0000000..eff9d79 --- /dev/null +++ b/docs/test-harness.md @@ -0,0 +1,61 @@ +# Test harness + +Vitest-based, lives entirely in `tests/`. Auto-discovers fixtures by filesystem +convention — no TypeScript edits needed to add tests. + +## What gets tested + +| Suite | What it asserts | +|---|---| +| `routes.test.ts` (structure) | route.yml exists, every route has a pipeline, every referenced pipeline file exists, routes use `output: __group`, filters aren't statically falsy, no pipeline named `main` | +| `routes.test.ts` (dynamic flow) | Per route: a synthetic event matching its filter triggers the named pipeline and isn't dropped (uses live Cribl) | +| `pipelines.test.ts` | Per fixture: pipeline produces non-empty output; partial-match against `.expected.json` if present; required-fields assertion (`sourcetype`+`index` for Edge; `host`+`source`+`_time` for Stream) unless `.skip-required-fields` marker present | + +## Fixture convention + +``` +tests/fixtures//.json # input event(s) +tests/fixtures//.expected.json # optional partial-match expected output +tests/fixtures/.skip-required-fields # optional org-wide opt-out marker +``` + +The generic `pipelines.test.ts` auto-discovers and parametrizes one Vitest case +per `.json`. Add a fixture → tests run automatically. Remove a fixture → +tests stop running. No code changes. + +## Generating expected fixtures + +Run input through the live Cribl container; capture the output trimmed to +partial-match keys (`sourcetype`, `index`, `datatype`, `_raw`, `_time`): + +```sh +make docker-up +cd tests +pnpm exec tsx generate-fixtures.ts fixtures//.json +``` + +Writes `.expected.json` next to the input. + +## Required-fields assertion + +Edge packs are expected to set `sourcetype` and `index` (typically via an Eval +function in the pipeline). Stream packs set `host`, `source`, `_time`. The +assertion fires unconditionally on every pipeline test unless you opt out by +creating an empty `tests/fixtures/.skip-required-fields` file (use this for +pass-through packs whose downstream sets these fields). + +## CriblClient API surface + +`tests/cribl-client.ts` wraps the Cribl management API. Reference: + +| Method | Purpose | +|---|---| +| `waitUntilReady()` | Block until `/health` returns 200 | +| `installPack(tarball, expectedId?)` | Upload `.crbl` + poll until pack registers | +| `deletePack(packId)` | Remove pack | +| `saveSample(name, events)` / `deleteSample(id)` | Sample lifecycle | +| `runPipeline(pipeline, sampleId, {pack})` | Execute via `/preview` (mode `pipe`); returns output events | +| `runRouteFlow(sampleId, events, {pack})` | Local route-matcher fallback (Cribl has no `mode:route`); finds matching route in `route.yml`, then runs its pipeline | +| `assertRequiredFields(events, packType?)` | Assert canonical fields per pack type | +| `startCapture(filter, ...)` / `readCapture(id, ...)` | Live capture primitives (reserved for future integration tests) | +| `createPackTarball(packRoot)` (static) | Build `.crbl` from on-disk pack contents | diff --git a/docs/validator-rules.md b/docs/validator-rules.md new file mode 100644 index 0000000..56d88be --- /dev/null +++ b/docs/validator-rules.md @@ -0,0 +1,27 @@ +# Validator rules (vct-cribl-pack-validator) + +Non-negotiable rules per the [validator skill](https://github.com/VisiCore/vct-cribl-pack-validator). +The CI `validate` job and `scripts/validate-pack-structure.sh` enforce the +machine-checkable subset; the rest are conventions to follow. + +| Rule | What it means | Enforced by | +|---|---|---| +| Pack ID format | `cc-edge--io` for Edge, `cc-stream--io` for Stream | CI (warning) | +| No pipeline named `main` | All pipelines must have descriptive names | `routes.test.ts` | +| All routes use `output: __group` | Never `input_id` (breaks on source rename) | `routes.test.ts` + CI (warning) | +| All sources have `metadata.datatype` | So route filters can match | Convention; verify locally | +| Filters must be dynamic | Never literal `false` / `0` | `routes.test.ts` | +| No hardcoded paths | Use environment variables (`$MY_LOG_PATH`) | Code review | +| No hardcoded credentials | Use Cribl secrets | Code review | +| PII fields masked | `email`, `username`, `*_id`, `src_ip`, etc. before destinations | Code review | + +## Running the deep validator locally + +```sh +make build # produces -.crbl +# Then from the validator repo: +cd ~/git/vct-cribl-pack-validator/main +claude /validate-pack /path/to/-.crbl +``` + +`make validate` prints the exact command after building. diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8b4287b --- /dev/null +++ b/flake.nix @@ -0,0 +1,29 @@ +{ + description = "Dev shell for dryvist Cribl pack template — delegates to nix-devenv's typescript shell."; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-25.11-darwin"; + nix-devenv.url = "github:JacobPEvans/nix-devenv"; + }; + + outputs = + { + nixpkgs, + nix-devenv, + ... + }: + let + systems = [ + "aarch64-darwin" + "x86_64-darwin" + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + devShells = forAllSystems (system: { + default = nix-devenv.devShells.${system}.typescript; + }); + }; +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..74443d7 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,9 @@ +# Pre-commit hooks. Install via: pnpm install --dir tests (runs `prepare`) +# Skip a single commit with: LEFTHOOK=0 git commit ... +pre-commit: + parallel: true + commands: + biome: + glob: '*.{js,jsx,ts,tsx,json,jsonc,yaml,yml,md}' + run: cd tests && pnpm exec biome check --no-errors-on-unmatched --staged --write {staged_files} + stage_fixed: true diff --git a/package.json b/package.json index 5b41bdb..a359d79 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,20 @@ { - "name": "REPLACE-WITH-PACK-NAME", + "name": "cc-edge-claude-code-io", "version": "0.0.1", "minLogStreamVersion": "4.17.0", - "author": "Your Name ", - "description": "REPLACE: Brief description of what this pack does.", - "displayName": "REPLACE With Display Name", + "author": "dryvist ", + "description": "Cribl Edge pack collecting Claude Code telemetry — session logs and OpenTelemetry metrics — for forwarding to a Cribl Stream worker group.", + "displayName": "Claude Code I/O", "tags": { - "streamtags": ["replace", "with", "tags"], - "dataType": ["logs"] + "streamtags": [ + "claude-code", + "ai", + "observability" + ], + "dataType": [ + "logs", + "metrics" + ] }, "exports": [] } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..c04b4c2 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": false, + "release-type": "node", + "packages": { + ".": { + "package-name": "cc-edge-claude-code-io", + "changelog-path": "CHANGELOG.md", + "extra-files": ["package.json"] + } + } +} diff --git a/scripts/build-crbl.sh b/scripts/build-crbl.sh new file mode 100755 index 0000000..4457625 --- /dev/null +++ b/scripts/build-crbl.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Build a Cribl pack .crbl tarball from the current repo's pack contents. +# +# Inputs (env vars): +# REPO_NAME — pack repo name (used in tarball filename); usually $GITHUB_REPOSITORY's name +# TAG_NAME — tag/version to embed in versioned filename; defaults to $GITHUB_REF_NAME +# ADDITIONAL_FILES — optional space-separated extra files to include +# +# Outputs (written via $GITHUB_ENV when run in a GitHub Actions step): +# OUT_VERSIONED — /tmp/-.crbl +# OUT_LATEST — /tmp/.crbl +# +# Standalone usage (outside CI): +# REPO_NAME=cc-edge-foo TAG_NAME=v1.2.3 ./scripts/build-crbl.sh + +set -euo pipefail + +: "${REPO_NAME:?REPO_NAME env var required}" +: "${TAG_NAME:=${GITHUB_REF_NAME:-}}" + +if [[ -z "${TAG_NAME}" ]]; then + echo "::error::TAG_NAME (or GITHUB_REF_NAME) must be set" >&2 + exit 1 +fi + +OUT_VERSIONED="/tmp/${REPO_NAME}-${TAG_NAME}.crbl" +OUT_LATEST="/tmp/${REPO_NAME}.crbl" + +# Standard pack contents (criblpacks convention). +INCLUDE=(data default package.json README.md) +[[ -f LICENSE ]] && INCLUDE+=(LICENSE) +for extra in ${ADDITIONAL_FILES:-}; do + [[ -e "${extra}" ]] && INCLUDE+=("${extra}") +done + +tar -czf "${OUT_VERSIONED}" "${INCLUDE[@]}" +cp "${OUT_VERSIONED}" "${OUT_LATEST}" +ls -lh "${OUT_VERSIONED}" "${OUT_LATEST}" + +# Export for downstream GH Actions steps when in CI. +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "OUT_VERSIONED=${OUT_VERSIONED}" + echo "OUT_LATEST=${OUT_LATEST}" + } >> "${GITHUB_ENV}" +fi diff --git a/scripts/validate-pack-structure.sh b/scripts/validate-pack-structure.sh new file mode 100755 index 0000000..a71e97d --- /dev/null +++ b/scripts/validate-pack-structure.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Structural validation for a dryvist Cribl pack repo. Combines the checks +# that previously lived inline in the cribl-pack-test.yml validate job. +# +# Inputs (env var): +# PACK_TYPE — 'edge' or 'stream' (required) +# +# Tooling required on PATH: jq, yq (mikefarah). +# +# Emits GitHub Actions error/warning annotations + exits non-zero if any +# blocking check fails. Warnings do not block. + +set -euo pipefail + +: "${PACK_TYPE:?PACK_TYPE env var must be 'edge' or 'stream'}" + +if [[ "${PACK_TYPE}" != "edge" && "${PACK_TYPE}" != "stream" ]]; then + echo "::error::pack_type must be 'edge' or 'stream', got '${PACK_TYPE}'" + exit 1 +fi + +failures=0 + +# 1) Required files exist at the pack root. +required_files=(package.json default/pack.yml default/pipelines/route.yml README.md) +for f in "${required_files[@]}"; do + if [[ ! -f "${f}" ]]; then + echo "::error file=${f}::Required pack file missing" + failures=$((failures + 1)) + fi +done + +# 2) package.json shape — name, version, minLogStreamVersion present. +name=$(jq -r '.name // ""' package.json) +version=$(jq -r '.version // ""' package.json) +minLogStreamVersion=$(jq -r '.minLogStreamVersion // ""' package.json) + +for v in name version minLogStreamVersion; do + if [[ -z "${!v}" || "${!v}" == "null" ]]; then + echo "::error file=package.json::Missing required field '${v}'" + failures=$((failures + 1)) + fi +done + +# 2a) Pack name follows the cc-{edge,stream}--io convention (warning only). +expected_prefix="cc-${PACK_TYPE}-" +expected_suffix="-io" +if [[ "${name}" != "${expected_prefix}"*"${expected_suffix}" ]]; then + echo "::warning file=package.json::Pack name '${name}' does not follow '${expected_prefix}${expected_suffix}' convention" +fi + +echo "package.json: name=${name} version=${version} minLogStreamVersion=${minLogStreamVersion}" + +# 3) Every route's pipeline reference resolves to a default/pipelines//conf.yml file. +mapfile -t pipelines_in_routes < <(yq -r '.routes[].pipeline' default/pipelines/route.yml | sort -u) +for pipeline in "${pipelines_in_routes[@]}"; do + if [[ -z "${pipeline}" || "${pipeline}" == "null" ]]; then continue; fi + conf="default/pipelines/${pipeline}/conf.yml" + if [[ ! -f "${conf}" ]]; then + echo "::error::Route references pipeline '${pipeline}' but ${conf} does not exist" + failures=$((failures + 1)) + fi +done + +# 4) Routes use 'output: __group' (validator rule, warning only). +bad_outputs=$(yq -r '.routes[] | select(.output != "__group") | .id' default/pipelines/route.yml) +if [[ -n "${bad_outputs}" ]]; then + echo "::warning::These routes do not use 'output: __group' (validator rule): ${bad_outputs}" +fi + +# 5) Sample events exist (warning only — packs may legitimately ship without samples). +if [[ -d data/samples ]]; then + count=$(find data/samples -maxdepth 1 -name '*.json' -type f | wc -l | tr -d ' ') + if [[ "${count}" -eq 0 ]]; then + echo "::warning::data/samples/ exists but contains no JSON sample events" + else + echo "Found ${count} sample event file(s) in data/samples/" + fi +else + echo "::warning::data/samples/ does not exist — pack lacks sample events" +fi + +if [[ "${failures}" -gt 0 ]]; then + echo "::error::Pack structure validation failed (${failures} blocking error(s))" + exit 1 +fi +echo "Pack structure validation passed." diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 9e618ee..0000000 --- a/tests/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# Tests - -Adopts the [criblpacks](https://github.com/criblpacks) test pattern (Python + Docker + Cribl management API) — generic and DRY across all packs. - -## Installation - -From the pack root, install Python test dependencies into a local virtualenv: - -```sh -python3 -m venv .venv -.venv/bin/pip install -r tests/requirements.txt -``` - -Or via the Makefile (creates the same venv): - -```sh -make install -``` - -The venv lives at the pack root (`.venv/`) so editor LSP tools (Pyright, Pylance, etc.) auto-detect it without additional configuration. - -Tests require Docker for the ephemeral `cribl/cribl` container — see `docker-compose.yml` at the pack root. - -## Usage - -From the pack root: - -```sh -make docker-up # start cribl/cribl Docker container -make test # run pytest -make docker-down # stop the container -``` - -Or run pytest directly (assuming the container is already running): - -```sh -.venv/bin/python -m pytest tests/ -v -``` - -In CI, the reusable workflow `dryvist/.github/.github/workflows/cribl-pack-test.yml` runs the same `pytest tests/` against an ephemeral `cribl/cribl` service container. - -## Layout - -``` -tests/ -├── conftest.py # pytest fixtures (Cribl client, pack install/cleanup) -├── cribl_client.py # API wrapper (adapted from criblpacks/cribl-palo-alto-networks) -├── test_pipelines.py # GENERIC parametrized over fixtures/ -├── test_routes.py # GENERIC route.yml structural assertions -├── fixtures/ -│ ├── / -│ │ ├── .json # input event(s) -│ │ └── .expected.json # (optional) expected output -│ └── ... -├── requirements.txt -└── README.md # this file -``` - -## Fixture Convention - -For each pipeline named `` (in `default/pipelines//conf.yml`): - -- `tests/fixtures//.json` — input event or list of events -- `tests/fixtures//.expected.json` — optional expected output (partial match) - -Both files contain JSON. Inputs may be a single object or a list of objects (each with `_raw` if you have raw text, or any structured fields). - -If `.expected.json` is missing, the test only asserts the pipeline produced non-empty output (smoke test). - -## What's generic vs pack-specific - -| File | Status | -|---|---| -| `cribl_client.py`, `conftest.py`, `test_pipelines.py`, `test_routes.py`, `requirements.txt` | **Generic** — copied unchanged from template, never modify per pack | -| `fixtures//*.json` | **Per-pack** — author one input + one expected per pipeline behavior | - -If you find yourself wanting to write pack-specific Python test code, first ask whether the assertion can be expressed as an `expected.json` partial-match. If yes, prefer that. If no (e.g. complex cross-event invariants), add a `tests/test__extras.py` file alongside the generic ones. - -## API - -Test discovery is driven by filesystem convention — there is no Python API to call. The pytest collector walks `tests/fixtures//` and parametrizes one test case per `.json` it finds. - -For lower-level scripting against a running Cribl instance, the `CriblClient` class in `cribl_client.py` exposes: - -- `wait_until_ready(timeout_seconds)` — block until `/api/v1/health` responds -- `create_pack_tarball(pack_root)` / `install_pack(tarball)` / `delete_pack(pack_id)` -- `save_sample(name, events)` / `delete_sample(sample_id)` / `delete_all_samples()` -- `run_pipeline(pipeline, sample_id, pack)` — execute pipeline against saved sample - -## Contributing - -Generic test files (`cribl_client.py`, `conftest.py`, `test_pipelines.py`, `test_routes.py`, `requirements.txt`) live in `dryvist/cc-edge-pack-template`. Changes must be made there and propagated to consuming packs — do not modify in-place inside a pack repo without first updating the template. - -## License - -Apache-2.0 (matches the template). diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ad92c17..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Shared pytest fixtures. - -Session-scoped: connect to Cribl, build pack tarball from PACK_ROOT, install, -yield client to tests, then clean up. - -Configurable via env vars (defaults match docker-compose.yml and CI service): -- CRIBL_HOST (default: localhost) -- CRIBL_PORT (default: 9000) -- CRIBL_USER (default: admin) -- CRIBL_PASS (default: admin) -""" - -from __future__ import annotations - -import json -import os -from pathlib import Path - -import pytest - -from cribl_client import CriblClient - -PACK_ROOT = Path(__file__).parent.parent - - -@pytest.fixture(scope="session") -def pack_id() -> str: - return json.loads((PACK_ROOT / "package.json").read_text())["name"] - - -@pytest.fixture(scope="session") -def cribl(pack_id: str): - """Cribl client with the pack pre-installed for the duration of the session.""" - client = CriblClient( - host=os.environ.get("CRIBL_HOST", "localhost"), - port=int(os.environ.get("CRIBL_PORT", "9000")), - username=os.environ.get("CRIBL_USER", "admin"), - password=os.environ.get("CRIBL_PASS", "admin"), - ) - client.wait_until_ready() - - tarball = client.create_pack_tarball(PACK_ROOT) - client.install_pack(tarball, expected_id=pack_id) - - yield client - - try: - client.delete_pack(pack_id) - except Exception: - pass - try: - client.delete_all_samples() - except Exception: - pass diff --git a/tests/cribl-client.ts b/tests/cribl-client.ts new file mode 100644 index 0000000..7f71751 --- /dev/null +++ b/tests/cribl-client.ts @@ -0,0 +1,543 @@ +/** + * Cribl management API client for pack testing. + * + * TypeScript port of the criblpacks/cribl-palo-alto-networks pattern. + * Streamlined for Vitest-based fixture testing. + * + * References: + * - https://docs.cribl.io/api-reference/ + * - https://github.com/criblpacks/cribl-palo-alto-networks + */ + +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import * as path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { create as tarCreate } from "tar"; +import { parse as yamlParse } from "yaml"; +import { parseSimpleFilter } from "./parse-filter.js"; + +// PACK_ROOT is the directory containing default/, data/, package.json — i.e. +// one level up from this tests/ folder. +export const PACK_ROOT = path.resolve(import.meta.dirname, ".."); + +export interface CriblClientOptions { + host?: string | undefined; + port?: number | undefined; + username?: string | undefined; + password?: string | undefined; + scheme?: "http" | "https" | undefined; +} + +export interface CriblEvent { + [key: string]: unknown; +} + +export interface Route { + id?: string; + name?: string; + pipeline: string; + filter?: string; + output?: string; + disabled?: boolean; + [key: string]: unknown; +} + +export interface RouteFlowResult { + route: Route; + pipeline: string; + events: CriblEvent[]; +} + +export type PackType = "edge" | "stream"; + +const REQUIRED_FIELDS: Record = { + edge: ["sourcetype", "index"], + stream: ["host", "source", "_time"], +}; + +// Cribl pack contents — only these top-level entries (mix of directories +// and files) ship inside the .crbl. Whitelisting keeps the tarball stable +// as repo-level dev tooling (flake.nix, biome.jsonc, etc.) accumulates. +// node-tar's create() recurses into the directory entries automatically and +// emits proper directory headers (Cribl rejects tarballs missing them). +const PACK_ROOT_ENTRIES = new Set([ + "data", + "default", + "package.json", + "README.md", + "LICENSE", +]); + +export class CriblClient { + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly scheme: string; + private cachedToken: string | null = null; + + constructor(options: CriblClientOptions = {}) { + this.host = options.host ?? "localhost"; + this.port = options.port ?? 9000; + this.username = options.username ?? "admin"; + this.password = options.password ?? "admin"; + this.scheme = options.scheme ?? "http"; + } + + get baseUrl(): string { + return `${this.scheme}://${this.host}:${this.port}/api/v1`; + } + + private async login(): Promise { + const response = await fetch(`${this.baseUrl}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }); + if (!response.ok) { + throw new Error( + `Cribl login failed: HTTP ${response.status} ${await response.text()}`, + ); + } + const body = (await response.json()) as { token: string }; + return body.token; + } + + async token(): Promise { + if (this.cachedToken === null) { + this.cachedToken = await this.login(); + } + return this.cachedToken; + } + + /** + * Send an authenticated HTTP request to the Cribl management API. + * + * The `pack` option scopes the URL to `/p//` (matches + * Cribl's pack-namespaced preview routes). + */ + async call( + method: string, + endpoint: string, + options: { + pack?: string | undefined; + payload?: unknown; + body?: string | Uint8Array | undefined; + params?: Record | undefined; + authenticated?: boolean | undefined; + contentType?: string | undefined; + } = {}, + ): Promise { + const prefix = options.pack !== undefined ? `/p/${options.pack}` : ""; + const search = + options.params !== undefined + ? `?${new URLSearchParams( + Object.fromEntries( + Object.entries(options.params).map(([k, v]) => [k, String(v)]), + ), + ).toString()}` + : ""; + const url = `${this.baseUrl}${prefix}${endpoint}${search}`; + + const headers: Record = {}; + if (options.authenticated !== false) { + headers.authorization = `Bearer ${await this.token()}`; + } + + let body: string | Uint8Array | undefined; + if (options.payload !== undefined) { + headers["content-type"] = options.contentType ?? "application/json"; + body = JSON.stringify(options.payload); + } else if (options.body !== undefined) { + headers["content-type"] = + options.contentType ?? "application/octet-stream"; + body = options.body; + } + + const init: RequestInit = { method: method.toUpperCase(), headers }; + if (body !== undefined) init.body = body; + const response = await fetch(url, init); + const text = await response.text(); + + if (!response.ok) { + throw new Error( + `Cribl API ${method.toUpperCase()} ${endpoint} failed: HTTP ${response.status} ${response.statusText} — ${text || ""}`, + ); + } + + // Try JSON first; fall back to NDJSON; fall back to raw text. + if (text.length === 0) return null; + try { + return JSON.parse(text); + } catch { + // NDJSON (newline-delimited) + const lines = text.split(/\r?\n/).filter((l) => l.length > 0); + try { + return lines.map((line) => JSON.parse(line)); + } catch { + return text; + } + } + } + + // ---- Lifecycle ------------------------------------------------------- + + async waitUntilReady(timeoutSeconds = 120): Promise { + const deadline = Date.now() + timeoutSeconds * 1000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(`${this.baseUrl}/health`); + if (response.status === 200) return; + } catch (err) { + lastError = err; + } + await sleep(2000); + } + throw new Error( + `Cribl at ${this.baseUrl} did not become ready in ${timeoutSeconds}s (last error: ${String(lastError)})`, + ); + } + + // ---- Pack management ------------------------------------------------- + + /** + * Build an in-memory .crbl tarball from packRoot, excluding test/dev directories. + */ + static async createPackTarball(packRoot: string): Promise { + const entries = await collectTarballEntries(packRoot); + const stream = tarCreate( + { + gzip: true, + cwd: packRoot, + portable: true, + // Normalize ownership so the tarball is deterministic. + mtime: new Date(0), + }, + entries, + ); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + + /** + * Upload a .crbl tarball, install it, and (optionally) wait for it to register. + * + * Cribl's pack install is asynchronous — the POST returns before the pack + * is fully registered. If `expectedId` is provided, poll /packs until it + * appears (or throw on timeout). Without `expectedId`, returns immediately + * after the install POST. + */ + async installPack( + tarball: Buffer, + expectedId?: string, + timeoutSeconds = 30, + ): Promise { + const filename = `${randomUUID()}.crbl`; + const upload = await this.call("put", "/packs", { + params: { filename, size: tarball.length }, + body: new Uint8Array(tarball), + }); + if (upload !== null && upload !== undefined) { + await this.call("post", "/packs", { payload: upload }); + } + + if (expectedId === undefined) return; + + const deadline = Date.now() + timeoutSeconds * 1000; + let installed: (string | undefined)[] = []; + while (Date.now() < deadline) { + installed = (await this.listPacks()).map((p) => p.id); + if (installed.includes(expectedId)) return; + await sleep(500); + } + throw new Error( + `Pack '${expectedId}' did not appear in /packs after ${timeoutSeconds}s. Currently installed: ${JSON.stringify(installed)}`, + ); + } + + async deletePack(packId: string): Promise { + const info = await this.call("get", `/packs/${packId}`); + if (info !== null && info !== undefined) { + await this.call("delete", `/packs/${packId}`, { payload: info }); + } + } + + async listPacks(): Promise<{ id?: string }[]> { + const response = await this.call("get", "/packs"); + if ( + response !== null && + typeof response === "object" && + "items" in response + ) { + const items = (response as { items: unknown }).items; + if (Array.isArray(items)) return items as { id?: string }[]; + } + return []; + } + + // ---- Sample lifecycle ----------------------------------------------- + + async saveSample(name: string, events: CriblEvent[]): Promise { + const response = (await this.call("post", "/system/samples", { + payload: { sampleName: name, context: { events } }, + })) as { items: { id: string }[] }; + if (response.items[0] === undefined) { + throw new Error(`saveSample('${name}') returned no items`); + } + return response.items[0].id; + } + + async deleteSample(sampleId: string): Promise { + const info = await this.call("get", `/system/samples/${sampleId}`); + if ( + info !== null && + typeof info === "object" && + "items" in info && + Array.isArray((info as { items: unknown }).items) && + (info as { items: unknown[] }).items.length > 0 + ) { + await this.call("delete", `/system/samples/${sampleId}`, { + payload: (info as { items: unknown[] }).items[0], + }); + } + } + + async deleteAllSamples(): Promise { + const response = await this.call("get", "/system/samples"); + if ( + response === null || + typeof response !== "object" || + !("items" in response) + ) + return; + const items = (response as { items: unknown[] }).items; + if (!Array.isArray(items)) return; + for (const sample of items) { + if ( + sample !== null && + typeof sample === "object" && + "isTemplate" in sample + ) + continue; + const id = (sample as { id?: string }).id; + if (id !== undefined) { + await this.call("delete", `/system/samples/${id}`, { payload: sample }); + } + } + } + + // ---- Pipeline execution --------------------------------------------- + + async runPipeline( + pipeline: string, + sampleId: string, + options: { + pack?: string | undefined; + timeoutMs?: number; + memoryMb?: number; + } = {}, + ): Promise { + const response = await this.call("post", "/preview", { + pack: options.pack, + payload: { + mode: "pipe", + pipelineId: pipeline, + level: 3, + sampleId, + dropped: true, + cpuProfile: false, + timeout: options.timeoutMs ?? 10_000, + memory: options.memoryMb ?? 2048, + }, + }); + if ( + response !== null && + typeof response === "object" && + "items" in response + ) { + const items = (response as { items: unknown }).items; + if (Array.isArray(items)) return items as CriblEvent[]; + } + if (Array.isArray(response)) return response as CriblEvent[]; + return []; + } + + // ---- Route flow + assertions ---------------------------------------- + + /** + * Match events against route.yml filters, then execute the matched pipeline. + * + * Cribl's `/preview` API has no `mode: "route"` — this is the local + * fallback. Loads `PACK_ROOT/default/pipelines/route.yml`, evaluates each + * non-disabled route's filter via `parseSimpleFilter` (canonical + * `==''` matcher), and on the first match calls + * `runPipeline` for that route's pipeline. + * + * Throws if no route matches (lists any filters skipped because the local + * matcher doesn't support them, so the caller can decide whether to skip + * the test vs treat it as a real failure). + */ + async runRouteFlow( + sampleId: string, + events: CriblEvent[], + options: { pack?: string | undefined } = {}, + ): Promise { + const routeYml = path.join(PACK_ROOT, "default", "pipelines", "route.yml"); + const config = yamlParse(await readFile(routeYml, "utf-8")) as { + routes?: Route[]; + }; + + const skippedFilters: string[] = []; + for (const route of config.routes ?? []) { + if (route.disabled === true) continue; + const parsed = parseSimpleFilter(route.filter); + if (parsed === null) { + skippedFilters.push(route.filter ?? ""); + continue; + } + const [field, expectedValue] = parsed; + const matches = events.some((event) => event[field] === expectedValue); + if (matches) { + const output = await this.runPipeline( + route.pipeline, + sampleId, + options, + ); + return { route, pipeline: route.pipeline, events: output }; + } + } + + throw new Error( + `No matching route for given events. Filters skipped (unparseable by local matcher): ${JSON.stringify(skippedFilters)}`, + ); + } + + /** + * Assert every event has the canonical output fields for its pack type. + * + * Edge packs require `sourcetype` + `index` (Splunk-canonical routing fields). + * Stream packs require `host` + `source` + `_time`. + * + * When `packType` is undefined, infer from `package.json` name prefix. + */ + static assertRequiredFields(events: CriblEvent[], packType?: PackType): void { + const resolvedType = packType ?? detectPackType(); + const required = REQUIRED_FIELDS[resolvedType]; + + const violations: string[] = []; + for (const [i, event] of events.entries()) { + const missing = required.filter((field) => !(field in event)); + if (missing.length > 0) { + violations.push(`event ${i}: missing ${JSON.stringify(missing)}`); + } + } + + if (violations.length > 0) { + throw new Error( + `${resolvedType} pack required fields ${JSON.stringify(required)} missing from ${violations.length}/${events.length} event(s):\n ${violations.join("\n ")}`, + ); + } + } + + // ---- Live capture (primitives reserved for future integration tests) - + + async startCapture( + filterExpr: string, + maxEvents = 100, + timeoutMs = 30_000, + ): Promise { + const response = await this.call("post", "/lib/captures", { + payload: { filter: filterExpr, maxEvents, timeout: timeoutMs, level: 0 }, + }); + if ( + response === null || + typeof response !== "object" || + !("captureId" in response) + ) { + throw new Error( + `Unexpected capture response: ${JSON.stringify(response)}`, + ); + } + return (response as { captureId: string }).captureId; + } + + async readCapture( + captureId: string, + options: { pollIntervalMs?: number; timeoutSeconds?: number } = {}, + ): Promise { + const pollIntervalMs = options.pollIntervalMs ?? 1000; + const timeoutSeconds = options.timeoutSeconds ?? 60; + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const response = await this.call( + "get", + `/lib/captures/${captureId}/events`, + ); + if ( + response !== null && + typeof response === "object" && + "status" in response && + (response as { status: string }).status === "complete" + ) { + const items = (response as { items?: unknown }).items; + return Array.isArray(items) ? (items as CriblEvent[]) : []; + } + await sleep(pollIntervalMs); + } + throw new Error( + `Capture ${captureId} did not complete in ${timeoutSeconds}s`, + ); + } +} + +/** + * Infer pack type from the pack root's package.json name field. + */ +export function detectPackType(): PackType { + const pkg = JSON.parse( + readFileSync(path.join(PACK_ROOT, "package.json"), "utf-8"), + ) as { + name?: string; + }; + const name = pkg.name ?? ""; + if (name.startsWith("cc-edge-")) return "edge"; + if (name.startsWith("cc-stream-")) return "stream"; + throw new Error( + `Cannot detect pack_type from package.json name '${name}'; expected 'cc-edge--io' or 'cc-stream--io' prefix.`, + ); +} + +/** + * Read the pack id (package.json name) — used by tests + setup. + */ +export function getPackId(): string { + const pkg = JSON.parse( + readFileSync(path.join(PACK_ROOT, "package.json"), "utf-8"), + ) as { + name?: string; + }; + if (pkg.name === undefined || pkg.name.length === 0) { + throw new Error('package.json missing required "name" field'); + } + return pkg.name; +} + +// ---- Internal helpers -------------------------------------------------- + +async function collectTarballEntries(packRoot: string): Promise { + const dirents = await readdir(packRoot, { withFileTypes: true }); + // Sort for determinism — readdir() ordering varies across filesystems. + return dirents + .filter((d) => PACK_ROOT_ENTRIES.has(d.name)) + .map((d) => d.name) + .sort(); +} diff --git a/tests/cribl_client.py b/tests/cribl_client.py deleted file mode 100644 index f005039..0000000 --- a/tests/cribl_client.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Cribl management API client for pack testing. - -Adapted from the criblpacks/cribl-palo-alto-networks pattern (Cribl's own pack -test pattern). Streamlined for parametrized fixture-based testing. - -References: -- https://github.com/criblpacks/cribl-palo-alto-networks/blob/main/test/cribl_stream.py -- https://docs.cribl.io/api-reference/ -""" - -from __future__ import annotations - -import io -import os -import tarfile -import time -import uuid -from json import JSONDecodeError -from pathlib import Path -from typing import Any - -import ndjson -import requests - - -class CriblClient: - """Thin wrapper around the Cribl management API for pack testing.""" - - def __init__( - self, - host: str = "localhost", - port: int = 9000, - username: str = "admin", - password: str = "admin", - scheme: str = "http", - ) -> None: - self.host = host - self.port = port - self.username = username - self.password = password - self.scheme = scheme - self._token: str | None = None - - @property - def base_url(self) -> str: - return f"{self.scheme}://{self.host}:{self.port}/api/v1" - - @property - def token(self) -> str: - if self._token is None: - self._token = self._login() - return self._token - - def _login(self) -> str: - response = requests.post( - f"{self.base_url}/auth/login", - json={"username": self.username, "password": self.password}, - timeout=10, - ) - response.raise_for_status() - return response.json()["token"] - - def _call( - self, - method: str, - endpoint: str, - *, - pack: str | None = None, - payload: dict | None = None, - data: bytes | None = None, - params: dict | None = None, - authenticated: bool = True, - ) -> Any: - prefix = f"/p/{pack}" if pack else "" - url = f"{self.base_url}{prefix}{endpoint}" - - headers: dict[str, str] = {} - if authenticated: - headers["authorization"] = f"Bearer {self.token}" - - response = requests.request( - method.upper(), - url, - headers=headers, - params=params, - data=data, - json=payload, - timeout=30, - ) - - try: - return response.json() - except JSONDecodeError: - try: - return response.json(cls=ndjson.Decoder) - except Exception: - return response.content - - # ---- Lifecycle --------------------------------------------------------- - - def wait_until_ready(self, timeout_seconds: int = 120) -> None: - """Poll the health endpoint until Cribl is ready or we time out.""" - deadline = time.time() + timeout_seconds - last_err: Exception | None = None - while time.time() < deadline: - try: - response = requests.get( - f"{self.base_url}/health", - timeout=5, - ) - if response.status_code == 200: - return - except requests.RequestException as exc: - last_err = exc - time.sleep(2) - raise TimeoutError( - f"Cribl at {self.base_url} did not become ready in {timeout_seconds}s " - f"(last error: {last_err})" - ) - - # ---- Pack management --------------------------------------------------- - - @staticmethod - def create_pack_tarball(pack_root: Path) -> bytes: - """Build an in-memory .crbl tarball from pack_root. - - Excludes test/dev directories that should not ship inside the pack. - """ - excluded_basenames = { - ".git", - ".github", - "tests", - "test", - "venv", - ".venv", - ".DS_Store", - ".idea", - ".pytest_cache", - "__pycache__", - ".direnv", - } - - def filter_func(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None: - name = os.path.basename(tarinfo.name) - if name in excluded_basenames: - return None - if name.endswith(".crbl"): - return None - tarinfo.uid = tarinfo.gid = 0 - tarinfo.uname = tarinfo.gname = "root" - return tarinfo - - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode="w:gz") as tar: - tar.add(str(pack_root), arcname="", filter=filter_func) - buffer.seek(0) - return buffer.read() - - def install_pack(self, tarball: bytes, expected_id: str | None = None, timeout_seconds: int = 30) -> None: - """Upload a .crbl tarball, install it, and (optionally) wait for it to appear. - - Cribl's pack install is asynchronous — the POST returns before the pack - is fully registered. If expected_id is given, poll /packs until it - appears (or raise TimeoutError). Without expected_id, returns - immediately after the install POST and the caller is responsible for - waiting if needed. - """ - params = { - "filename": f"{uuid.uuid4()}.crbl", - "size": len(tarball), - } - upload = self._call("put", "/packs", params=params, data=tarball) - if upload: - self._call("post", "/packs", payload=upload) - - if expected_id is None: - return - - deadline = time.time() + timeout_seconds - installed: list[str | None] = [] - while time.time() < deadline: - installed = [p.get("id") for p in self.list_packs()] - if expected_id in installed: - return - time.sleep(0.5) - raise TimeoutError( - f"Pack '{expected_id}' did not appear in /packs after {timeout_seconds}s. " - f"Currently installed: {installed}" - ) - - def delete_pack(self, pack_id: str) -> None: - info = self._call("get", f"/packs/{pack_id}") - if info: - self._call("delete", f"/packs/{pack_id}", payload=info) - - def list_packs(self) -> list[dict]: - response = self._call("get", "/packs") - return response.get("items", []) if isinstance(response, dict) else [] - - # ---- Sample lifecycle -------------------------------------------------- - - def save_sample(self, name: str, events: list[dict]) -> str: - """Save events as a named sample, returning the sample ID.""" - payload = { - "sampleName": name, - "context": {"events": events}, - } - response = self._call("post", "/system/samples", payload=payload) - return response["items"][0]["id"] - - def delete_sample(self, sample_id: str) -> None: - info = self._call("get", f"/system/samples/{sample_id}") - if isinstance(info, dict) and info.get("items"): - self._call( - "delete", - f"/system/samples/{sample_id}", - payload=info["items"][0], - ) - - def delete_all_samples(self) -> None: - response = self._call("get", "/system/samples") - if not isinstance(response, dict): - return - for sample in response.get("items", []): - if "isTemplate" in sample: - continue - self._call( - "delete", - f"/system/samples/{sample['id']}", - payload=sample, - ) - - # ---- Pipeline execution ------------------------------------------------ - - def run_pipeline( - self, - pipeline: str, - sample_id: str, - pack: str | None = None, - timeout_ms: int = 10000, - memory_mb: int = 2048, - ) -> list[dict]: - """Execute a pipeline against a saved sample, returning processed events.""" - payload = { - "mode": "pipe", - "pipelineId": pipeline, - "level": 3, - "sampleId": sample_id, - "dropped": True, - "cpuProfile": False, - "timeout": timeout_ms, - "memory": memory_mb, - } - response = self._call("post", "/preview", pack=pack, payload=payload) - if isinstance(response, dict): - return response.get("items", []) - return response or [] diff --git a/tests/fixtures/passthrough/sample.expected.json b/tests/fixtures/passthrough/sample.expected.json new file mode 100644 index 0000000..9573b8b --- /dev/null +++ b/tests/fixtures/passthrough/sample.expected.json @@ -0,0 +1,8 @@ +[ + { + "sourcetype": "cribl:demo", + "index": "main", + "datatype": "cribl-demo", + "_raw": "demo event" + } +] diff --git a/tests/fixtures/passthrough/sample.json b/tests/fixtures/passthrough/sample.json new file mode 100644 index 0000000..ec57193 --- /dev/null +++ b/tests/fixtures/passthrough/sample.json @@ -0,0 +1,7 @@ +[ + { + "_raw": "demo event", + "_time": 1714137600, + "datatype": "cribl-demo" + } +] diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..01a500e --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,55 @@ +/** + * Vitest globalSetup — runs once per test session, before all test files. + * + * Connects to the Cribl container, builds + installs this pack, exposes the + * pack id via env var so test files can reuse it. The token is NOT shared + * across processes; each test file's CriblClient re-authenticates on first + * use (cheap — one HTTP call). + */ + +import { CriblClient, getPackId, PACK_ROOT } from "./cribl-client.js"; + +export async function setup(): Promise { + const packId = getPackId(); + process.env.CRIBL_PACK_ID = packId; + + const client = new CriblClient({ + host: process.env.CRIBL_HOST, + port: + process.env.CRIBL_PORT !== undefined + ? Number(process.env.CRIBL_PORT) + : undefined, + username: process.env.CRIBL_USER, + password: process.env.CRIBL_PASS, + }); + + await client.waitUntilReady(); + const tarball = await CriblClient.createPackTarball(PACK_ROOT); + await client.installPack(tarball, packId); +} + +export async function teardown(): Promise { + const packId = process.env.CRIBL_PACK_ID; + if (packId === undefined) return; + + const client = new CriblClient({ + host: process.env.CRIBL_HOST, + port: + process.env.CRIBL_PORT !== undefined + ? Number(process.env.CRIBL_PORT) + : undefined, + username: process.env.CRIBL_USER, + password: process.env.CRIBL_PASS, + }); + + try { + await client.deletePack(packId); + } catch { + // best-effort cleanup + } + try { + await client.deleteAllSamples(); + } catch { + // best-effort cleanup + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..4d04b13 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,29 @@ +{ + "name": "@dryvist/cc-edge-pack-tests", + "version": "0.0.0", + "private": true, + "description": "Cribl pack test harness — Vitest-based, scaffolded into every dryvist pack from cc-edge-pack-template.", + "type": "module", + "scripts": { + "prepare": "cd .. && lefthook install", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check ..", + "format": "biome format --write .." + }, + "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@biomejs/biome": "^2.0.0", + "@types/node": "^22.0.0", + "@types/tar": "^6.1.13", + "lefthook": "^1.7.0", + "tar": "^7.4.3", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "yaml": "^2.6.0" + } +} diff --git a/tests/parse-filter.ts b/tests/parse-filter.ts new file mode 100644 index 0000000..73c872a --- /dev/null +++ b/tests/parse-filter.ts @@ -0,0 +1,18 @@ +// Canonical Cribl filter shape we can auto-resolve: `==''`. +// Anything more complex (boolean ops, function calls, regex) is out of scope +// for the local evaluator — callers should it.skip when this returns null. +const SIMPLE_FILTER_RE = /^\s*([A-Za-z_]\w*)\s*==\s*['"](.*?)['"]\s*$/; + +/** + * Parse `==''` (or `==""`) into [field, value]. + * Returns null for any expression we cannot statically resolve. + */ +export function parseSimpleFilter( + expr: string | null | undefined, +): [string, string] | null { + const match = (expr ?? "").match(SIMPLE_FILTER_RE); + if (!match || match[1] === undefined || match[2] === undefined) { + return null; + } + return [match[1], match[2]]; +} diff --git a/tests/pipelines.test.ts b/tests/pipelines.test.ts new file mode 100644 index 0000000..e3c9aad --- /dev/null +++ b/tests/pipelines.test.ts @@ -0,0 +1,142 @@ +/** + * Pipeline tests — auto-discovered + parametrized over tests/fixtures/. + * + * GENERIC: copied verbatim from the template into every pack repo. + * + * Convention: + * tests/fixtures//.json -- input event(s) + * tests/fixtures//.expected.json -- (optional) expected output + * tests/fixtures/.skip-required-fields -- (optional) marker; + * when present, the required-fields assertion is bypassed for this pack + * (use for pass-through packs whose downstream sets sourcetype/index) + * + * For each fixture: + * 1. Save the input as a Cribl sample. + * 2. Run it through the named pipeline (within this pack's namespace). + * 3. If .expected.json exists, partial-match-assert each output event + * has the expected fields and values. Extra fields in actual output OK. + * 4. If .expected.json is absent, smoke-test only: assert non-empty. + * 5. Unless `.skip-required-fields` is present, assert every output event + * has the canonical fields for this pack type. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { CriblClient, type CriblEvent } from "./cribl-client.js"; +import { getInstalledPackId, makeClient } from "./test-helpers.js"; + +const FIXTURES = path.join(import.meta.dirname, "fixtures"); +const SKIP_REQUIRED_FIELDS_MARKER = path.join( + FIXTURES, + ".skip-required-fields", +); + +interface FixtureCase { + pipeline: string; + inputFile: string; + expectedFile: string | null; + id: string; +} + +function discoverCases(): FixtureCase[] { + if (!existsSync(FIXTURES)) return []; + const cases: FixtureCase[] = []; + for (const entry of readdirSync(FIXTURES, { withFileTypes: true }).sort( + (a, b) => a.name.localeCompare(b.name), + )) { + if (!entry.isDirectory()) continue; + const pipelineDir = path.join(FIXTURES, entry.name); + const files = readdirSync(pipelineDir).sort(); + for (const file of files) { + if (!file.endsWith(".json") || file.endsWith(".expected.json")) continue; + const stem = file.slice(0, -".json".length); + const expectedFile = path.join(pipelineDir, `${stem}.expected.json`); + cases.push({ + pipeline: entry.name, + inputFile: path.join(pipelineDir, file), + expectedFile: existsSync(expectedFile) ? expectedFile : null, + id: `${entry.name}/${stem}`, + }); + } + } + return cases; +} + +function loadEvents(filePath: string): CriblEvent[] { + const raw = JSON.parse(readFileSync(filePath, "utf-8")) as unknown; + return Array.isArray(raw) ? (raw as CriblEvent[]) : [raw as CriblEvent]; +} + +function assertPartialMatch( + actual: CriblEvent[], + expected: CriblEvent[], + context: string, +): void { + expect( + actual.length, + `${context}: pipeline produced ${actual.length} events, expected ${expected.length}`, + ).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + const act = actual[i]; + const exp = expected[i]; + if (act === undefined || exp === undefined) continue; + for (const [key, expVal] of Object.entries(exp)) { + expect( + key in act, + `${context} event ${i}: expected key '${key}' missing from output`, + ).toBe(true); + expect( + act[key], + `${context} event ${i}, key '${key}': expected ${JSON.stringify(expVal)}, got ${JSON.stringify(act[key])}`, + ).toEqual(expVal); + } + } +} + +const CASES = discoverCases(); +const SKIP_REQUIRED_FIELDS = existsSync(SKIP_REQUIRED_FIELDS_MARKER); + +describe("pipeline behavior (fixture-driven)", () => { + if (CASES.length === 0) { + it.skip("no fixtures found in tests/fixtures//", () => undefined); + return; + } + + for (const fixture of CASES) { + it(`${fixture.id} processes its sample`, async () => { + const events = loadEvents(fixture.inputFile); + const client = makeClient(); + const packId = getInstalledPackId(); + const sampleId = await client.saveSample( + path.basename(fixture.inputFile, ".json"), + events, + ); + try { + const result = await client.runPipeline(fixture.pipeline, sampleId, { + pack: packId, + }); + + expect( + result.length, + `Pipeline '${fixture.pipeline}' produced no output for ${path.basename(fixture.inputFile)}. Check the pipeline's filter/drop conditions.`, + ).toBeGreaterThan(0); + + if (fixture.expectedFile !== null) { + const expected = loadEvents(fixture.expectedFile); + assertPartialMatch( + result, + expected, + path.basename(fixture.inputFile), + ); + } + + if (!SKIP_REQUIRED_FIELDS) { + CriblClient.assertRequiredFields(result); + } + } finally { + await client.deleteSample(sampleId); + } + }); + } +}); diff --git a/tests/pnpm-lock.yaml b/tests/pnpm-lock.yaml new file mode 100644 index 0000000..6999261 --- /dev/null +++ b/tests/pnpm-lock.yaml @@ -0,0 +1,1169 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.0.0 + version: 2.4.13 + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + '@types/tar': + specifier: ^6.1.13 + version: 6.1.13 + lefthook: + specifier: ^1.7.0 + version: 1.13.6 + tar: + specifier: ^7.4.3 + version: 7.5.13 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.17) + yaml: + specifier: ^2.6.0 + version: 2.8.3 + +packages: + + '@biomejs/biome@2.4.13': + resolution: {integrity: sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.13': + resolution: {integrity: sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.13': + resolution: {integrity: sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.13': + resolution: {integrity: sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.13': + resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.13': + resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.13': + resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.13': + resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.13': + resolution: {integrity: sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + + '@types/tar@6.1.13': + resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lefthook-darwin-arm64@1.13.6: + resolution: {integrity: sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@1.13.6: + resolution: {integrity: sha512-CoRpdzanu9RK3oXR1vbEJA5LN7iB+c7hP+sONeQJzoOXuq4PNKVtEaN84Gl1BrVtCNLHWFAvCQaZPPiiXSy8qg==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@1.13.6: + resolution: {integrity: sha512-X4A7yfvAJ68CoHTqP+XvQzdKbyd935sYy0bQT6Ajz7FL1g7hFiro8dqHSdPdkwei9hs8hXeV7feyTXbYmfjKQQ==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@1.13.6: + resolution: {integrity: sha512-ai2m+Sj2kGdY46USfBrCqLKe9GYhzeq01nuyDYCrdGISePeZ6udOlD1k3lQKJGQCHb0bRz4St0r5nKDSh1x/2A==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@1.13.6: + resolution: {integrity: sha512-cbo4Wtdq81GTABvikLORJsAWPKAJXE8Q5RXsICFUVznh5PHigS9dFW/4NXywo0+jfFPCT6SYds2zz4tCx6DA0Q==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@1.13.6: + resolution: {integrity: sha512-uJl9vjCIIBTBvMZkemxCE+3zrZHlRO7Oc+nZJ+o9Oea3fu+W82jwX7a7clw8jqNfaeBS+8+ZEQgiMHWCloTsGw==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@1.13.6: + resolution: {integrity: sha512-7r153dxrNRQ9ytRs2PmGKKkYdvZYFPre7My7XToSTiRu5jNCq++++eAKVkoyWPduk97dGIA+YWiEr5Noe0TK2A==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@1.13.6: + resolution: {integrity: sha512-Z+UhLlcg1xrXOidK3aLLpgH7KrwNyWYE3yb7ITYnzJSEV8qXnePtVu8lvMBHs/myzemjBzeIr/U/+ipjclR06g==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@1.13.6: + resolution: {integrity: sha512-Uxef6qoDxCmUNQwk8eBvddYJKSBFglfwAY9Y9+NnnmiHpWTjjYiObE9gT2mvGVpEgZRJVAatBXc+Ha5oDD/OgQ==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@1.13.6: + resolution: {integrity: sha512-mOZoM3FQh3o08M8PQ/b3IYuL5oo36D9ehczIw1dAgp1Ly+Tr4fJ96A+4SEJrQuYeRD4mex9bR7Ps56I73sBSZA==} + cpu: [x64] + os: [win32] + + lefthook@1.13.6: + resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.4.13': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.13 + '@biomejs/cli-darwin-x64': 2.4.13 + '@biomejs/cli-linux-arm64': 2.4.13 + '@biomejs/cli-linux-arm64-musl': 2.4.13 + '@biomejs/cli-linux-x64': 2.4.13 + '@biomejs/cli-linux-x64-musl': 2.4.13 + '@biomejs/cli-win32-arm64': 2.4.13 + '@biomejs/cli-win32-x64': 2.4.13 + + '@biomejs/cli-darwin-arm64@2.4.13': + optional: true + + '@biomejs/cli-darwin-x64@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-arm64@2.4.13': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.13': + optional: true + + '@biomejs/cli-linux-x64@2.4.13': + optional: true + + '@biomejs/cli-win32-arm64@2.4.13': + optional: true + + '@biomejs/cli-win32-x64@2.4.13': + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + + '@types/tar@6.1.13': + dependencies: + '@types/node': 22.19.17 + minipass: 4.2.8 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.17))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.17) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chownr@3.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fsevents@2.3.3: + optional: true + + lefthook-darwin-arm64@1.13.6: + optional: true + + lefthook-darwin-x64@1.13.6: + optional: true + + lefthook-freebsd-arm64@1.13.6: + optional: true + + lefthook-freebsd-x64@1.13.6: + optional: true + + lefthook-linux-arm64@1.13.6: + optional: true + + lefthook-linux-x64@1.13.6: + optional: true + + lefthook-openbsd-arm64@1.13.6: + optional: true + + lefthook-openbsd-x64@1.13.6: + optional: true + + lefthook-windows-arm64@1.13.6: + optional: true + + lefthook-windows-x64@1.13.6: + optional: true + + lefthook@1.13.6: + optionalDependencies: + lefthook-darwin-arm64: 1.13.6 + lefthook-darwin-x64: 1.13.6 + lefthook-freebsd-arm64: 1.13.6 + lefthook-freebsd-x64: 1.13.6 + lefthook-linux-arm64: 1.13.6 + lefthook-linux-x64: 1.13.6 + lefthook-openbsd-arm64: 1.13.6 + lefthook-openbsd-x64: 1.13.6 + lefthook-windows-arm64: 1.13.6 + lefthook-windows-x64: 1.13.6 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minipass@4.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tar@7.5.13: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@2.1.9(@types/node@22.19.17): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.17) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.17): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.10 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.19.17): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.17)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.17) + vite-node: 2.1.9(@types/node@22.19.17) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.17 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yallist@5.0.0: {} + + yaml@2.8.3: {} diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index a00c0df..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests==2.32.4 -ndjson==0.3.1 -PyYAML==6.0.2 -pytest==8.3.4 diff --git a/tests/routes.test.ts b/tests/routes.test.ts new file mode 100644 index 0000000..77ccd33 --- /dev/null +++ b/tests/routes.test.ts @@ -0,0 +1,158 @@ +/** + * Route tests — structural + dynamic flow. + * + * GENERIC: copied verbatim from the template into every pack repo. + * + * Validates: + * - route.yml exists + * - every route declares a pipeline + * - every referenced pipeline has a default/pipelines//conf.yml + * - every route uses 'output: __group' (vct-cribl-pack-validator rule) + * - no route filter is statically falsy (would never match) + * - no pipeline named 'main' (validator rule) + * - DYNAMIC: for each route with an auto-resolvable filter, a synthetic + * event matching that filter triggers the named pipeline and isn't dropped + */ + +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse as yamlParse } from "yaml"; +import { PACK_ROOT, type Route } from "./cribl-client.js"; +import { parseSimpleFilter } from "./parse-filter.js"; +import { getInstalledPackId, makeClient } from "./test-helpers.js"; + +const ROUTE_YML = path.join(PACK_ROOT, "default", "pipelines", "route.yml"); +const PIPELINES_DIR = path.join(PACK_ROOT, "default", "pipelines"); +const ROUTE_YML_EXISTS = existsSync(ROUTE_YML); + +function loadRoutes(): { routes?: Route[] } { + return yamlParse(readFileSync(ROUTE_YML, "utf-8")) as { routes?: Route[] }; +} + +describe("route structure", () => { + it("route.yml exists", () => { + expect(ROUTE_YML_EXISTS).toBe(true); + }); + + // Subsequent tests parse route.yml. If the file is missing, they would + // throw ENOENT (unhelpful) and obscure the real issue (the failing + // 'route.yml exists' assertion above). Skip them instead. + describe.skipIf(!ROUTE_YML_EXISTS)("with route.yml present", () => { + it("routes are declared", () => { + const config = loadRoutes(); + expect(config.routes).toBeDefined(); + expect(config.routes?.length ?? 0).toBeGreaterThan(0); + }); + + it("routes have pipelines", () => { + const config = loadRoutes(); + for (const route of config.routes ?? []) { + expect( + route.pipeline, + `Route '${route.id ?? ""}' missing 'pipeline' field`, + ).toBeTruthy(); + } + }); + + it("pipeline files exist for each route", () => { + const config = loadRoutes(); + for (const route of config.routes ?? []) { + const conf = path.join(PIPELINES_DIR, route.pipeline, "conf.yml"); + expect( + existsSync(conf), + `Route '${route.id}' references pipeline '${route.pipeline}' but ${conf} does not exist`, + ).toBe(true); + } + }); + + it("routes use __group output (vct-cribl-pack-validator rule)", () => { + const config = loadRoutes(); + const bad = (config.routes ?? []) + .filter((r) => r.output !== "__group") + .map((r) => r.id); + expect( + bad.length, + `Routes not using output: __group: ${JSON.stringify(bad)}. Per validator rule, routes should target __group so source renames don't break routing.`, + ).toBe(0); + }); + + it("route filters are not statically falsy", () => { + const config = loadRoutes(); + for (const route of config.routes ?? []) { + expect(route.filter, `Route '${route.id}' has no filter`).toBeDefined(); + const normalised = String(route.filter ?? "") + .trim() + .toLowerCase(); + expect( + ["false", "0", '""', "''"].includes(normalised), + `Route '${route.id}' has falsy filter '${route.filter}' — would never match`, + ).toBe(false); + } + }); + + it("no pipeline named main (vct-cribl-pack-validator rule)", () => { + const config = loadRoutes(); + for (const route of config.routes ?? []) { + expect( + route.pipeline, + `Route '${route.id}' uses pipeline 'main'. Per validator rule, pipelines must have descriptive names.`, + ).not.toBe("main"); + } + }); + }); +}); + +describe("route flow (dynamic)", () => { + const config = existsSync(ROUTE_YML) ? loadRoutes() : { routes: [] }; + const routesForFlow = (config.routes ?? []).filter( + (r) => r.disabled !== true, + ); + + if (routesForFlow.length === 0) { + it.skip("no routes declared in default/pipelines/route.yml", () => + undefined); + return; + } + + for (const route of routesForFlow) { + const parsed = parseSimpleFilter(route.filter); + const skipReason = + parsed === null + ? `filter '${route.filter}' not auto-resolvable by the local matcher (expected \`==''\`)` + : null; + + it.skipIf(skipReason !== null)( + `${route.id ?? ""} routes a synthetic event to its pipeline${skipReason !== null ? ` [skip: ${skipReason}]` : ""}`, + async () => { + // parsed is guaranteed non-null here (test would be skipped otherwise), + // but TS doesn't know — re-derive in-test for type narrowing. + const reparsed = parseSimpleFilter(route.filter); + if (reparsed === null) throw new Error("unreachable: skipped above"); + const [field, value] = reparsed; + const event = { [field]: value, _raw: "{}", _time: Date.now() / 1000 }; + + const client = makeClient(); + const packId = getInstalledPackId(); + const sampleId = await client.saveSample(`route-flow-${route.id}`, [ + event, + ]); + try { + const result = await client.runRouteFlow(sampleId, [event], { + pack: packId, + }); + expect( + result.route.id, + `Expected route '${route.id}' to match the synthetic event, but route '${result.route.id}' matched first.`, + ).toBe(route.id); + expect( + result.events.length, + `Pipeline '${result.pipeline}' produced no output for the synthetic event matching route '${route.id}' — check the pipeline for unconditional Drop functions.`, + ).toBeGreaterThan(0); + } finally { + await client.deleteSample(sampleId); + } + }, + ); + } +}); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts new file mode 100644 index 0000000..a7701f5 --- /dev/null +++ b/tests/test-helpers.ts @@ -0,0 +1,32 @@ +/** + * Per-test-file Cribl client helper. + * + * Vitest's globalSetup runs in a separate process, so we can't share a token + * — each test file constructs its own CriblClient that re-authenticates on + * first use. The pack itself is already installed (from globalSetup); the + * pack id flows via env var. + */ + +import { CriblClient } from "./cribl-client.js"; + +export function makeClient(): CriblClient { + return new CriblClient({ + host: process.env.CRIBL_HOST, + port: + process.env.CRIBL_PORT !== undefined + ? Number(process.env.CRIBL_PORT) + : undefined, + username: process.env.CRIBL_USER, + password: process.env.CRIBL_PASS, + }); +} + +export function getInstalledPackId(): string { + const id = process.env.CRIBL_PACK_ID; + if (id === undefined || id.length === 0) { + throw new Error( + "CRIBL_PACK_ID env var is unset — globalSetup did not run or failed silently.", + ); + } + return id; +} diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py deleted file mode 100644 index 3de63a4..0000000 --- a/tests/test_pipelines.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Pipeline tests — generic, parametrized over tests/fixtures/. - -Convention: - tests/fixtures//.json -- input event(s) - tests/fixtures//.expected.json -- (optional) expected output - -For each fixture: -1. Save the input as a Cribl sample. -2. Run it through the named pipeline (within this pack's namespace). -3. If .expected.json exists, partial-match-assert each output event has - the expected fields and values. Extra fields in actual output are allowed. -4. If .expected.json is absent, smoke-test only: assert non-empty output. - -This file is GENERIC — copied verbatim from the template into every pack repo. -Pack-specific behavior is expressed entirely through fixture data. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -FIXTURES = Path(__file__).parent / "fixtures" - - -def _discover_cases() -> list: - if not FIXTURES.exists(): - return [] - cases = [] - for pipeline_dir in sorted(p for p in FIXTURES.iterdir() if p.is_dir()): - pipeline = pipeline_dir.name - for inp in sorted(pipeline_dir.glob("*.json")): - if inp.stem.endswith(".expected") or inp.name.endswith(".expected.json"): - continue - exp = inp.with_name(inp.stem + ".expected.json") - cases.append( - pytest.param( - pipeline, - inp, - exp if exp.exists() else None, - id=f"{pipeline}/{inp.stem}", - ) - ) - return cases - - -CASES = _discover_cases() - - -def _load_events(path: Path) -> list[dict]: - raw = json.loads(path.read_text()) - return raw if isinstance(raw, list) else [raw] - - -def _assert_partial_match(actual: list[dict], expected: list[dict], context: str) -> None: - assert len(actual) == len(expected), ( - f"{context}: pipeline produced {len(actual)} events, expected {len(expected)}" - ) - for i, (act, exp) in enumerate(zip(actual, expected)): - for key, exp_val in exp.items(): - assert key in act, ( - f"{context} event {i}: expected key '{key}' missing from output" - ) - assert act[key] == exp_val, ( - f"{context} event {i}, key '{key}': " - f"expected {exp_val!r}, got {act[key]!r}" - ) - - -@pytest.mark.skipif( - not CASES, - reason="No fixtures found in tests/fixtures//", -) -@pytest.mark.parametrize("pipeline,input_file,expected_file", CASES) -def test_pipeline_processes_sample( - cribl, - pack_id: str, - pipeline: str, - input_file: Path, - expected_file: Path | None, -) -> None: - events = _load_events(input_file) - sample_id = cribl.save_sample(input_file.stem, events) - try: - result = cribl.run_pipeline(pipeline, sample_id, pack=pack_id) - - assert result, ( - f"Pipeline '{pipeline}' produced no output for {input_file.name}. " - "Check the pipeline's filter/drop conditions." - ) - - if expected_file is None: - return # Smoke-test only - - expected = _load_events(expected_file) - _assert_partial_match(result, expected, context=input_file.name) - finally: - cribl.delete_sample(sample_id) diff --git a/tests/test_routes.py b/tests/test_routes.py deleted file mode 100644 index 369445c..0000000 --- a/tests/test_routes.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Route tests — generic, validates route.yml structure and references. - -This file is GENERIC — copied verbatim from the template into every pack repo. -Validates: -- route.yml exists -- every route declares a pipeline -- every referenced pipeline has a default/pipelines//conf.yml -- every route uses 'output: __group' (vct-cribl-pack-validator rule) -- no route filter is statically falsy (would never match) -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml - -PACK_ROOT = Path(__file__).parent.parent -ROUTE_YML = PACK_ROOT / "default" / "pipelines" / "route.yml" -PIPELINES_DIR = PACK_ROOT / "default" / "pipelines" - - -def _load_routes() -> dict: - with open(ROUTE_YML) as fh: - return yaml.safe_load(fh) - - -def test_route_yml_exists() -> None: - assert ROUTE_YML.exists(), f"{ROUTE_YML} does not exist" - - -def test_routes_are_declared() -> None: - config = _load_routes() - assert config.get("routes"), f"{ROUTE_YML} declares no routes" - - -def test_routes_have_pipelines() -> None: - config = _load_routes() - for route in config["routes"]: - assert route.get("pipeline"), ( - f"Route '{route.get('id', '')}' missing 'pipeline' field" - ) - - -def test_pipeline_files_exist_for_each_route() -> None: - config = _load_routes() - for route in config["routes"]: - pipeline = route["pipeline"] - conf = PIPELINES_DIR / pipeline / "conf.yml" - assert conf.exists(), ( - f"Route '{route.get('id')}' references pipeline '{pipeline}' " - f"but {conf} does not exist" - ) - - -def test_routes_use_group_output() -> None: - """vct-cribl-pack-validator rule: routes should output to __group, not input_id.""" - config = _load_routes() - bad = [ - r.get("id") for r in config["routes"] - if r.get("output") != "__group" - ] - if bad: - pytest.fail( - f"Routes not using output: __group: {bad}. " - "Per validator rule, routes should target __group " - "so source renames don't break routing." - ) - - -def test_routes_filters_not_statically_falsy() -> None: - config = _load_routes() - for route in config["routes"]: - f = route.get("filter") - assert f is not None, f"Route '{route.get('id')}' has no filter" - normalised = str(f).strip().lower() - assert normalised not in ("false", "0", '""', "''"), ( - f"Route '{route.get('id')}' has falsy filter '{f}' — would never match" - ) - - -def test_no_pipeline_named_main() -> None: - """vct-cribl-pack-validator rule: no pipeline should be named 'main'.""" - config = _load_routes() - for route in config["routes"]: - assert route["pipeline"] != "main", ( - f"Route '{route.get('id')}' uses pipeline 'main'. " - "Per validator rule, pipelines must have descriptive names." - ) diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..7e75c94 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "fixtures"] +} diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts new file mode 100644 index 0000000..9356ccf --- /dev/null +++ b/tests/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Single sequential worker — pack install is shared across files via globalSetup. + fileParallelism: false, + globalSetup: ["./global-setup.ts"], + testTimeout: 60_000, + hookTimeout: 120_000, + reporters: ["verbose"], + }, +});