diff --git a/projects/openshell-exporter/.dockerignore b/projects/openshell-exporter/.dockerignore new file mode 100644 index 00000000..b1fa5e2e --- /dev/null +++ b/projects/openshell-exporter/.dockerignore @@ -0,0 +1,7 @@ +bin +state +output +input +secrets +.env +.git diff --git a/projects/openshell-exporter/.gitignore b/projects/openshell-exporter/.gitignore new file mode 100644 index 00000000..76f1074c --- /dev/null +++ b/projects/openshell-exporter/.gitignore @@ -0,0 +1,7 @@ +/bin/ +/state/ +/output/ +/input/ +/secrets/ +.env +*.local.yaml diff --git a/projects/openshell-exporter/AGENTS.md b/projects/openshell-exporter/AGENTS.md new file mode 100644 index 00000000..4e39a2d2 --- /dev/null +++ b/projects/openshell-exporter/AGENTS.md @@ -0,0 +1,9 @@ +# OpenShell exporter module + +Keep this Go module self-contained. Use Go commands directly; do not add wrapper +scripts. Run `go mod verify`, `go vet ./...`, `go test -race ./...`, and +`go build ./cmd/...` after substantive changes. Test documented configurations. +Preserve stable telemetry scope names and calculate identity before redaction. +Keep separate persistent checkpoints, queues and recovery with one writer. +Receiver/TLS/durability behavior changes require relevant live qualification. +Do not publish tags or claim remote installation works until the module is public. diff --git a/projects/openshell-exporter/Dockerfile b/projects/openshell-exporter/Dockerfile new file mode 100644 index 00000000..8b2e6f6d --- /dev/null +++ b/projects/openshell-exporter/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.19@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6 +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26.8-bookworm@sha256:9fdc884aacc3bec89b20ffc69f4bb369c78210e3e4f600387b5128b12c199f81 AS builder +ARG TARGETOS +ARG TARGETARCH +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download && go mod verify +COPY . . +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags='-s -w' -o /out/ ./cmd/openshell-event-exporter ./cmd/healthcheck +RUN go install github.com/google/go-licenses/v2@v2.0.1 && \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} /go/bin/go-licenses save ./cmd/openshell-event-exporter ./cmd/healthcheck \ + --ignore=github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter --save_path=/out/licenses + +FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab + +ARG VERSION=dev +ARG VCS_REF=unknown +ARG CREATED=unknown +ARG IMAGE_SOURCE=https://github.com/NVIDIA/OpenShell-Research +LABEL org.opencontainers.image.title="OpenShell Event Exporter" \ + org.opencontainers.image.description="Vendor-neutral OpenShell event and OCSF exporter" \ + org.opencontainers.image.source="${IMAGE_SOURCE}" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.created="${CREATED}" + +COPY --from=builder /out/openshell-event-exporter /usr/local/bin/openshell-event-exporter +COPY --from=builder /out/healthcheck /usr/local/bin/healthcheck + +COPY LICENSE NOTICE /usr/share/licenses/openshell-exporter/ +COPY --from=builder /out/licenses /usr/share/licenses/third-party/ +COPY --from=builder /usr/local/go/LICENSE /usr/share/licenses/go/LICENSE + +USER nonroot:nonroot +EXPOSE 13133 8888 4317 4318 4319 4320 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["/usr/local/bin/healthcheck"] +ENTRYPOINT ["/usr/local/bin/openshell-event-exporter"] +CMD ["--config", "/etc/openshell-event-exporter/config.yaml"] diff --git a/projects/openshell-exporter/LICENSE b/projects/openshell-exporter/LICENSE new file mode 100644 index 00000000..f76e7a1e --- /dev/null +++ b/projects/openshell-exporter/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025-2026 NVIDIA CORPORATION & AFFILIATES + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/projects/openshell-exporter/NOTICE b/projects/openshell-exporter/NOTICE new file mode 100644 index 00000000..438e8301 --- /dev/null +++ b/projects/openshell-exporter/NOTICE @@ -0,0 +1,4 @@ +OpenShell Event Exporter +Copyright 2026 NVIDIA Corporation + +This product includes software developed by the OpenTelemetry Authors. diff --git a/projects/openshell-exporter/README.md b/projects/openshell-exporter/README.md new file mode 100644 index 00000000..954c6ecf --- /dev/null +++ b/projects/openshell-exporter/README.md @@ -0,0 +1,136 @@ +# OpenShell Event Exporter + +Collect OpenShell events, normalize and redact records, and deliver CloudEvents +or OTLP. This self-contained Go module runs as a local process or a Docker +container, with the full exporter component set. Experimental research software, +provided as-is; use at your own risk. + +For other deployment methods and more advanced features, contact +[delgadof@nvidia.com](mailto:delgadof@nvidia.com). + +## Quickstart: local process + +Use [Go 1.26.8](https://go.dev/doc/install) and a Bash-compatible shell. Get the +source and enter the project directory: + +```sh +git clone https://github.com/NVIDIA/OpenShell-Research.git +cd OpenShell-Research/projects/openshell-exporter +``` + +The build +downloads public dependencies. This first run uses a synthetic OCSF record; it +needs no OpenShell gateway, credentials, or model service. + +```sh +go build -o bin/openshell-event-exporter ./cmd/openshell-event-exporter +mkdir -p input state output +chmod 700 state output +printf '%s\n' '{"class_uid":4001,"category_uid":4,"activity_id":1,"type_uid":400101,"time":1789120800000,"metadata":{"uid":"quickstart-1","version":"1.8.0"},"message":"OpenShell exporter quickstart"}' > input/example.jsonl +./bin/openshell-event-exporter validate --config config.yaml +./bin/openshell-event-exporter --config config.yaml +``` + +In another terminal, run `curl --fail http://127.0.0.1:13133/` and +`cat output/events.json`. Output contains `quickstart-1` in a normalized record. +The file uses OpenTelemetry JSON, not bare OCSF JSONL or a CloudEvents batch. +Ctrl-C stops the process. Restarting resumes saved file checkpoints; keep `state` +and `output`. Stop the local process before trying the container with those paths. + +## Quickstart: Docker + +Use Docker Engine or Docker Desktop. Create the input and directories as above, +then build and run from this project directory: + +```sh +docker build -t openshell-event-exporter:local . +docker run --rm --name openshell-exporter \ + --user "$(id -u):$(id -g)" --read-only --cap-drop ALL \ + --security-opt no-new-privileges:true --tmpfs /tmp:rw,noexec,nosuid,size=32m \ + --workdir /work -e EXPORTER_HEALTH_ENDPOINT=0.0.0.0:13133 \ + -p 127.0.0.1:13133:13133 \ + -v "$PWD/config.yaml:/work/config.yaml:ro" \ + -v "$PWD/input:/work/input:ro" \ + -v "$PWD/state:/work/state" -v "$PWD/output:/work/output" \ + openshell-event-exporter:local --config /work/config.yaml +``` + +The image health check probes `127.0.0.1:13133`; retain that port inside the +container. Its optional `healthcheck --writable DIRECTORY ...` command checks +persistence-directory permissions. + +Use the same health and output checks. With existing checkpoints the example is +not replayed; append a new record to test more delivery. Stop with +`docker stop openshell-exporter`. Build locally; no published image is required. +Linux AMD64/ARM64 and native macOS ARM64 are build targets; Docker on Apple Silicon +runs the Linux ARM64 image through Docker Desktop. + +## Connect real inputs and destinations + +For actual OCSF logs, set `receivers.file_log/ocsf.include` in [config.yaml](config.yaml) +to the readable source file glob. In Docker, mount the source directory read-only +and use its container path in the glob. Mount the directory, so rotated files remain +visible. Logs inside an OpenShell sandbox must first be made accessible to the +exporter; this sample does not install a sandbox agent. + +[template.config.yaml](template.config.yaml) describes all exporter-specific +settings and included component types, including gateway collection, OTLP, privacy +filtering, and delivery. Select the needed pipelines and mount their credentials +and persistent storage. The template is a reference, not a ready-to-run deployment. +`openshell-event-exporter components` lists the full component set. + +For an HTTPS receiver that expects **bare OCSF inside CloudEvents `data`**, use +[http.config.yaml](http.config.yaml). It reads the same input files and sends +CloudEvents batches with a persistent retry queue. This configuration bypasses +normalization and redaction; source content is forwarded as supplied. Provision +`secrets/destination-token` (raw bearer token) and `secrets/destination-ca.pem` +(the trusted issuing CA), restrict their access, then run: + +```sh +export EXPORTER_DESTINATION_ENDPOINT=https://receiver.example.com:8090/events +./bin/openshell-event-exporter validate --config http.config.yaml +./bin/openshell-event-exporter --config http.config.yaml +``` + +For Docker, use the same run command with the config mount changed to +`-v "$PWD/http.config.yaml:/work/config.yaml:ro"`, and add +`-e EXPORTER_DESTINATION_ENDPOINT -v "$PWD/secrets:/work/secrets:ro"`. +Use a hostname reachable from the container and present in the server certificate. +Docker Desktop can reach a host receiver using `host.docker.internal`; Linux +Docker Engine can add `--add-host host.docker.internal:host-gateway`. The receiver +must listen on a reachable interface. Bearer authentication requires HTTPS. + +For normalized, redacted HTTP delivery, add `openshell` from `config.yaml` to that +pipeline after `memory_limiter`. The receiver must then read the redacted OCSF +record from `data.original`. A 2xx acknowledges a batch; retryable failures retry +and may produce duplicates. Consumers should deduplicate by `(source,id)`. +Raw mode hashes the body for identity, so identical source bodies share an ID. +Append a new input record when switching configurations; saved checkpoints do not +replay records already read. +Health and local recovery output alone do not establish successful remote delivery. + +Keep one writer per state directory and separate checkpoint, queue and recovery +paths. Restrict access and manage retention; do not clear state to recover space. +Malformed and oversized input can be marked or rejected; inspect recovery records +and exporter logs. The exporter observes evidence; it does not approve policy changes. + +## Go package and development + +`go install ./cmd/openshell-event-exporter` installs from this checkout. The module +path is `github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter`; a remote +`go install ...@version` needs a public release tagged +`projects/openshell-exporter/v`. No such release is published by this change. +Custom Collectors can import the component packages and register their `NewFactory`. + +```sh +go mod verify +go vet ./... +go test -race ./... +go build ./cmd/... +``` + +The source is extracted from exporter snapshot +`6a440f05249ac05aa9b9ef8acada4c909ec86bc8`. This distribution retains the Go +implementation and component tests; deployment tooling lives separately. +Preserve LICENSE and NOTICE when redistributing. Synthetic local and container +checks do not qualify live gateway or external application behavior. diff --git a/projects/openshell-exporter/VERSION b/projects/openshell-exporter/VERSION new file mode 100644 index 00000000..6a546fb9 --- /dev/null +++ b/projects/openshell-exporter/VERSION @@ -0,0 +1 @@ +0.0.5-rc.1 diff --git a/projects/openshell-exporter/cmd/healthcheck/main.go b/projects/openshell-exporter/cmd/healthcheck/main.go new file mode 100644 index 00000000..6e3688ae --- /dev/null +++ b/projects/openshell-exporter/cmd/healthcheck/main.go @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +// Command healthcheck probes Collector health or verifies writable persistence paths without a shell. +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "time" +) + +func main() { + if len(os.Args) > 1 { + if os.Args[1] != "--writable" || len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: healthcheck [--writable DIRECTORY ...]") + os.Exit(2) + } + for _, directory := range os.Args[2:] { + if err := checkWritable(directory); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + return + } + if err := probeHealth(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func probeHealth() error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:13133/", nil) + if err != nil { + return err + } + response, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { + _ = response.Body.Close() + }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("health endpoint returned %s", response.Status) + } + return nil +} + +func checkWritable(directory string) error { + file, err := os.CreateTemp(directory, ".openshell-write-check-") + if err != nil { + return fmt.Errorf("persistence directory %s is not writable: %w", directory, err) + } + name := file.Name() + if err := file.Close(); err != nil { + _ = os.Remove(name) + return fmt.Errorf("close persistence probe in %s: %w", directory, err) + } + if err := os.Remove(name); err != nil { + return fmt.Errorf("remove persistence probe in %s: %w", directory, err) + } + return nil +} diff --git a/projects/openshell-exporter/cmd/openshell-event-exporter/components.go b/projects/openshell-exporter/cmd/openshell-event-exporter/components.go new file mode 100644 index 00000000..410833db --- /dev/null +++ b/projects/openshell-exporter/cmd/openshell-event-exporter/components.go @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + cloudeventsexporter "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/exporter/cloudeventsexporter" + storagehealthextension "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/extension/storagehealthextension" + evidencecontractprocessor "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/processor/evidencecontractprocessor" + openshellprocessor "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/processor/openshellprocessor" + relayprocessor "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/processor/relayprocessor" + watchsandboxreceiver "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/receiver/watchsandboxreceiver" + fileexporter "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/fileexporter" + loadbalancingexporter "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter" + bearertokenauthextension "github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension" + healthcheckextension "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension" + filestorage "github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage" + attributesprocessor "github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor" + resourceprocessor "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourceprocessor" + filelogreceiver "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/filelogreceiver" + k8sobjectsreceiver "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sobjectsreceiver" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/connector" + "go.opentelemetry.io/collector/exporter" + otlpexporter "go.opentelemetry.io/collector/exporter/otlpexporter" + otlphttpexporter "go.opentelemetry.io/collector/exporter/otlphttpexporter" + "go.opentelemetry.io/collector/extension" + "go.opentelemetry.io/collector/otelcol" + "go.opentelemetry.io/collector/processor" + memorylimiterprocessor "go.opentelemetry.io/collector/processor/memorylimiterprocessor" + "go.opentelemetry.io/collector/receiver" + otlpreceiver "go.opentelemetry.io/collector/receiver/otlpreceiver" + otelconftelemetry "go.opentelemetry.io/collector/service/telemetry/otelconftelemetry" +) + +type aliasProvider interface{ DeprecatedAlias() component.Type } + +func makeModulesMap[T component.Factory](factories map[component.Type]T, modules map[component.Type]string) map[component.Type]string { + for compType, factory := range factories { + if ap, ok := any(factory).(aliasProvider); ok { + alias := ap.DeprecatedAlias() + if alias.String() != "" { + modules[alias] = modules[compType] + } + } + } + return modules +} + +func components() (otelcol.Factories, error) { + var err error + factories := otelcol.Factories{ + Telemetry: otelconftelemetry.NewFactory(), + } + + factories.Extensions, err = otelcol.MakeFactoryMap[extension.Factory]( + bearertokenauthextension.NewFactory(), + healthcheckextension.NewFactory(), + filestorage.NewFactory(), + storagehealthextension.NewFactory(), + ) + if err != nil { + return otelcol.Factories{}, err + } + factories.ExtensionModules = makeModulesMap(factories.Extensions, map[component.Type]string{ + bearertokenauthextension.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension v0.160.0", + healthcheckextension.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.160.0", + filestorage.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage v0.160.0", + storagehealthextension.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + }) + + factories.Receivers, err = otelcol.MakeFactoryMap[receiver.Factory]( + otlpreceiver.NewFactory(), + filelogreceiver.NewFactory(), + k8sobjectsreceiver.NewFactory(), + watchsandboxreceiver.NewFactory(), + ) + if err != nil { + return otelcol.Factories{}, err + } + factories.ReceiverModules = makeModulesMap(factories.Receivers, map[component.Type]string{ + otlpreceiver.NewFactory().Type(): "go.opentelemetry.io/collector/receiver/otlpreceiver v0.160.0", + filelogreceiver.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/filelogreceiver v0.160.0", + k8sobjectsreceiver.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sobjectsreceiver v0.160.0", + watchsandboxreceiver.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + }) + + factories.Exporters, err = otelcol.MakeFactoryMap[exporter.Factory]( + otlpexporter.NewFactory(), + otlphttpexporter.NewFactory(), + fileexporter.NewFactory(), + loadbalancingexporter.NewFactory(), + cloudeventsexporter.NewFactory(), + ) + if err != nil { + return otelcol.Factories{}, err + } + factories.ExporterModules = makeModulesMap(factories.Exporters, map[component.Type]string{ + otlpexporter.NewFactory().Type(): "go.opentelemetry.io/collector/exporter/otlpexporter v0.160.0", + otlphttpexporter.NewFactory().Type(): "go.opentelemetry.io/collector/exporter/otlphttpexporter v0.160.0", + fileexporter.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/fileexporter v0.160.0", + loadbalancingexporter.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter v0.160.0", + cloudeventsexporter.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + }) + + factories.Processors, err = otelcol.MakeFactoryMap[processor.Factory]( + attributesprocessor.NewFactory(), + memorylimiterprocessor.NewFactory(), + resourceprocessor.NewFactory(), + evidencecontractprocessor.NewFactory(), + openshellprocessor.NewFactory(), + relayprocessor.NewFactory(), + ) + if err != nil { + return otelcol.Factories{}, err + } + factories.ProcessorModules = makeModulesMap(factories.Processors, map[component.Type]string{ + attributesprocessor.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.160.0", + memorylimiterprocessor.NewFactory().Type(): "go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.160.0", + resourceprocessor.NewFactory().Type(): "github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourceprocessor v0.160.0", + evidencecontractprocessor.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + openshellprocessor.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + relayprocessor.NewFactory().Type(): "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter v0.0.5-rc.1", + }) + + factories.Connectors, err = otelcol.MakeFactoryMap[connector.Factory]() + if err != nil { + return otelcol.Factories{}, err + } + factories.ConnectorModules = makeModulesMap(factories.Connectors, map[component.Type]string{}) + + return factories, nil +} diff --git a/projects/openshell-exporter/cmd/openshell-event-exporter/main.go b/projects/openshell-exporter/cmd/openshell-event-exporter/main.go new file mode 100644 index 00000000..0f7c54a1 --- /dev/null +++ b/projects/openshell-exporter/cmd/openshell-event-exporter/main.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +// Program openshell-event-exporter is an OpenTelemetry Collector binary. +package main + +import ( + "os" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + envprovider "go.opentelemetry.io/collector/confmap/provider/envprovider" + fileprovider "go.opentelemetry.io/collector/confmap/provider/fileprovider" + yamlprovider "go.opentelemetry.io/collector/confmap/provider/yamlprovider" + "go.opentelemetry.io/collector/otelcol" +) + +func main() { + info := component.BuildInfo{ + Command: "openshell-event-exporter", + Description: "OpenShell Event Exporter", + Version: "0.0.5-rc.1", + } + + set := otelcol.CollectorSettings{ + BuildInfo: info, + Factories: components, + ConfigProviderSettings: otelcol.ConfigProviderSettings{ + ResolverSettings: confmap.ResolverSettings{ + ProviderFactories: []confmap.ProviderFactory{ + envprovider.NewFactory(), + fileprovider.NewFactory(), + yamlprovider.NewFactory(), + }, + }, + }, + ProviderModules: map[string]string{ + envprovider.NewFactory().Create(confmap.ProviderSettings{}).Scheme(): "go.opentelemetry.io/collector/confmap/provider/envprovider v1.66.0", + fileprovider.NewFactory().Create(confmap.ProviderSettings{}).Scheme(): "go.opentelemetry.io/collector/confmap/provider/fileprovider v1.66.0", + yamlprovider.NewFactory().Create(confmap.ProviderSettings{}).Scheme(): "go.opentelemetry.io/collector/confmap/provider/yamlprovider v1.66.0", + }, + ConverterModules: []string{}, + } + + if err := run(set); err != nil { + // The error message is logged by cobra, so we intentionally + // avoid logging it again here to prevent duplicate output. + os.Exit(1) + } +} + +func runInteractive(params otelcol.CollectorSettings) error { + cmd := otelcol.NewCommand(params) + if err := cmd.Execute(); err != nil { + return err + } + + return nil +} diff --git a/projects/openshell-exporter/cmd/openshell-event-exporter/main_others.go b/projects/openshell-exporter/cmd/openshell-event-exporter/main_others.go new file mode 100644 index 00000000..b9785e3c --- /dev/null +++ b/projects/openshell-exporter/cmd/openshell-event-exporter/main_others.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package main + +import "go.opentelemetry.io/collector/otelcol" + +func run(params otelcol.CollectorSettings) error { + return runInteractive(params) +} diff --git a/projects/openshell-exporter/cmd/openshell-event-exporter/main_windows.go b/projects/openshell-exporter/cmd/openshell-event-exporter/main_windows.go new file mode 100644 index 00000000..fbfd0422 --- /dev/null +++ b/projects/openshell-exporter/cmd/openshell-event-exporter/main_windows.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package main + +import ( + "errors" + "fmt" + "go.opentelemetry.io/collector/otelcol" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" +) + +func run(params otelcol.CollectorSettings) error { + // No need to supply service name when startup is invoked through + // the Service Control Manager directly. + if err := svc.Run("", otelcol.NewSvcHandler(params)); err != nil { + if errors.Is(err, windows.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) { + // Per https://learn.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-startservicectrldispatchera#return-value + // this means that the process is not running as a service, so run interactively. + return runInteractive(params) + } + + return fmt.Errorf("failed to start collector server: %w", err) + } + + return nil +} diff --git a/projects/openshell-exporter/config.yaml b/projects/openshell-exporter/config.yaml new file mode 100644 index 00000000..3580a32c --- /dev/null +++ b/projects/openshell-exporter/config.yaml @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-License-Identifier: Apache-2.0 +extensions: + file_storage/checkpoints: + directory: ./state/checkpoints + create_directory: true + storagehealth: + paths: + checkpoints: ./state/checkpoints + recovery: ./output/events.json + health_check: + endpoint: ${env:EXPORTER_HEALTH_ENDPOINT:-127.0.0.1:13133} + +receivers: + file_log/ocsf: + include: [./input/*.jsonl, ./input/sandboxes/*/openshell-ocsf.*.log] + start_at: beginning + storage: file_storage/checkpoints + retry_on_failure: + enabled: true + max_elapsed_time: 0s + include_file_path: true + include_file_path_resolved: true + include_file_record_offset: true + include_file_record_number: true + on_truncate: read_whole_file + max_log_size: 8MiB + max_log_size_behavior: split + attributes: + openshell.acquisition.kind: ocsf.file + openshell.acquisition.source_instance: sandbox-ocsf-jsonl + operators: + - type: json_parser + parse_from: body + parse_to: body + on_error: send + +processors: + memory_limiter: + check_interval: 1s + limit_mib: 256 + spike_limit_mib: 64 + openshell: + source_profiles: [ocsf.file] + gateway_id: local-gateway + workspace: default + source_instance: gateway-ocsf-jsonl + validation: + mode: mark + +exporters: + file/recovery: + path: ./output/events.json + format: json + append: true + +service: + telemetry: + logs: + encoding: json + metrics: + level: none + extensions: [file_storage/checkpoints, storagehealth, health_check] + pipelines: + logs: + receivers: [file_log/ocsf] + processors: [memory_limiter, openshell] + exporters: [file/recovery] diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/acknowledgement_test.go b/projects/openshell-exporter/exporter/cloudeventsexporter/acknowledgement_test.go new file mode 100644 index 00000000..1488aca6 --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/acknowledgement_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "context" + "errors" + "io" + "net/http" + "testing" + + "go.opentelemetry.io/collector/consumer/consumererror" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestAcknowledgementFailuresAreRetryableAndNotCountedDelivered(t *testing.T) { + tests := []struct { + name string + body io.ReadCloser + }{ + {name: "read failure", body: &faultyAcknowledgement{readErr: errors.New("truncated acknowledgement")}}, + {name: "close failure", body: &faultyAcknowledgement{closeErr: errors.New("close acknowledgement")}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = provider.Shutdown(context.Background()) }) + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusAccepted, + Body: test.body, + Header: make(http.Header), + }, nil + })} + implementation := testExporter(client) + implementation.metrics = newExporterMetrics(provider) + err := implementation.pushLogs(context.Background(), oneLog(t, "evidence")) + if err == nil || consumererror.IsPermanent(err) { + t.Fatalf("acknowledgement error=%v, want retryable", err) + } + + var resource metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resource); err != nil { + t.Fatal(err) + } + retryable := int64(0) + delivered := int64(0) + for _, scope := range resource.ScopeMetrics { + for _, current := range scope.Metrics { + sum, ok := current.Data.(metricdata.Sum[int64]) + if !ok { + continue + } + for _, point := range sum.DataPoints { + switch current.Name { + case "openshell.exporter.delivery.retryable_failures": + retryable += point.Value + case "openshell.exporter.delivery.events": + delivered += point.Value + } + } + } + } + if retryable != 1 { + t.Fatalf("retryable failure metric=%d, want 1", retryable) + } + if delivered != 0 { + t.Fatalf("delivered event metric=%d after uncertain acknowledgement, want 0", delivered) + } + }) + } +} + +func TestNetworkFailureIsRetryable(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("destination unavailable") + })} + err := testExporter(client).pushLogs(context.Background(), oneLog(t, "evidence")) + if err == nil || consumererror.IsPermanent(err) { + t.Fatalf("network error=%v, want retryable", err) + } +} + +type faultyAcknowledgement struct { + readErr error + closeErr error +} + +func (body *faultyAcknowledgement) Read([]byte) (int, error) { + if body.readErr != nil { + return 0, body.readErr + } + return 0, io.EOF +} + +func (body *faultyAcknowledgement) Close() error { + return body.closeErr +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/config.go b/projects/openshell-exporter/exporter/cloudeventsexporter/config.go new file mode 100644 index 00000000..4b9ea52f --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/config.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "errors" + "fmt" + "net/url" + + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configoptional" + "go.opentelemetry.io/collector/config/configretry" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/exporter/exporterhelper" +) + +// ClientConfig exposes HTTP fields for strict decoding without inheriting the +// upstream Unmarshal method, which ignores unknown sibling settings. +type ClientConfig confighttp.ClientConfig + +type Config struct { + ClientConfig `mapstructure:",squash"` + RetryConfig configretry.BackOffConfig `mapstructure:"retry_on_failure"` + QueueConfig configoptional.Optional[exporterhelper.QueueBatchConfig] `mapstructure:"sending_queue"` + AllowInsecureHTTP bool `mapstructure:"allow_insecure_http"` + MaxEvents int `mapstructure:"max_events"` + MaxEventBytes int `mapstructure:"max_event_bytes"` + MaxRequestBytes int `mapstructure:"max_request_bytes"` + DefaultSource string `mapstructure:"default_source"` +} + +// Unmarshal decodes the complete configuration strictly before letting the HTTP +// client normalize its deprecated and current keepalive settings. +func (cfg *Config) Unmarshal(conf *confmap.Conf) error { + if err := conf.Unmarshal(cfg); err != nil { + return err + } + return (*confighttp.ClientConfig)(&cfg.ClientConfig).Unmarshal(conf) +} + +func (cfg *Config) Validate() error { + if err := (*confighttp.ClientConfig)(&cfg.ClientConfig).Validate(); err != nil { + return err + } + if err := cfg.TLS.Validate(); err != nil { + return fmt.Errorf("tls: %w", err) + } + if err := cfg.Headers.Validate(); err != nil { + return fmt.Errorf("headers: %w", err) + } + parsed, err := url.Parse(cfg.Endpoint) + if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.Hostname() == "" { + return errors.New("endpoint must be an absolute HTTP(S) URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return errors.New("endpoint scheme must be http or https") + } + if parsed.User != nil { + return errors.New("endpoint must not contain user information") + } + if parsed.RawQuery != "" { + return errors.New("endpoint must not contain a query string") + } + if parsed.Fragment != "" { + return errors.New("endpoint must not contain a fragment") + } + if cfg.TLS.Insecure || cfg.TLS.InsecureSkipVerify { + return errors.New("TLS verification cannot be disabled") + } + if parsed.Scheme == "http" && !cfg.AllowInsecureHTTP { + return errors.New("endpoint must use HTTPS unless allow_insecure_http is true") + } + if parsed.Scheme == "http" && (cfg.Auth.HasValue() || len(cfg.Headers) > 0 || + len(cfg.Middlewares) > 0 || cfg.Cookies.HasValue()) { + return errors.New("HTTP endpoints cannot use authentication, headers, middleware, or cookies") + } + if cfg.DefaultSource == "" { + return errors.New("default_source must not be empty") + } + if cfg.MaxEvents <= 0 || cfg.MaxEvents > 500 { + return errors.New("max_events must be between 1 and 500") + } + if cfg.MaxEventBytes <= 0 || cfg.MaxEventBytes > 1024*1024 { + return errors.New("max_event_bytes must be between 1 and 1048576") + } + if cfg.MaxRequestBytes <= 2 || cfg.MaxRequestBytes > 4*1024*1024 { + return errors.New("max_request_bytes must be between 3 and 4194304") + } + if cfg.MaxRequestBytes < cfg.MaxEventBytes+2 { + return errors.New("max_request_bytes must fit at least one serialized event") + } + return nil +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/config_test.go b/projects/openshell-exporter/exporter/cloudeventsexporter/config_test.go new file mode 100644 index 00000000..308e0245 --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/config_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "net/url" + "strings" + "testing" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/configauth" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configmiddleware" + "go.opentelemetry.io/collector/config/configopaque" + "go.opentelemetry.io/collector/config/configoptional" + "go.opentelemetry.io/collector/confmap" +) + +func TestConfigUnmarshalPreservesExporterAndHTTPSettings(t *testing.T) { + cfg := createDefaultConfig().(*Config) + values := map[string]any{ + "endpoint": "http://127.0.0.1:8080/v1/events", + "allow_insecure_http": true, + "default_source": "openshell://configured-source", + "max_events": 7, + "keepalive": map[string]any{"idle_conn_timeout": "12s"}, + "sending_queue": map[string]any{"enabled": false}, + } + if err := confmap.NewFromStringMap(values).Unmarshal(cfg); err != nil { + t.Fatal(err) + } + if !cfg.AllowInsecureHTTP || cfg.MaxEvents != 7 || cfg.DefaultSource != "openshell://configured-source" || cfg.QueueConfig.HasValue() { + t.Fatalf("exporter settings were lost: %#v", cfg) + } + // confighttp normalizes the new section into the effective legacy fields. + if cfg.IdleConnTimeout != 12*time.Second { //nolint:staticcheck // Verify the upstream decoder's documented normalization. + t.Fatal("HTTP keepalive settings were lost") + } + if err := cfg.Validate(); err != nil { + t.Fatalf("decoded config rejected: %v", err) + } + values["unknown_exporter_setting"] = true + if err := confmap.NewFromStringMap(values).Unmarshal(createDefaultConfig()); err == nil { + t.Fatal("unknown settings must be rejected") + } +} + +func TestConfigAcceptsVerifiedHTTPSAndExplicitCredentialFreeHTTP(t *testing.T) { + httpsConfig := validCloudEventsConfig() + httpsConfig.Headers.Set("Authorization", configopaque.String("Bearer destination-token")) + httpsConfig.Auth = configoptional.Some(configauth.Config{ + AuthenticatorID: component.NewID(component.MustNewType("bearertokenauth")), + }) + if err := httpsConfig.Validate(); err != nil { + t.Fatalf("verified HTTPS config rejected: %v", err) + } + + httpConfig := validCloudEventsConfig() + httpConfig.Endpoint = "http://127.0.0.1:8080/v1/events" + httpConfig.AllowInsecureHTTP = true + if err := httpConfig.Validate(); err != nil { + t.Fatalf("explicit credential-free HTTP fixture rejected: %v", err) + } +} + +func TestConfigRejectsAmbiguousOrUnverifiedDestinations(t *testing.T) { + tests := []struct { + name string + want string + configure func(*Config) + }{ + {name: "relative endpoint", want: "absolute HTTP(S)", configure: func(cfg *Config) { cfg.Endpoint = "/v1/events" }}, + {name: "missing hostname", want: "absolute HTTP(S)", configure: func(cfg *Config) { cfg.Endpoint = "https://:443/v1/events" }}, + {name: "unsupported scheme", want: "scheme must be http or https", configure: func(cfg *Config) { cfg.Endpoint = "ftp://receiver.example/events"; cfg.AllowInsecureHTTP = true }}, + {name: "userinfo", want: "user information", configure: func(cfg *Config) { cfg.Endpoint = "https://user:secret@receiver.example/v1/events" }}, + {name: "query", want: "query string", configure: func(cfg *Config) { cfg.Endpoint = "https://receiver.example/v1/events?token=secret" }}, + {name: "fragment", want: "fragment", configure: func(cfg *Config) { cfg.Endpoint = "https://receiver.example/v1/events#ignored" }}, + {name: "implicit HTTP", want: "must use HTTPS", configure: func(cfg *Config) { cfg.Endpoint = "http://127.0.0.1:8080/v1/events" }}, + {name: "TLS insecure", want: "cannot be disabled", configure: func(cfg *Config) { cfg.TLS.Insecure = true }}, + {name: "skip verification", want: "cannot be disabled", configure: func(cfg *Config) { cfg.TLS.InsecureSkipVerify = true }}, + {name: "invalid TLS version", want: "invalid TLS min_version", configure: func(cfg *Config) { cfg.TLS.MinVersion = "SSL3.0" }}, + {name: "HTTP headers", want: "cannot use authentication", configure: func(cfg *Config) { + cfg.Endpoint = "http://receiver.test/v1/events" + cfg.AllowInsecureHTTP = true + cfg.Headers.Set("Authorization", configopaque.String("Bearer secret")) + }}, + {name: "HTTP auth extension", want: "cannot use authentication", configure: func(cfg *Config) { + cfg.Endpoint = "http://receiver.test/v1/events" + cfg.AllowInsecureHTTP = true + cfg.Auth = configoptional.Some(configauth.Config{AuthenticatorID: component.NewID(component.MustNewType("bearertokenauth"))}) + }}, + {name: "HTTP middleware", want: "cannot use authentication", configure: func(cfg *Config) { + cfg.Endpoint = "http://receiver.test/v1/events" + cfg.AllowInsecureHTTP = true + cfg.Middlewares = []configmiddleware.Config{{ID: component.NewID(component.MustNewType("headersetter"))}} + }}, + {name: "HTTP cookies", want: "cannot use authentication", configure: func(cfg *Config) { + cfg.Endpoint = "http://receiver.test/v1/events" + cfg.AllowInsecureHTTP = true + cfg.Cookies = configoptional.Some(confighttp.CookiesConfig{}) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validCloudEventsConfig() + test.configure(cfg) + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error=%v, want substring %q", err, test.want) + } + }) + } +} + +func TestConfigRejectsInvalidContractLimits(t *testing.T) { + tests := []struct { + name string + want string + configure func(*Config) + }{ + {name: "empty source", want: "default_source", configure: func(cfg *Config) { cfg.DefaultSource = "" }}, + {name: "zero events", want: "max_events", configure: func(cfg *Config) { cfg.MaxEvents = 0 }}, + {name: "too many events", want: "max_events", configure: func(cfg *Config) { cfg.MaxEvents = 501 }}, + {name: "zero event bytes", want: "max_event_bytes", configure: func(cfg *Config) { cfg.MaxEventBytes = 0 }}, + {name: "large event", want: "max_event_bytes", configure: func(cfg *Config) { cfg.MaxEventBytes = 1024*1024 + 1 }}, + {name: "tiny request", want: "max_request_bytes", configure: func(cfg *Config) { cfg.MaxRequestBytes = 2 }}, + {name: "large request", want: "max_request_bytes", configure: func(cfg *Config) { cfg.MaxRequestBytes = 4*1024*1024 + 1 }}, + {name: "event cannot fit", want: "fit at least one", configure: func(cfg *Config) { cfg.MaxEventBytes = 100; cfg.MaxRequestBytes = 101 }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validCloudEventsConfig() + test.configure(cfg) + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error=%v, want substring %q", err, test.want) + } + }) + } +} + +func validCloudEventsConfig() *Config { + cfg := createDefaultConfig().(*Config) + cfg.Endpoint = "https://receiver.example/v1/events" + return cfg +} + +func FuzzConfigTransportInvariants(f *testing.F) { + f.Add("https://receiver.example/v1/events", false, true, false) + f.Add("http://127.0.0.1:8080/v1/events", true, false, false) + f.Add("https://user:secret@receiver.example/v1/events?token=x#ignored", true, true, true) + f.Fuzz(func(t *testing.T, endpoint string, allowHTTP, withHeaders, skipVerify bool) { + cfg := validCloudEventsConfig() + cfg.Endpoint = endpoint + cfg.AllowInsecureHTTP = allowHTTP + cfg.TLS.InsecureSkipVerify = skipVerify + if withHeaders { + cfg.Headers.Set("Authorization", configopaque.String("Bearer fuzz-secret")) + } + if err := cfg.Validate(); err != nil { + return + } + parsed, err := url.Parse(cfg.Endpoint) + if err != nil { + t.Fatalf("validated endpoint cannot be parsed: %v", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + t.Fatalf("validated unsupported scheme %q", parsed.Scheme) + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + t.Fatalf("validated ambiguous endpoint: %q", cfg.Endpoint) + } + if cfg.TLS.Insecure || cfg.TLS.InsecureSkipVerify { + t.Fatal("validated configuration disables TLS verification") + } + if parsed.Scheme == "http" && (len(cfg.Headers) > 0 || cfg.Auth.HasValue() || + len(cfg.Middlewares) > 0 || cfg.Cookies.HasValue()) { + t.Fatal("validated plaintext configuration can carry credentials or state") + } + }) +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/exporter.go b/projects/openshell-exporter/exporter/cloudeventsexporter/exporter.go new file mode 100644 index 00000000..f656401c --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/exporter.go @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/exporter" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.uber.org/zap" +) + +const maxResponseBody = 64 * 1024 + +type cloudEventsExporter struct { + config *Config + settings exporter.Settings + client *http.Client + metrics *exporterMetrics +} + +func (e *cloudEventsExporter) start(ctx context.Context, host component.Host) error { + if err := e.config.Validate(); err != nil { + return err + } + client, err := (*confighttp.ClientConfig)(&e.config.ClientConfig).ToClient( + ctx, + host.GetExtensions(), + e.settings.TelemetrySettings, + ) + if err != nil { + return err + } + client.CheckRedirect = refuseRedirect + e.client = client + return nil +} + +func (e *cloudEventsExporter) shutdown(context.Context) error { + if e.client != nil { + e.client.CloseIdleConnections() + } + return nil +} + +func refuseRedirect(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse +} + +func (e *cloudEventsExporter) pushLogs(ctx context.Context, logs plog.Logs) error { + if e.client == nil { + return errors.New("CloudEvents HTTP client is not started") + } + encodedEvents := make([][]byte, 0, logs.LogRecordCount()) + oversizedCount := 0 + for resourceIndex := 0; resourceIndex < logs.ResourceLogs().Len(); resourceIndex++ { + resourceLogs := logs.ResourceLogs().At(resourceIndex) + for scopeIndex := 0; scopeIndex < resourceLogs.ScopeLogs().Len(); scopeIndex++ { + records := resourceLogs.ScopeLogs().At(scopeIndex).LogRecords() + for recordIndex := 0; recordIndex < records.Len(); recordIndex++ { + event := e.cloudEvent( + resourceLogs.Resource().Attributes(), + records.At(recordIndex), + ) + encoded, err := json.Marshal(event) + if err != nil { + return consumererror.NewPermanent( + fmt.Errorf("marshal CloudEvent: %w", err), + ) + } + if len(encoded) > e.config.MaxEventBytes || + len(encoded)+2 > e.config.MaxRequestBytes { + oversizedCount++ + continue + } + encodedEvents = append(encodedEvents, encoded) + } + } + } + + // Permanent rejection applies to one HTTP batch, not unattempted records. + // Transient failures still retry the queued input, including accepted batches. + var permanentErrors []error + send := func(batch [][]byte) error { + err := e.sendBatch(ctx, batch) + if consumererror.IsPermanent(err) { + permanentErrors = append(permanentErrors, err) + return nil + } + return err + } + batch := make([][]byte, 0, e.config.MaxEvents) + batchBytes := 2 + for _, event := range encodedEvents { + nextBytes := batchBytes + len(event) + if len(batch) > 0 { + nextBytes++ + } + if len(batch) >= e.config.MaxEvents || + (len(batch) > 0 && nextBytes > e.config.MaxRequestBytes) { + if err := send(batch); err != nil { + return err + } + batch = batch[:0] + batchBytes = 2 + nextBytes = batchBytes + len(event) + } + batch = append(batch, event) + batchBytes = nextBytes + } + if len(batch) > 0 { + if err := send(batch); err != nil { + return err + } + } + + if oversizedCount > 0 { + e.metrics.recordOversized(ctx, oversizedCount) + logger := e.settings.Logger + if logger == nil { + logger = zap.NewNop() + } + logger.Error( + "CloudEvent permanently rejected from webhook delivery because it exceeds limits", + zap.Int("max_event_bytes", e.config.MaxEventBytes), + zap.Int("max_request_bytes", e.config.MaxRequestBytes), + ) + permanentErrors = append(permanentErrors, fmt.Errorf( + "%d CloudEvents exceed webhook size limits", + oversizedCount, + )) + } + if len(permanentErrors) > 0 { + return consumererror.NewPermanent(errors.Join(permanentErrors...)) + } + return nil +} + +func (e *cloudEventsExporter) sendBatch( + ctx context.Context, + events [][]byte, +) error { + payload := bytes.NewBuffer(make([]byte, 0, encodedBatchSize(events))) + payload.WriteByte('[') + for index, event := range events { + if index > 0 { + payload.WriteByte(',') + } + payload.Write(event) + } + payload.WriteByte(']') + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + e.config.Endpoint, + payload, + ) + if err != nil { + return consumererror.NewPermanent( + fmt.Errorf("create CloudEvents request: %w", err), + ) + } + request.Header.Set("Content-Type", "application/cloudevents-batch+json") + request.Header.Set("User-Agent", "openshell-event-exporter") + + started := time.Now() + response, err := e.client.Do(request) + if err != nil { + e.metrics.recordNetworkFailure(ctx, time.Since(started)) + return fmt.Errorf("send CloudEvents batch: %w", err) + } + _, readErr := io.Copy( + io.Discard, + io.LimitReader(response.Body, maxResponseBody), + ) + closeErr := response.Body.Close() + if readErr != nil { + e.metrics.recordNetworkFailure(ctx, time.Since(started)) + return fmt.Errorf("read CloudEvents acknowledgement: %w", readErr) + } + if closeErr != nil { + e.metrics.recordNetworkFailure(ctx, time.Since(started)) + return fmt.Errorf("close CloudEvents acknowledgement: %w", closeErr) + } + e.metrics.recordResponse(ctx, response.StatusCode, len(events), time.Since(started)) + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + statusError := fmt.Errorf( + "CloudEvents receiver returned HTTP %d", + response.StatusCode, + ) + if retryableStatus(response.StatusCode) { + return statusError + } + return consumererror.NewPermanent(statusError) +} + +func retryableStatus(status int) bool { + return status == http.StatusRequestTimeout || + status == http.StatusTooEarly || + status == http.StatusTooManyRequests || + status >= 500 +} + +func encodedBatchSize(events [][]byte) int { + size := 2 + for index, event := range events { + size += len(event) + if index > 0 { + size++ + } + } + return size +} + +func (e *cloudEventsExporter) cloudEvent( + resource pcommon.Map, + record plog.LogRecord, +) map[string]any { + attributes := record.Attributes() + eventID := stringAttribute(attributes, "cloudevents.id", "") + if eventID == "" { + encoded, _ := json.Marshal(record.Body().AsRaw()) + sum := sha256.Sum256(encoded) + eventID = "sha256:" + hex.EncodeToString(sum[:]) + } + event := map[string]any{ + "specversion": "1.0", + "id": eventID, + "source": stringAttribute( + attributes, + "cloudevents.source", + e.config.DefaultSource, + ), + "type": stringAttribute( + attributes, + "cloudevents.type", + "com.nvidia.openshell.stream.warning.v1", + ), + "datacontenttype": "application/json", + "data": record.Body().AsRaw(), + } + if schema := stringAttribute( + attributes, + "cloudevents.dataschema", + "", + ); schema != "" { + event["dataschema"] = schema + } + if subject := stringAttribute( + attributes, + "cloudevents.subject", + "", + ); subject != "" { + event["subject"] = subject + } + if timestamp := record.Timestamp(); timestamp != 0 { + event["time"] = timestamp.AsTime().UTC().Format(time.RFC3339Nano) + } + if correlation := stringAttribute( + attributes, + "openshell.correlation.id", + "", + ); correlation != "" { + event["openshellcorrelation"] = correlation + } + if serviceName := stringAttribute(resource, "service.name", ""); serviceName != "" { + event["service"] = serviceName + } + if valid, ok := attributes.Get("openshell.ocsf.valid"); ok && + valid.Type() == pcommon.ValueTypeBool { + event["openshellocsfvalid"] = valid.Bool() + } + return event +} + +func stringAttribute(attributes pcommon.Map, key string, fallback string) string { + value, ok := attributes.Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr { + return fallback + } + return value.Str() +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/exporter_test.go b/projects/openshell-exporter/exporter/cloudeventsexporter/exporter_test.go new file mode 100644 index 00000000..a23f5e00 --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/exporter_test.go @@ -0,0 +1,397 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/exporter" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.uber.org/zap" +) + +func TestCloudEventsBatchContract(t *testing.T) { + var received []map[string]any + client := &http.Client{Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + if got := request.Header.Get("Content-Type"); got != + "application/cloudevents-batch+json" { + t.Errorf("content type=%q", got) + } + if err := json.NewDecoder(request.Body).Decode(&received); err != nil { + t.Error(err) + } + return response(http.StatusAccepted), nil + }, + )} + logs := oneLog(t, map[string]any{"schema_version": "1.0"}) + record := onlyLogRecord(logs) + record.Attributes().PutStr("cloudevents.id", "sha256:event-1") + record.Attributes().PutStr("cloudevents.source", "openshell://test") + record.Attributes().PutStr( + "cloudevents.type", + "com.nvidia.openshell.ocsf.4001.v1", + ) + record.Attributes().PutStr( + "cloudevents.dataschema", + "urn:openshell:event-envelope:1", + ) + record.SetObservedTimestamp(pcommon.NewTimestampFromTime(time.Now())) + + implementation := testExporter(client) + if err := implementation.pushLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if len(received) != 1 { + t.Fatalf("received %d events, want 1", len(received)) + } + if received[0]["id"] != "sha256:event-1" { + t.Fatalf("id=%#v", received[0]["id"]) + } + if received[0]["dataschema"] != "urn:openshell:event-envelope:1" { + t.Fatalf("dataschema=%#v", received[0]["dataschema"]) + } + if _, exists := received[0]["time"]; exists { + t.Fatal("observation time was incorrectly substituted for source time") + } +} + +func TestSplitsByCountAndEncodedRequestBytes(t *testing.T) { + var mu sync.Mutex + requestSizes := make([]int, 0) + eventCounts := make([]int, 0) + client := &http.Client{Transport: roundTripFunc( + func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + t.Error(err) + } + var events []map[string]any + if err := json.Unmarshal(body, &events); err != nil { + t.Error(err) + } + mu.Lock() + requestSizes = append(requestSizes, len(body)) + eventCounts = append(eventCounts, len(events)) + mu.Unlock() + return response(http.StatusNoContent), nil + }, + )} + implementation := testExporter(client) + implementation.config.MaxEvents = 2 + implementation.config.MaxRequestBytes = 750 + logs := plog.NewLogs() + records := logs.ResourceLogs(). + AppendEmpty(). + ScopeLogs(). + AppendEmpty(). + LogRecords() + for index := 0; index < 5; index++ { + record := records.AppendEmpty() + record.Body().SetStr(strings.Repeat("x", 250)) + record.Attributes().PutStr( + "cloudevents.id", + "sha256:"+strings.Repeat(string(rune('a'+index)), 64), + ) + } + if err := implementation.pushLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if len(requestSizes) < 3 { + t.Fatalf("requests=%d, want at least 3", len(requestSizes)) + } + for index, size := range requestSizes { + if size > implementation.config.MaxRequestBytes { + t.Fatalf("request %d size=%d", index, size) + } + if eventCounts[index] > implementation.config.MaxEvents { + t.Fatalf("request %d events=%d", index, eventCounts[index]) + } + } +} + +func TestHTTPDisposition(t *testing.T) { + tests := []struct { + status int + permanent bool + }{ + {status: http.StatusRequestTimeout}, + {status: http.StatusTooEarly}, + {status: http.StatusTooManyRequests}, + {status: http.StatusInternalServerError}, + {status: http.StatusMultipleChoices, permanent: true}, + {status: http.StatusBadRequest, permanent: true}, + {status: http.StatusUnauthorized, permanent: true}, + {status: http.StatusRequestEntityTooLarge, permanent: true}, + } + for _, test := range tests { + t.Run(http.StatusText(test.status), func(t *testing.T) { + client := &http.Client{Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return response(test.status), nil + }, + )} + err := testExporter(client).pushLogs( + context.Background(), + oneLog(t, "event"), + ) + if err == nil { + t.Fatal("expected error") + } + if consumererror.IsPermanent(err) != test.permanent { + t.Fatalf( + "permanent=%v, want %v: %v", + consumererror.IsPermanent(err), + test.permanent, + err, + ) + } + }) + } +} + +func TestHTTPDispositionExhaustive(t *testing.T) { + for statusCode := 300; statusCode <= 599; statusCode++ { + expectedRetryable := statusCode == http.StatusRequestTimeout || + statusCode == http.StatusTooEarly || + statusCode == http.StatusTooManyRequests || + statusCode >= 500 + if retryableStatus(statusCode) != expectedRetryable { + t.Fatalf("status %d retryable=%v, want %v", statusCode, retryableStatus(statusCode), expectedRetryable) + } + client := &http.Client{Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + return response(statusCode), nil + }, + )} + err := testExporter(client).pushLogs(context.Background(), oneLog(t, "event")) + if err == nil { + t.Fatalf("status %d returned no error", statusCode) + } + if consumererror.IsPermanent(err) == expectedRetryable { + t.Fatalf( + "status %d permanent=%v, want %v: %v", + statusCode, + consumererror.IsPermanent(err), + !expectedRetryable, + err, + ) + } + } +} + +func TestRedirectIsPermanentAndNotFollowed(t *testing.T) { + followed := false + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/redirected" { + followed = true + writer.WriteHeader(http.StatusAccepted) + return + } + http.Redirect(writer, request, "/redirected", http.StatusFound) + })) + defer server.Close() + + client := server.Client() + client.CheckRedirect = refuseRedirect + implementation := testExporter(client) + implementation.config.Endpoint = server.URL + err := implementation.pushLogs(context.Background(), oneLog(t, "event")) + if err == nil || !consumererror.IsPermanent(err) { + t.Fatalf("redirect error=%v, want permanent", err) + } + if followed { + t.Fatal("CloudEvents exporter followed a redirect") + } +} + +func TestRefuseRedirectUsesLastResponse(t *testing.T) { + if err := refuseRedirect(nil, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Fatalf("redirect policy error=%v", err) + } +} + +func TestOversizedSingleEventIsPermanentAndNotSent(t *testing.T) { + called := false + client := &http.Client{Transport: roundTripFunc( + func(*http.Request) (*http.Response, error) { + called = true + return response(http.StatusAccepted), nil + }, + )} + implementation := testExporter(client) + implementation.config.MaxEventBytes = 256 + err := implementation.pushLogs( + context.Background(), + oneLog(t, strings.Repeat("secret-redacted-recovery-evidence", 50)), + ) + if !consumererror.IsPermanent(err) { + t.Fatalf("error=%v, want permanent", err) + } + if called { + t.Fatal("oversized event was sent") + } +} + +func FuzzBatchLimitsAreNeverExceeded(f *testing.F) { + f.Add(uint8(5), uint16(250), uint8(2), uint16(750)) + f.Fuzz(func(t *testing.T, rawEvents uint8, rawPayload uint16, rawMaxEvents uint8, rawMaxRequest uint16) { + eventCount := int(rawEvents%32) + 1 + payloadBytes := int(rawPayload%1024) + 1 + maxEvents := int(rawMaxEvents%16) + 1 + maxRequestBytes := int(rawMaxRequest%14336) + 2048 + delivered := 0 + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + if len(body) > maxRequestBytes { + t.Fatalf("request size=%d, maximum=%d", len(body), maxRequestBytes) + } + var events []json.RawMessage + if err := json.Unmarshal(body, &events); err != nil { + t.Fatalf("invalid CloudEvents batch: %v", err) + } + if len(events) == 0 || len(events) > maxEvents { + t.Fatalf("batch events=%d, maximum=%d", len(events), maxEvents) + } + delivered += len(events) + return response(http.StatusNoContent), nil + })} + + implementation := testExporter(client) + implementation.config.MaxEvents = maxEvents + implementation.config.MaxEventBytes = 1024 * 1024 + implementation.config.MaxRequestBytes = maxRequestBytes + logs := plog.NewLogs() + records := logs.ResourceLogs().AppendEmpty().ScopeLogs().AppendEmpty().LogRecords() + for index := 0; index < eventCount; index++ { + record := records.AppendEmpty() + record.Body().SetStr(strings.Repeat("x", payloadBytes)) + record.Attributes().PutStr("cloudevents.id", fmt.Sprintf("sha256:%064x", index+1)) + } + if err := implementation.pushLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if delivered != eventCount { + t.Fatalf("delivered=%d, want=%d", delivered, eventCount) + } + }) +} + +func BenchmarkCloudEventsBatchDelivery(b *testing.B) { + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if _, err := io.Copy(io.Discard, request.Body); err != nil { + return nil, err + } + return response(http.StatusNoContent), nil + })} + implementation := testExporter(client) + logs := plog.NewLogs() + records := logs.ResourceLogs().AppendEmpty().ScopeLogs().AppendEmpty().LogRecords() + for index := 0; index < 100; index++ { + record := records.AppendEmpty() + record.Body().SetStr(strings.Repeat("x", 2048)) + record.Attributes().PutStr("cloudevents.id", fmt.Sprintf("sha256:%064x", index+1)) + } + b.ReportAllocs() + b.SetBytes(int64(100 * 2048)) + for b.Loop() { + if err := implementation.pushLogs(context.Background(), logs); err != nil { + b.Fatal(err) + } + } +} + +func testExporter(client *http.Client) *cloudEventsExporter { + config := createDefaultConfig().(*Config) + config.Endpoint = "http://receiver.example.test/v1/events" + settings := exporter.Settings{} + settings.Logger = zap.NewNop() + config.AllowInsecureHTTP = true + return &cloudEventsExporter{ + config: config, + settings: settings, + client: client, + metrics: newExporterMetrics(nil), + } +} + +func oneLog(t *testing.T, body any) plog.Logs { + t.Helper() + logs := plog.NewLogs() + record := onlyLogRecord(logs) + if err := record.Body().FromRaw(body); err != nil { + t.Fatal(err) + } + return logs +} + +func onlyLogRecord(logs plog.Logs) plog.LogRecord { + if logs.ResourceLogs().Len() == 0 { + logs.ResourceLogs(). + AppendEmpty(). + ScopeLogs(). + AppendEmpty(). + LogRecords(). + AppendEmpty() + } + return logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) +} + +func response(status int) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestPermanentSplitRejectionDoesNotSkipLaterBatches(t *testing.T) { + for _, laterStatus := range []int{http.StatusAccepted, http.StatusServiceUnavailable} { + t.Run(fmt.Sprint(laterStatus), func(t *testing.T) { + calls := 0 + implementation := testExporter(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return response(http.StatusBadRequest), nil + } + return response(laterStatus), nil + })}) + implementation.config.MaxEvents = 1 + logs := oneLog(t, map[string]any{"first": "rejected"}) + records := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords() + records.AppendEmpty().Body().SetStr("valid later record") + err := implementation.pushLogs(context.Background(), logs) + if calls != 2 { + t.Fatalf("attempted %d batches, want 2", calls) + } + if err == nil { + t.Fatal("rejection must be reported") + } + if consumererror.IsPermanent(err) != (laterStatus == http.StatusAccepted) { + t.Fatalf("wrong retry classification: %v", err) + } + }) + } +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/factory.go b/projects/openshell-exporter/exporter/cloudeventsexporter/factory.go new file mode 100644 index 00000000..f4199f88 --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/factory.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "context" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/confighttp" + "go.opentelemetry.io/collector/config/configoptional" + "go.opentelemetry.io/collector/config/configretry" + "go.opentelemetry.io/collector/exporter" + "go.opentelemetry.io/collector/exporter/exporterhelper" +) + +var componentType = component.MustNewType("cloudevents") + +func NewFactory() exporter.Factory { + return exporter.NewFactory( + componentType, + createDefaultConfig, + exporter.WithLogs(createLogsExporter, component.StabilityLevelAlpha), + ) +} + +func createDefaultConfig() component.Config { + clientConfig := confighttp.NewDefaultClientConfig() + clientConfig.Timeout = 15 * time.Second + queueConfig := exporterhelper.NewDefaultQueueConfig() + queueConfig.QueueSize = 10_000 + queueConfig.NumConsumers = 4 + return &Config{ + ClientConfig: ClientConfig(clientConfig), + RetryConfig: configretry.NewDefaultBackOffConfig(), + QueueConfig: configoptional.Some(queueConfig), + DefaultSource: "openshell://local", + AllowInsecureHTTP: false, + MaxEvents: 500, + MaxEventBytes: 1024 * 1024, + MaxRequestBytes: 4 * 1024 * 1024, + } +} + +func createLogsExporter( + ctx context.Context, + settings exporter.Settings, + cfg component.Config, +) (exporter.Logs, error) { + exporterConfig := cfg.(*Config) + if err := exporterConfig.Validate(); err != nil { + return nil, err + } + implementation := &cloudEventsExporter{ + config: exporterConfig, + settings: settings, + metrics: newExporterMetrics(settings.MeterProvider), + } + return exporterhelper.NewLogs( + ctx, + settings, + cfg, + implementation.pushLogs, + exporterhelper.WithStart(implementation.start), + exporterhelper.WithShutdown(implementation.shutdown), + exporterhelper.WithRetry(exporterConfig.RetryConfig), + exporterhelper.WithQueue(exporterConfig.QueueConfig), + ) +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/lifecycle_security_test.go b/projects/openshell-exporter/exporter/cloudeventsexporter/lifecycle_security_test.go new file mode 100644 index 00000000..b30b201d --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/lifecycle_security_test.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "context" + "encoding/pem" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/config/configopaque" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/exporter" + "go.uber.org/zap" +) + +type cloudEventsTestHost struct{} + +func (cloudEventsTestHost) GetExtensions() map[component.ID]component.Component { + return map[component.ID]component.Component{} +} + +func TestExporterLifecycleUsesVerifiedTLSAndBearerAuthentication(t *testing.T) { + const token = "destination-token-that-is-long-enough-for-a-real-secret" + var accepted atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.TLS == nil { + t.Error("request did not use TLS") + } + if request.Header.Get("Authorization") != "Bearer "+token { + writer.WriteHeader(http.StatusUnauthorized) + return + } + accepted.Add(1) + writer.WriteHeader(http.StatusAccepted) + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + defer server.Close() + + cfg := validCloudEventsConfig() + cfg.Endpoint = server.URL + "/v1/events" + cfg.TLS.CAFile = writeServerCA(t, server) + cfg.Headers.Set("Authorization", configopaque.String("Bearer "+token)) + implementation := lifecycleExporter(cfg) + if err := implementation.start(context.Background(), cloudEventsTestHost{}); err != nil { + t.Fatal(err) + } + if err := implementation.pushLogs(context.Background(), oneLog(t, "verified evidence")); err != nil { + t.Fatal(err) + } + if accepted.Load() != 1 { + t.Fatalf("accepted requests=%d, want 1", accepted.Load()) + } + if err := implementation.shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if err := implementation.shutdown(context.Background()); err != nil { + t.Fatalf("second shutdown failed: %v", err) + } +} + +func TestExporterRejectsUntrustedDestinationCertificate(t *testing.T) { + var received atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + received.Add(1) + writer.WriteHeader(http.StatusAccepted) + })) + server.Config.ErrorLog = log.New(io.Discard, "", 0) + server.StartTLS() + defer server.Close() + + cfg := validCloudEventsConfig() + cfg.Endpoint = server.URL + "/v1/events" + implementation := lifecycleExporter(cfg) + if err := implementation.start(context.Background(), cloudEventsTestHost{}); err != nil { + t.Fatal(err) + } + err := implementation.pushLogs(context.Background(), oneLog(t, "untrusted evidence")) + if err == nil || consumererror.IsPermanent(err) { + t.Fatalf("untrusted certificate error=%v, want retryable transport failure", err) + } + if received.Load() != 0 { + t.Fatal("application handler received evidence across an untrusted TLS connection") + } +} + +func TestExporterStartRevalidatesSecurityConfiguration(t *testing.T) { + cfg := validCloudEventsConfig() + cfg.Endpoint = "http://receiver.test/v1/events" + cfg.AllowInsecureHTTP = true + cfg.Headers.Set("Authorization", configopaque.String("Bearer must-not-cross-plaintext")) + implementation := lifecycleExporter(cfg) + err := implementation.start(context.Background(), cloudEventsTestHost{}) + if err == nil { + t.Fatal("start accepted destination credentials over plaintext HTTP") + } +} + +func TestFactoryValidatesConfiguration(t *testing.T) { + factory := NewFactory() + if factory.Type() != componentType { + t.Fatalf("factory type=%q, want %q", factory.Type(), componentType) + } + if _, ok := factory.CreateDefaultConfig().(*Config); !ok { + t.Fatal("factory returned an unexpected default config type") + } + + cfg := validCloudEventsConfig() + cfg.Endpoint = "ftp://receiver.example/v1/events" + _, err := createLogsExporter(context.Background(), exporter.Settings{ + ID: component.NewID(componentType), + TelemetrySettings: component.TelemetrySettings{Logger: zap.NewNop()}, + }, cfg) + if err == nil { + t.Fatal("factory accepted an unsupported destination scheme") + } +} + +func lifecycleExporter(cfg *Config) *cloudEventsExporter { + return &cloudEventsExporter{ + config: cfg, + settings: exporter.Settings{TelemetrySettings: component.TelemetrySettings{ + Logger: zap.NewNop(), + }}, + metrics: newExporterMetrics(nil), + } +} + +func writeServerCA(t *testing.T, server *httptest.Server) string { + t.Helper() + certificate := server.Certificate() + if certificate == nil { + t.Fatal("TLS server certificate is missing") + } + path := filepath.Join(t.TempDir(), "server-ca.pem") + encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}) + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/projects/openshell-exporter/exporter/cloudeventsexporter/metrics.go b/projects/openshell-exporter/exporter/cloudeventsexporter/metrics.go new file mode 100644 index 00000000..4c54e235 --- /dev/null +++ b/projects/openshell-exporter/exporter/cloudeventsexporter/metrics.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package cloudeventsexporter + +import ( + "context" + "strconv" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +const exporterMeterName = "github.com/NVIDIA-dev/OpenShell-exporter/cloudevents" + +type exporterMetrics struct { + oversized metric.Int64Counter + permanentResponses metric.Int64Counter + retryableResponses metric.Int64Counter + deliveredBatches metric.Int64Counter + deliveredEvents metric.Int64Counter + destinationLatency metric.Float64Histogram + lastSuccess metric.Int64Gauge +} + +func newExporterMetrics(provider metric.MeterProvider) *exporterMetrics { + if provider == nil { + provider = noop.NewMeterProvider() + } + meter := provider.Meter(exporterMeterName) + oversized, _ := meter.Int64Counter("openshell.exporter.events.oversized", metric.WithUnit("{event}")) + permanent, _ := meter.Int64Counter("openshell.exporter.delivery.permanent_rejections", metric.WithUnit("{response}")) + retryable, _ := meter.Int64Counter("openshell.exporter.delivery.retryable_failures", metric.WithUnit("{response}")) + batches, _ := meter.Int64Counter("openshell.exporter.delivery.batches", metric.WithUnit("{batch}")) + events, _ := meter.Int64Counter("openshell.exporter.delivery.events", metric.WithUnit("{event}")) + latency, _ := meter.Float64Histogram("openshell.exporter.destination.latency", metric.WithUnit("ms")) + lastSuccess, _ := meter.Int64Gauge("openshell.exporter.destination.last_success_unixtime", metric.WithUnit("s")) + return &exporterMetrics{ + oversized: oversized, + permanentResponses: permanent, + retryableResponses: retryable, + deliveredBatches: batches, + deliveredEvents: events, + destinationLatency: latency, + lastSuccess: lastSuccess, + } +} + +func (m *exporterMetrics) recordOversized(ctx context.Context, count int) { + if m.oversized != nil { + m.oversized.Add(ctx, int64(count), metric.WithAttributes( + attribute.String("destination", "cloudevents"), + )) + } +} + +func (m *exporterMetrics) recordResponse( + ctx context.Context, + status int, + eventCount int, + elapsed time.Duration, +) { + attrs := metric.WithAttributes(attribute.String("status_code", strconv.Itoa(status))) + if status >= 200 && status < 300 { + if m.deliveredBatches != nil { + m.deliveredBatches.Add(ctx, 1, attrs) + m.deliveredEvents.Add(ctx, int64(eventCount), attrs) + m.lastSuccess.Record(ctx, time.Now().Unix()) + } + } else if retryableStatus(status) { + if m.retryableResponses != nil { + m.retryableResponses.Add(ctx, 1, attrs) + } + } else if m.permanentResponses != nil { + m.permanentResponses.Add(ctx, 1, attrs) + } + if m.destinationLatency != nil { + m.destinationLatency.Record(ctx, float64(elapsed.Microseconds())/1000) + } +} + +func (m *exporterMetrics) recordNetworkFailure(ctx context.Context, elapsed time.Duration) { + if m.retryableResponses != nil { + m.retryableResponses.Add(ctx, 1, metric.WithAttributes( + attribute.String("status_code", "network"), + )) + } + if m.destinationLatency != nil { + m.destinationLatency.Record(ctx, float64(elapsed.Microseconds())/1000) + } +} diff --git a/projects/openshell-exporter/extension/storagehealthextension/config.go b/projects/openshell-exporter/extension/storagehealthextension/config.go new file mode 100644 index 00000000..4ad0c2d1 --- /dev/null +++ b/projects/openshell-exporter/extension/storagehealthextension/config.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package storagehealthextension + +import ( + "errors" + "fmt" + "strings" + "time" +) + +const ( + defaultInterval = 30 * time.Second + defaultMaxEntries = 1024 + minimumInterval = time.Second +) + +var allowedStorageNames = map[string]struct{}{ + "checkpoints": {}, + "cloudevents_queue": {}, + "otlp_grpc_queue": {}, + "otlp_http_queue": {}, + "recovery": {}, +} + +type Config struct { + Interval time.Duration `mapstructure:"interval"` + MaxEntries int `mapstructure:"max_entries"` + Paths map[string]string `mapstructure:"paths"` +} + +func (config *Config) Validate() error { + if config.Interval < minimumInterval { + return fmt.Errorf("interval must be at least %s", minimumInterval) + } + if config.MaxEntries < 1 || config.MaxEntries > 10_000 { + return errors.New("max_entries must be between 1 and 10000") + } + if len(config.Paths) == 0 { + return errors.New("at least one durable path is required") + } + for name, path := range config.Paths { + if _, ok := allowedStorageNames[name]; !ok { + return fmt.Errorf("unsupported durable path name %q", name) + } + if strings.TrimSpace(path) == "" { + return fmt.Errorf("durable path %q is empty", name) + } + } + return nil +} diff --git a/projects/openshell-exporter/extension/storagehealthextension/extension.go b/projects/openshell-exporter/extension/storagehealthextension/extension.go new file mode 100644 index 00000000..f904e69c --- /dev/null +++ b/projects/openshell-exporter/extension/storagehealthextension/extension.go @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package storagehealthextension + +import ( + "context" + "errors" + "io" + "math" + "os" + "path/filepath" + "sort" + "sync" + "syscall" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" +) + +const storageMeterName = "github.com/NVIDIA-dev/OpenShell-exporter/storagehealth" + +type storageMetrics struct { + configured metric.Int64Gauge + available metric.Int64Gauge + freeBytes metric.Int64Gauge + freeInodes metric.Int64Gauge + statePresent metric.Int64Gauge + stateAge metric.Float64Gauge + truncated metric.Int64Gauge +} + +type storageHealthExtension struct { + config *Config + metrics storageMetrics + now func() time.Time + + mutex sync.Mutex + cancel context.CancelFunc + wait sync.WaitGroup +} + +type pathSample struct { + available bool + freeBytes int64 + freeInodes int64 + statePresent bool + stateAge float64 + truncated bool +} + +func newStorageHealthExtension(config *Config, settings extension.Settings) (*storageHealthExtension, error) { + meterProvider := settings.MeterProvider + if meterProvider == nil { + meterProvider = metricnoop.NewMeterProvider() + } + meter := meterProvider.Meter(storageMeterName) + configured, err := meter.Int64Gauge( + "openshell.exporter.storage.configured", + metric.WithDescription("Configured durable exporter paths by bounded storage role."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + available, err := meter.Int64Gauge( + "openshell.exporter.storage.available", + metric.WithDescription("Stat availability for each configured durable exporter path."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + freeBytes, err := meter.Int64Gauge( + "openshell.exporter.storage.free_bytes", + metric.WithDescription("Filesystem bytes available to each configured durable exporter path."), + metric.WithUnit("By"), + ) + if err != nil { + return nil, err + } + freeInodes, err := meter.Int64Gauge( + "openshell.exporter.storage.free_inodes", + metric.WithDescription("Filesystem inodes available to each configured durable exporter path."), + metric.WithUnit("{inode}"), + ) + if err != nil { + return nil, err + } + statePresent, err := meter.Int64Gauge( + "openshell.exporter.storage.state_present", + metric.WithDescription("At least one state file exists at the configured durable exporter path."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + stateAge, err := meter.Float64Gauge( + "openshell.exporter.storage.state_age_seconds", + metric.WithDescription("Age of the newest state file at the configured durable exporter path."), + metric.WithUnit("s"), + ) + if err != nil { + return nil, err + } + truncated, err := meter.Int64Gauge( + "openshell.exporter.storage.scan_truncated", + metric.WithDescription("Durable path contained more entries than the bounded state-age scan limit."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + return &storageHealthExtension{ + config: config, + metrics: storageMetrics{ + configured: configured, available: available, freeBytes: freeBytes, + freeInodes: freeInodes, statePresent: statePresent, stateAge: stateAge, + truncated: truncated, + }, + now: time.Now, + }, nil +} + +func (extension *storageHealthExtension) Start(_ context.Context, _ component.Host) error { + extension.mutex.Lock() + defer extension.mutex.Unlock() + if extension.cancel != nil { + return nil + } + workerContext, cancel := context.WithCancel(context.Background()) + extension.cancel = cancel + extension.wait.Add(1) + go extension.run(workerContext) + return nil +} + +func (extension *storageHealthExtension) Shutdown(ctx context.Context) error { + extension.mutex.Lock() + cancel := extension.cancel + extension.cancel = nil + extension.mutex.Unlock() + if cancel != nil { + cancel() + done := make(chan struct{}) + go func() { + extension.wait.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func (extension *storageHealthExtension) run(ctx context.Context) { + defer extension.wait.Done() + extension.sample(ctx) + ticker := time.NewTicker(extension.config.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + extension.sample(ctx) + } + } +} + +func (extension *storageHealthExtension) sample(ctx context.Context) { + names := make([]string, 0, len(extension.config.Paths)) + for name := range extension.config.Paths { + names = append(names, name) + } + sort.Strings(names) + now := extension.now() + for _, name := range names { + attrs := metric.WithAttributes(attribute.String("storage", name)) + extension.metrics.configured.Record(ctx, 1, attrs) + sample, err := samplePath(extension.config.Paths[name], extension.config.MaxEntries, now) + if err != nil { + extension.metrics.available.Record(ctx, 0, attrs) + extension.metrics.freeBytes.Record(ctx, 0, attrs) + extension.metrics.freeInodes.Record(ctx, 0, attrs) + extension.metrics.statePresent.Record(ctx, 0, attrs) + extension.metrics.stateAge.Record(ctx, 0, attrs) + extension.metrics.truncated.Record(ctx, 0, attrs) + continue + } + extension.metrics.available.Record(ctx, boolValue(sample.available), attrs) + extension.metrics.freeBytes.Record(ctx, sample.freeBytes, attrs) + extension.metrics.freeInodes.Record(ctx, sample.freeInodes, attrs) + extension.metrics.statePresent.Record(ctx, boolValue(sample.statePresent), attrs) + extension.metrics.stateAge.Record(ctx, sample.stateAge, attrs) + extension.metrics.truncated.Record(ctx, boolValue(sample.truncated), attrs) + } +} + +func samplePath(path string, maxEntries int, now time.Time) (pathSample, error) { + return samplePathWithStatfs(path, maxEntries, now, syscall.Statfs) +} + +type statfsFunc func(string, *syscall.Statfs_t) error + +func samplePathWithStatfs(path string, maxEntries int, now time.Time, statfs statfsFunc) (pathSample, error) { + statTarget, err := nearestExistingPath(path) + if err != nil { + return pathSample{}, err + } + var filesystem syscall.Statfs_t + if err := statfs(statTarget, &filesystem); err != nil { + return pathSample{}, err + } + newest, present, truncated, err := newestState(path, maxEntries) + if err != nil { + return pathSample{}, err + } + age := 0.0 + if present { + age = now.Sub(newest).Seconds() + if age < 0 { + age = 0 + } + } + return pathSample{ + available: true, + freeBytes: saturatingProduct(uint64(filesystem.Bavail), uint64(filesystem.Bsize)), + freeInodes: saturatingInt64(uint64(filesystem.Ffree)), + statePresent: present, + stateAge: age, + truncated: truncated, + }, nil +} + +func nearestExistingPath(path string) (string, error) { + candidate := filepath.Clean(path) + for { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", err + } + parent := filepath.Dir(candidate) + if parent == candidate { + return "", os.ErrNotExist + } + candidate = parent + } +} + +func newestState(path string, maxEntries int) (time.Time, bool, bool, error) { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return time.Time{}, false, false, nil + } + return time.Time{}, false, false, err + } + if !info.IsDir() { + return info.ModTime(), true, false, nil + } + directory, err := os.Open(path) + if err != nil { + return time.Time{}, false, false, err + } + defer func() { + _ = directory.Close() + }() + entries, err := directory.ReadDir(maxEntries + 1) + if err != nil && !errors.Is(err, io.EOF) { + return time.Time{}, false, false, err + } + truncated := len(entries) > maxEntries + if truncated { + entries = entries[:maxEntries] + } + newest := time.Time{} + for _, entry := range entries { + entryInfo, err := entry.Info() + if err != nil { + return time.Time{}, false, truncated, err + } + if entryInfo.ModTime().After(newest) { + newest = entryInfo.ModTime() + } + } + return newest, !newest.IsZero(), truncated, nil +} + +func boolValue(value bool) int64 { + if value { + return 1 + } + return 0 +} + +func saturatingInt64(value uint64) int64 { + if value > math.MaxInt64 { + return math.MaxInt64 + } + return int64(value) +} + +func saturatingProduct(left, right uint64) int64 { + if right != 0 && left > math.MaxInt64/right { + return math.MaxInt64 + } + return int64(left * right) +} diff --git a/projects/openshell-exporter/extension/storagehealthextension/extension_test.go b/projects/openshell-exporter/extension/storagehealthextension/extension_test.go new file mode 100644 index 00000000..c761927d --- /dev/null +++ b/projects/openshell-exporter/extension/storagehealthextension/extension_test.go @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package storagehealthextension + +import ( + "context" + "os" + "path/filepath" + "sort" + "syscall" + "testing" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.uber.org/zap" +) + +func TestLifecycleIsIdempotent(t *testing.T) { + t.Parallel() + config := &Config{ + Interval: time.Second, MaxEntries: 1, + Paths: map[string]string{"checkpoints": t.TempDir()}, + } + instance, err := newStorageHealthExtension(config, extension.Settings{ + TelemetrySettings: component.TelemetrySettings{Logger: zap.NewNop()}, + }) + if err != nil { + t.Fatal(err) + } + if err := instance.Start(context.Background(), nil); err != nil { + t.Fatal(err) + } + if err := instance.Start(context.Background(), nil); err != nil { + t.Fatal(err) + } + if err := instance.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if err := instance.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestConfigValidationFailsClosed(t *testing.T) { + t.Parallel() + tests := []Config{ + {Interval: time.Second, MaxEntries: 1}, + {Interval: time.Millisecond, MaxEntries: 1, Paths: map[string]string{"checkpoints": "/tmp"}}, + {Interval: time.Second, MaxEntries: 0, Paths: map[string]string{"checkpoints": "/tmp"}}, + {Interval: time.Second, MaxEntries: 1, Paths: map[string]string{"customer-sandbox": "/tmp"}}, + {Interval: time.Second, MaxEntries: 1, Paths: map[string]string{"checkpoints": ""}}, + } + for index := range tests { + if err := tests[index].Validate(); err == nil { + t.Fatalf("test %d unexpectedly passed", index) + } + } + valid := Config{ + Interval: time.Second, MaxEntries: 10, + Paths: map[string]string{"checkpoints": "/tmp", "recovery": "/tmp/recovery.json"}, + } + if err := valid.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestStorageMetricsAreBoundedAndDoNotExposePaths(t *testing.T) { + t.Parallel() + root := t.TempDir() + checkpointDir := filepath.Join(root, "customer-secret", "checkpoints") + if err := os.MkdirAll(checkpointDir, 0o700); err != nil { + t.Fatal(err) + } + stateFile := filepath.Join(checkpointDir, "state.db") + if err := os.WriteFile(stateFile, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + if err := os.Chtimes(stateFile, now.Add(-2*time.Minute), now.Add(-2*time.Minute)); err != nil { + t.Fatal(err) + } + + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + config := &Config{ + Interval: time.Hour, MaxEntries: 10, + Paths: map[string]string{ + "checkpoints": checkpointDir, + "recovery": filepath.Join(root, "customer-secret", "recovery", "events.json"), + }, + } + instance, err := newStorageHealthExtension(config, extension.Settings{ + TelemetrySettings: component.TelemetrySettings{ + Logger: zap.NewNop(), MeterProvider: provider, + }, + }) + if err != nil { + t.Fatal(err) + } + instance.now = func() time.Time { return now } + instance.sample(context.Background()) + + metrics := collectMetrics(t, reader) + wantNames := []string{ + "openshell.exporter.storage.available", + "openshell.exporter.storage.configured", + "openshell.exporter.storage.free_bytes", + "openshell.exporter.storage.free_inodes", + "openshell.exporter.storage.scan_truncated", + "openshell.exporter.storage.state_age_seconds", + "openshell.exporter.storage.state_present", + } + gotNames := make([]string, 0, len(metrics)) + for name := range metrics { + gotNames = append(gotNames, name) + } + sort.Strings(gotNames) + if !equalStringSlices(gotNames, wantNames) { + t.Fatalf("metric names=%v, want %v", gotNames, wantNames) + } + for name, points := range metrics { + if len(points) != 2 { + t.Fatalf("metric %q points=%d, want 2", name, len(points)) + } + for _, point := range points { + labels := point.attributes.ToSlice() + if len(labels) != 1 || labels[0].Key != attribute.Key("storage") { + t.Fatalf("metric %q labels=%v", name, labels) + } + if value := labels[0].Value.AsString(); value != "checkpoints" && value != "recovery" { + t.Fatalf("metric %q exposed unbounded storage label %q", name, value) + } + } + } + assertMetricValue(t, metrics, "openshell.exporter.storage.available", "checkpoints", 1) + assertMetricValue(t, metrics, "openshell.exporter.storage.available", "recovery", 1) + assertMetricValue(t, metrics, "openshell.exporter.storage.state_present", "checkpoints", 1) + assertMetricValue(t, metrics, "openshell.exporter.storage.state_present", "recovery", 0) + assertMetricValue(t, metrics, "openshell.exporter.storage.state_age_seconds", "checkpoints", 120) +} + +func TestStateScanIsBounded(t *testing.T) { + t.Parallel() + directory := t.TempDir() + for index := 0; index < 3; index++ { + if err := os.WriteFile(filepath.Join(directory, string(rune('a'+index))), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + _, present, truncated, err := newestState(directory, 2) + if err != nil { + t.Fatal(err) + } + if !present || !truncated { + t.Fatalf("present=%t truncated=%t", present, truncated) + } +} + +func TestEmptyDurableDirectoryIsAvailableWithoutState(t *testing.T) { + t.Parallel() + sample, err := samplePath(t.TempDir(), 2, time.Now()) + if err != nil { + t.Fatal(err) + } + if !sample.available || sample.statePresent || sample.truncated { + t.Fatalf("sample=%+v, want available empty durable directory", sample) + } +} + +func TestSaturatingProduct(t *testing.T) { + t.Parallel() + if got := saturatingProduct(2, 3); got != 6 { + t.Fatalf("saturatingProduct(2, 3)=%d, want 6", got) + } + if got := saturatingProduct(^uint64(0), 2); got != int64(^uint64(0)>>1) { + t.Fatalf("overflow result=%d, want MaxInt64", got) + } +} + +func TestZeroFilesystemCapacityIsReportedExactly(t *testing.T) { + t.Parallel() + directory := t.TempDir() + sample, err := samplePathWithStatfs(directory, 1, time.Now(), func(_ string, filesystem *syscall.Statfs_t) error { + filesystem.Bavail = 0 + filesystem.Bsize = 4096 + filesystem.Ffree = 0 + return nil + }) + if err != nil { + t.Fatal(err) + } + if !sample.available || sample.freeBytes != 0 || sample.freeInodes != 0 { + t.Fatalf("sample=%+v, want available storage with zero free bytes and inodes", sample) + } +} + +type metricPoint struct { + attributes attribute.Set + value float64 +} + +func collectMetrics(t *testing.T, reader *sdkmetric.ManualReader) map[string][]metricPoint { + t.Helper() + var resourceMetrics metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resourceMetrics); err != nil { + t.Fatal(err) + } + result := map[string][]metricPoint{} + for _, scope := range resourceMetrics.ScopeMetrics { + for _, metric := range scope.Metrics { + switch data := metric.Data.(type) { + case metricdata.Gauge[int64]: + for _, point := range data.DataPoints { + result[metric.Name] = append(result[metric.Name], metricPoint{point.Attributes, float64(point.Value)}) + } + case metricdata.Gauge[float64]: + for _, point := range data.DataPoints { + result[metric.Name] = append(result[metric.Name], metricPoint{point.Attributes, point.Value}) + } + } + } + } + return result +} + +func assertMetricValue(t *testing.T, metrics map[string][]metricPoint, name, storage string, want float64) { + t.Helper() + for _, point := range metrics[name] { + value, ok := point.attributes.Value(attribute.Key("storage")) + if ok && value.AsString() == storage { + if point.value != want { + t.Fatalf("metric %q storage %q value=%v, want %v", name, storage, point.value, want) + } + return + } + } + t.Fatalf("metric %q storage %q not found", name, storage) +} + +func equalStringSlices(got, want []string) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} diff --git a/projects/openshell-exporter/extension/storagehealthextension/factory.go b/projects/openshell-exporter/extension/storagehealthextension/factory.go new file mode 100644 index 00000000..383af0fc --- /dev/null +++ b/projects/openshell-exporter/extension/storagehealthextension/factory.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package storagehealthextension + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" +) + +var componentType = component.MustNewType("storagehealth") + +func NewFactory() extension.Factory { + return extension.NewFactory( + componentType, + createDefaultConfig, + createExtension, + component.StabilityLevelAlpha, + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + Interval: defaultInterval, + MaxEntries: defaultMaxEntries, + Paths: map[string]string{}, + } +} + +func createExtension( + _ context.Context, + settings extension.Settings, + componentConfig component.Config, +) (extension.Extension, error) { + config := componentConfig.(*Config) + if err := config.Validate(); err != nil { + return nil, err + } + return newStorageHealthExtension(config, settings) +} diff --git a/projects/openshell-exporter/go.mod b/projects/openshell-exporter/go.mod new file mode 100644 index 00000000..1b03619e --- /dev/null +++ b/projects/openshell-exporter/go.mod @@ -0,0 +1,280 @@ +module github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter + +go 1.26.0 + +toolchain go1.26.8 + +require ( + github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260825132042-455883905a7a + github.com/open-telemetry/opentelemetry-collector-contrib/exporter/fileexporter v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourceprocessor v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/receiver/filelogreceiver v0.160.0 + github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sobjectsreceiver v0.160.0 + go.opentelemetry.io/collector/component v1.66.0 + go.opentelemetry.io/collector/config/configauth v1.66.0 + go.opentelemetry.io/collector/config/confighttp v0.160.0 + go.opentelemetry.io/collector/config/configmiddleware v1.66.0 + go.opentelemetry.io/collector/config/configopaque v1.66.0 + go.opentelemetry.io/collector/config/configoptional v1.66.0 + go.opentelemetry.io/collector/config/configretry v1.66.0 + go.opentelemetry.io/collector/confmap v1.66.0 + go.opentelemetry.io/collector/confmap/provider/envprovider v1.66.0 + go.opentelemetry.io/collector/confmap/provider/fileprovider v1.66.0 + go.opentelemetry.io/collector/confmap/provider/yamlprovider v1.66.0 + go.opentelemetry.io/collector/connector v0.160.0 + go.opentelemetry.io/collector/consumer v1.66.0 + go.opentelemetry.io/collector/consumer/consumererror v0.160.0 + go.opentelemetry.io/collector/exporter v1.66.0 + go.opentelemetry.io/collector/exporter/exporterhelper v0.160.0 + go.opentelemetry.io/collector/exporter/otlpexporter v0.160.0 + go.opentelemetry.io/collector/exporter/otlphttpexporter v0.160.0 + go.opentelemetry.io/collector/extension v1.66.0 + go.opentelemetry.io/collector/extension/xextension v0.160.0 + go.opentelemetry.io/collector/otelcol v0.160.0 + go.opentelemetry.io/collector/pdata v1.66.0 + go.opentelemetry.io/collector/processor v1.66.0 + go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.160.0 + go.opentelemetry.io/collector/processor/processorhelper v0.160.0 + go.opentelemetry.io/collector/processor/processortest v0.160.0 + go.opentelemetry.io/collector/receiver v1.66.0 + go.opentelemetry.io/collector/receiver/otlpreceiver v0.160.0 + go.opentelemetry.io/collector/service v0.160.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.uber.org/zap v1.28.0 + golang.org/x/sys v0.48.0 + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 +) + +require ( + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/DeRuina/timberjack v1.4.7 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alecthomas/participle/v2 v2.1.4 // indirect + github.com/antchfx/xmlquery v1.5.1 // indirect + github.com/antchfx/xpath v1.3.8 // indirect + github.com/aws/aws-sdk-go-v2 v1.45.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.1 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.1 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect + github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.46.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect + github.com/aws/smithy-go v1.28.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cenkalti/backoff/v7 v7.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/ebitengine/purego v0.10.2 // indirect + github.com/elastic/go-grok v0.3.1 // indirect + github.com/elastic/lunes v0.2.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/expr-lang/expr v1.17.8 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/foxboron/go-tpm-keyfiles v0.0.0-20251226215517-609e4778396f // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.28.0 // indirect + github.com/go-openapi/swag/cmdutils v0.28.0 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/netutils v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/knadh/koanf/maps v0.1.3 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/v2 v2.3.6 // indirect + github.com/leodido/go-syslog/v4 v4.6.0 // indirect + github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/extension/k8sleaderelector v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/common v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/healthcheck v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sconfig v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sinventory v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/internal/sharedcomponent v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/batchpersignal v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.160.0 // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/status v0.160.0 // indirect + github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7 // indirect + github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 // indirect + github.com/pierrec/lz4/v4 v4.1.29 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.24.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.8 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.12.1 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/twmb/murmur3 v1.1.8 // indirect + github.com/ua-parser/uap-go v0.0.0-20251207011819-db9adb27a0b8 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.etcd.io/bbolt v1.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/collector v0.160.0 // indirect + go.opentelemetry.io/collector/client v1.66.0 // indirect + go.opentelemetry.io/collector/component/componentstatus v0.160.0 // indirect + go.opentelemetry.io/collector/component/componenttest v0.160.0 // indirect + go.opentelemetry.io/collector/config/configcompression v1.66.0 // indirect + go.opentelemetry.io/collector/config/configgrpc v1.66.0 // indirect + go.opentelemetry.io/collector/config/confignet v1.66.0 // indirect + go.opentelemetry.io/collector/config/configtelemetry v0.160.0 // indirect + go.opentelemetry.io/collector/config/configtls v1.66.0 // indirect + go.opentelemetry.io/collector/confmap/xconfmap v0.160.0 // indirect + go.opentelemetry.io/collector/connector/connectortest v0.160.0 // indirect + go.opentelemetry.io/collector/connector/xconnector v0.160.0 // indirect + go.opentelemetry.io/collector/consumer/consumererror/xconsumererror v0.160.0 // indirect + go.opentelemetry.io/collector/consumer/consumertest v0.160.0 // indirect + go.opentelemetry.io/collector/consumer/xconsumer v0.160.0 // indirect + go.opentelemetry.io/collector/exporter/exporterhelper/xexporterhelper v0.160.0 // indirect + go.opentelemetry.io/collector/exporter/exportertest v0.160.0 // indirect + go.opentelemetry.io/collector/exporter/xexporter v0.160.0 // indirect + go.opentelemetry.io/collector/extension/extensionauth v1.66.0 // indirect + go.opentelemetry.io/collector/extension/extensioncapabilities v0.160.0 // indirect + go.opentelemetry.io/collector/extension/extensionmiddleware v0.160.0 // indirect + go.opentelemetry.io/collector/extension/extensiontest v0.160.0 // indirect + go.opentelemetry.io/collector/featuregate v1.66.0 // indirect + go.opentelemetry.io/collector/filter v0.160.0 // indirect + go.opentelemetry.io/collector/internal/componentalias v0.160.0 // indirect + go.opentelemetry.io/collector/internal/fanoutconsumer v0.160.0 // indirect + go.opentelemetry.io/collector/internal/memorylimiter v0.160.0 // indirect + go.opentelemetry.io/collector/internal/sharedcomponent v0.160.0 // indirect + go.opentelemetry.io/collector/internal/telemetry v0.160.0 // indirect + go.opentelemetry.io/collector/pdata/pprofile v0.160.0 // indirect + go.opentelemetry.io/collector/pdata/testdata v0.160.0 // indirect + go.opentelemetry.io/collector/pdata/xpdata v0.160.0 // indirect + go.opentelemetry.io/collector/pipeline v1.66.0 // indirect + go.opentelemetry.io/collector/pipeline/xpipeline v0.160.0 // indirect + go.opentelemetry.io/collector/processor/processorhelper/xprocessorhelper v0.160.0 // indirect + go.opentelemetry.io/collector/processor/xprocessor v0.160.0 // indirect + go.opentelemetry.io/collector/receiver/receiverhelper v0.160.0 // indirect + go.opentelemetry.io/collector/receiver/receivertest v0.160.0 // indirect + go.opentelemetry.io/collector/receiver/xreceiver v0.160.0 // indirect + go.opentelemetry.io/collector/service/hostcapabilities v0.160.0 // indirect + go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 // indirect + go.opentelemetry.io/contrib/detectors/aws/ecs v1.45.0 // indirect + go.opentelemetry.io/contrib/detectors/aws/eks v1.45.0 // indirect + go.opentelemetry.io/contrib/detectors/azure/azurevm v0.17.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.45.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.opentelemetry.io/contrib/otelconf v0.25.0 // indirect + go.opentelemetry.io/contrib/propagators/autoprop v0.70.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.45.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.45.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.45.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.67.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect + go.opentelemetry.io/otel/log v0.22.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.21.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.15.0 // indirect + gonum.org/v1/gonum v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.36.4 // indirect + k8s.io/apimachinery v0.36.4 // indirect + k8s.io/client-go v0.36.4 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect + sigs.k8s.io/controller-runtime v0.24.1 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/projects/openshell-exporter/go.sum b/projects/openshell-exporter/go.sum new file mode 100644 index 00000000..1f3bcb2d --- /dev/null +++ b/projects/openshell-exporter/go.sum @@ -0,0 +1,766 @@ +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/DeRuina/timberjack v1.4.7 h1:bAM3pgbmwRrBxgKfAlqJ7Q1LOFxoqcyy0NhVsTeEJrA= +github.com/DeRuina/timberjack v1.4.7/go.mod h1:DCXjOescYyzTrOryf57SEbSeCOqkBQ3QjH7Pn03i5Cs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 h1:bN1gA3of5bXtbnLsRPrwfmbbe7A5UWFlcTHseujLnpc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0/go.mod h1:Yj5vHEz/aAepZGliRJsA6uvHAVAQyEwajq9ORCHPxzM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260825132042-455883905a7a h1:Pl/w+mbbijUg96ktvDBO4OMmXTtgsaPNxhpsGFIqoMk= +github.com/NVIDIA/OpenShell/sdk/go v0.0.0-20260825132042-455883905a7a/go.mod h1:PPbbhH5tmSfoWzVcb5CXurnMkuJOYpesZmUB1itrlFY= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= +github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/antchfx/xpath v1.3.8 h1:RQlkLaJDKk1Ew1H6CUPUTKM+IQxm+6HTyOgcrfqOU9c= +github.com/antchfx/xpath v1.3.8/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= +github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks= +github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw= +github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A= +github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ= +github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw= +github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.46.1 h1:P12LPOpkuH3AGuW3wCAxAb+/wwzHoTQK3e7Jon4oFas= +github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.46.1/go.mod h1:MRyJtazDpGm2aFdt0RkbnF3e90xTZGWdPGBZpjIOJ04= +github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE= +github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI= +github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM= +github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c= +github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0= +github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd h1:C0dfBzAdNMqxokqWUysk2KTJSMmqvh9cNW1opdy5+0Q= +github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20221221133751-67e37ae746cd/go.mod h1:CeKhh8xSs3WZAc50xABMxu+FlfAAd5PNumo7NfOv7EE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v7 v7.0.0 h1:ZP+QAaaOnVUHo+ufFpZ835hbT3x2fy+h2lecVEosZ6A= +github.com/cenkalti/backoff/v7 v7.0.0/go.mod h1:qcKBGwsu4hpxHtQ8tWYsQ+ifzx2+sS+Xx/3jfe30lI8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U= +github.com/elastic/go-grok v0.3.1/go.mod h1:n38ls8ZgOboZRgKcjMY8eFeZFMmcL9n2lP0iHhIDk64= +github.com/elastic/lunes v0.2.2 h1:dZFEaebNg9l+mzvOQN6Nd/c9y6y8rUe3tBWsTgvM08U= +github.com/elastic/lunes v0.2.2/go.mod h1:u3W/BdONWTrh0JjNZ21C907dDc+cUZttZrGa625nf2k= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxboron/go-tpm-keyfiles v0.0.0-20251226215517-609e4778396f h1:RJ+BDPLSHQO7cSjKBqjPJSbi1qfk9WcsjQDtZiw3dZw= +github.com/foxboron/go-tpm-keyfiles v0.0.0-20251226215517-609e4778396f/go.mod h1:VHbbch/X4roIY22jL1s3qRbZhCiRIgUAF/PdSUcx2io= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= +github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= +github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= +github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws= +github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= +github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4= +github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= +github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-syslog/v4 v4.6.0 h1:3o9H6K0cj9ITrNt7MG6CJ/Ae+vXmZcEPhI/N30/20Ig= +github.com/leodido/go-syslog/v4 v4.6.0/go.mod h1:BOEXCJSgy32THF4eZWwtZ11w6LrrFVBj+nMtv06ge4w= +github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0= +github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= +github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/fileexporter v0.160.0 h1:F92t2DB5K/McNoSa0r/kXGWGGsBkoxxYLzt4szCy+YI= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/fileexporter v0.160.0/go.mod h1:cnh4/5XUu0szCxQZ8lZWDpQUSTcb9tYc4LZtNH05D1Y= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter v0.160.0 h1:mCqsd8jq8lsDCgFfR+9xcRN9NfMe7O18XcyvDoZkdXM= +github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter v0.160.0/go.mod h1:wN+edayRWl4izkn1+N8y1Qwjam6zboGclooYYaJNrmE= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension v0.160.0 h1:RSPfTdqBuM1ZRjMMcjPCw2oLMl5ybVeqWGf0n8mBM4Y= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/bearertokenauthextension v0.160.0/go.mod h1:7T500+GFY1TfpR5f/RiiRAuDz6dDmN6PB/buAIi5OcY= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding v0.160.0 h1:9DB2aUIid1BjtYOsXU0o9mIEl6u0UE+4MQRgmHDYv/o= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding v0.160.0/go.mod h1:oL61j8Ea1AjFoiKlZmb9f47CcyEAOWXdaFu5NThpXxs= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding/otlpencodingextension v0.160.0 h1:MlEBAzeVv9gmuPJeXHPJRmGrEt2Pe8hhsaoiWoaaQbk= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding/otlpencodingextension v0.160.0/go.mod h1:tQAj2bTjwEmMO1OmddIbeUGmy26PWH2mUJ44aacGgu4= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.160.0 h1:+Wc4w1ubs+fjarbTjuIwEm+mGwtz0h5P3Rx6QKL65cs= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.160.0/go.mod h1:A5vpQ23oRdYwmz+I4Ps9qD8o64IE5XmFtsfik4v4Puw= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile v0.160.0 h1:+Bv4gqVDkjmJ0Na18DNVjKlgvI2s73xU/Ftt5ltYWCw= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/internal/credentialsfile v0.160.0/go.mod h1:ES7NpVsTrG0v/t8k3FOkEtoGQlcif3il2Lc9hwnP9sE= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/k8sleaderelector v0.160.0 h1:GCrsNTYJ3h/EfQDiLhP+FP8hLjWB4C2/JnUN30LlJZw= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/k8sleaderelector v0.160.0/go.mod h1:DFDFs5m4GPBl0VpI2wfWwwHOZ7THFJg6aGZOK2Xmm6Q= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage v0.160.0 h1:mtiXI3I5DVlk/NpDTAP+s4aIWBM2fTEYnderXz1nLSY= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage v0.160.0/go.mod h1:Asls3MkjllqrACUBRGOXaC9Rr3SJ67mkLOOSQgpekPc= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage v0.160.0 h1:1qEz630ZgNwbQXAKLZHNNysnE4d2Qj6FAAO+Q/N4yhs= +github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage v0.160.0/go.mod h1:SH+uFxl3xDCEaSNfk4o9FBRuIfGDWlmxHBpPx73z7OQ= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/common v0.160.0 h1:4OHGcs4P5wIaNZzezFBQL1c5FeISZ3WF3yx8cwTrDb0= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/common v0.160.0/go.mod h1:sFWqV6HHAg30rwSjwyp1lXqTxhMBYCSVZDeg/IYk8yY= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.160.0 h1:bgUTLilI1DRaRi8wII9aJu0yQZuS/jFu0RBf6EL+Sfw= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.160.0/go.mod h1:PcugtOWj/c+J4hhVBcXfcM4mv+R5vD/mKJW+hW1x8WM= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.160.0 h1:XwC9DyKBx2RYeCkDtu53bz0YyM16tt8nQi4bYAIeJI0= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.160.0/go.mod h1:reZbvAmPMr7JvMBy3P2IqxU0k6/sQTtlr1LiWi3XRVA= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.160.0 h1:j0NOlR7a5P6rX9SE812RQqbOn0bFvu56Mkv90Bt4jsc= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/filter v0.160.0/go.mod h1:NRCK83U0dVzSlBxlBIWULhMg4q5yRtnrDU8p0T1+shE= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/healthcheck v0.160.0 h1:SuW/UQn3F3t69saZPcIzpgsRBsOR9JtNhx1jexm9cpY= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/healthcheck v0.160.0/go.mod h1:rH/GP010UQ1N22CRKEF3gwFpqRCcLpoo/u4Fuwe0mCs= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sconfig v0.160.0 h1:d1+wYyM/cWn48IYlSJMsbhw2HkodYS+N4PPB0j0qems= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sconfig v0.160.0/go.mod h1:GmHiRXgv8mYjtKc5Baq1geissS477dhRosMGxqdRBG4= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sinventory v0.160.0 h1:yX6b2VP+r6LC51PXQmGVXUJdc5dtKY+PlX3q10y/HBo= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sinventory v0.160.0/go.mod h1:QP1ZYQQbgyKIB7b1cN58nHGviq5VriAu09wDpL7WFSU= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sleaderelectortest v0.160.0 h1:1BSodp8Q8nn0KDwTsMIETxCvsXHQwVAiwelPNprdzLc= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sleaderelectortest v0.160.0/go.mod h1:Tf+1pVFmvwFHIfgvduMGE/fs76pvrceaGWatdbMfNCw= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/sharedcomponent v0.160.0 h1:wUOesc+yVnoX4KTunao/GipC+oHyUiaUaCdZAXL1XK8= +github.com/open-telemetry/opentelemetry-collector-contrib/internal/sharedcomponent v0.160.0/go.mod h1:3OrP6ISnwJlPSrjpWrSNjo6iQKTPFLl17OvVdQsiflk= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/batchpersignal v0.160.0 h1:awxAhSiiAVADhRoekHAO1jn9CFcnRUx6kngnQOM9GmU= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/batchpersignal v0.160.0/go.mod h1:bOs2XEZDN+j6ptS9O3Fyu2evjdtDxE2VNtxo/V1FbZM= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden v0.160.0 h1:8LfxzGUeTMqi0q01tG31mm66l3PJEGyyAqSLClSeaho= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden v0.160.0/go.mod h1:uVSZcwF4DIM9N/wM2oSNMzVePq2c82gWMudbhb2HLzU= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.160.0 h1:lvjlW4iAohPgInJ0+rYvK5IAqVgeiDiRxssEgwKC2nw= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/ottl v0.160.0/go.mod h1:6TmSQyz5Xe7o/B+QhfS8quWHgpVe6ic2YPNI0GEeqHY= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.160.0 h1:PFqwQnOTTUFrwLYPopdLcK42OeJpJ8EdGzZoVj7JdAY= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.160.0/go.mod h1:x4+nWF854DOMUAzOUOKazQcagdBDXYDVA2tRBlIKyCw= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.160.0 h1:KuAMWLY47aSlhUR45cty6V3vmqPfGtuEWUMWoXLihL8= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.160.0/go.mod h1:m7KYHz7g2nS7G3pLpyVoLFiyKXiVQNSsizqj+Dg1pLo= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.160.0 h1:Gk0qHgeKqPNQYr9unqkkr2yrfwspW6UUnbzMKNmdZ9M= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza v0.160.0/go.mod h1:8QzaC7ITQ6ul4a2YcHoZr0IslPczaJjoxHNIhIEIBiM= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/status v0.160.0 h1:kvrSOaF4BSdjPN7TcqyRbdwShH0Ox87DpxDTeeD5kf0= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/status v0.160.0/go.mod h1:6SbdXJxinPqEg06k8BICRxYtXzit4kKLMlfccFFqyBI= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/xk8stest v0.160.0 h1:8hCo0hU5v1POfFGAge91EJLlzCWnPiFGIkLBlAXLcns= +github.com/open-telemetry/opentelemetry-collector-contrib/pkg/xk8stest v0.160.0/go.mod h1:WCPkU7Re0ks2IcYGyTxwhhxJI1pDM2uGgmI04bxKdNU= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.160.0 h1:oar6wasA3bbQgT6QI55lJjK4nKdDe/o3OTFv7ZAfKBs= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/attributesprocessor v0.160.0/go.mod h1:0JXASqxcKhxU4wuqB/0bSN7YggzIlAMsRSSluERZqSA= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourceprocessor v0.160.0 h1:QKdlb+ZVC1LDGvipUKu909UR/IegA8n4JG0ptbgaRDQ= +github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourceprocessor v0.160.0/go.mod h1:gIlR9u/lmSvJgy4wENVC2rEyAa+36T6rAvv3pqO/zzc= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/filelogreceiver v0.160.0 h1:Ns8FjywoPhWf4igyFbDqkhfvnPrc7XmlFsLNo5Si3sE= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/filelogreceiver v0.160.0/go.mod h1:9hm0b2v7p01Qn3dB7IvQNbR+8XlZFAvZh9bu5xFjNpc= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sobjectsreceiver v0.160.0 h1:InxduEfEog086it1Sq+rv8hGGvn4oauAmkF9KllYnaU= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sobjectsreceiver v0.160.0/go.mod h1:L/6UHuQ+p2krhthvNKEV1DZeJsWZI/k/ZFWYkQYLx/w= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7 h1:Ot2fbEEPmF3WlPQkyEW/bUCV38GMugH/UmZvxpWceNc= +github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= +github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY= +github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235/go.mod h1:L49W6pfrZkfOE5iC1PqEkuLkXG4W0BX4w8b+L2Bv7fM= +github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg= +github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo= +github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/ua-parser/uap-go v0.0.0-20251207011819-db9adb27a0b8 h1:yS0rzVnj7Z/ZeHzvv5erQbO2b8gyTL4CeMNodl9SJMQ= +github.com/ua-parser/uap-go v0.0.0-20251207011819-db9adb27a0b8/go.mod h1:gwANdYmo9R8LLwGnyDFWK2PMsaXXX2HhAvCnb/UhZsM= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= +go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector v0.160.0 h1:PnyWUf8vIViBWT8Klg50j0SbmUd5rkDjc2L/3jdRz3Y= +go.opentelemetry.io/collector v0.160.0/go.mod h1:mgavHnQvFpRWacnJ0wo7JTJK8xz1ubGVx6NEEToc8GE= +go.opentelemetry.io/collector/client v1.66.0 h1:uLwmr3QejpA0upn8bBAeDjMofC5kgmoujZAUNxdRpl8= +go.opentelemetry.io/collector/client v1.66.0/go.mod h1:pH/NTKb1jXgUYXvVnRZpYq/kiBse8hJJcQSkCns4dF8= +go.opentelemetry.io/collector/component v1.66.0 h1:JaAwBPug6Wm5V0+4qHpWVcazabgZvEozfcMC8gmgIY4= +go.opentelemetry.io/collector/component v1.66.0/go.mod h1:IIS7ZSCTijjAy5uaCwqAp6RKqOs1rzHl3EmwBZ6B0V4= +go.opentelemetry.io/collector/component/componentstatus v0.160.0 h1:DwFYGAARWGp0TW1LYIGVRgSgLa2L3uOjPmYLK53TApk= +go.opentelemetry.io/collector/component/componentstatus v0.160.0/go.mod h1:qCwO9DZvdlDaSyNjra2FbVxeOYjylS4WHU4XrInjy0M= +go.opentelemetry.io/collector/component/componenttest v0.160.0 h1:vfe7NaNksFjFfsrD1dDTCp/oQmknfI19z7SLyE8XDFo= +go.opentelemetry.io/collector/component/componenttest v0.160.0/go.mod h1:Rp9rk0xkLrfbWGhz78/y7swrC1rLG8nSOVb0tjRW2TI= +go.opentelemetry.io/collector/config/configauth v1.66.0 h1:Sl+8/vCJqZKDuZDojQyex4sqkhUSE/GL2ADl319I0y4= +go.opentelemetry.io/collector/config/configauth v1.66.0/go.mod h1:dYq9Whw9PYaWBlHcSHgCZJ37b4tMbCDQC8rCNHNPI1s= +go.opentelemetry.io/collector/config/configcompression v1.66.0 h1:x+W9FJV/R3gMGAHD1CeWTQvpLpvLUTLLW7urjP/Uiq4= +go.opentelemetry.io/collector/config/configcompression v1.66.0/go.mod h1:SyPEhp8hwY8Yxuxbet2AIFDRYIEKl+tn5/jKnWtV1y0= +go.opentelemetry.io/collector/config/configgrpc v1.66.0 h1:zbOJyYcHSz9e0GweRQUYUSk2QiUd8VRjebPnskAabSc= +go.opentelemetry.io/collector/config/configgrpc v1.66.0/go.mod h1:GNolWxmCZle2VRd3YV3cKhJ88/mbil8oV3K/JAYIH9U= +go.opentelemetry.io/collector/config/confighttp v0.160.0 h1:7Qlql6hwE64gWx1SG/9IgQuEwU3MvJAG5jpe8UjnxD0= +go.opentelemetry.io/collector/config/confighttp v0.160.0/go.mod h1:Kx6kgj4QCQkBP5SceIS5bdoyEGRpNLe4wWC1xX7aL3Y= +go.opentelemetry.io/collector/config/configmiddleware v1.66.0 h1:esvaflMeMFgLStuO948VYVPxHLduH0+kDRqdXNiRtpc= +go.opentelemetry.io/collector/config/configmiddleware v1.66.0/go.mod h1:B4/F68ahOHyrHDBW0vQltq5FRODqBIEvwlWBnzKUACQ= +go.opentelemetry.io/collector/config/confignet v1.66.0 h1:b8iQ2czQuIrRV+OK6DiGUSDbxJ9jY4Q+ZH4J9rH4mNw= +go.opentelemetry.io/collector/config/confignet v1.66.0/go.mod h1:0+gyxP6P/6t0yyX5cjCuban3srMChlzyXXXwgVRtxHA= +go.opentelemetry.io/collector/config/configopaque v1.66.0 h1:pyFA37SPoX7u6RWJX818YMGjHLENAUX5iYJYABqCdYw= +go.opentelemetry.io/collector/config/configopaque v1.66.0/go.mod h1:7KllH8nccvvn7BmrdHlFFYd6hh65a+bfGgn0y67nJ9k= +go.opentelemetry.io/collector/config/configoptional v1.66.0 h1:1DC5zgyfyUDzeaivb9w5bvPwGD/7AjINS3NCPdQWlHY= +go.opentelemetry.io/collector/config/configoptional v1.66.0/go.mod h1:GuJS8LsG5DIfpCYj7OAQxrOmN6SYuWCgwpHnyY7Yh8I= +go.opentelemetry.io/collector/config/configretry v1.66.0 h1:qFuzoYW+TDKJzxJY5v9N8O1w08FMEcvYCwIY83DIJPk= +go.opentelemetry.io/collector/config/configretry v1.66.0/go.mod h1:Y2Kdgd0HFBXYbGqUUUWGDLYviRHgl9aJVSyDfFYY7U8= +go.opentelemetry.io/collector/config/configtelemetry v0.160.0 h1:WlawoDoR1C5vOpWQ+4HOSXLsQEDFKFdQdperFbrbUrw= +go.opentelemetry.io/collector/config/configtelemetry v0.160.0/go.mod h1:dLqioNzNXPjdFPtHWppiKKWPZWHQRS2PKnoSYYAVvGQ= +go.opentelemetry.io/collector/config/configtls v1.66.0 h1:hozBpidIFUMH/q4Vs6au7ILjp/uAfAUJy2B2a1Fx9Rk= +go.opentelemetry.io/collector/config/configtls v1.66.0/go.mod h1:Qn5hjCugrDDssLA+bWqYwZmDQgD9fbTX9OtBfGYs+yY= +go.opentelemetry.io/collector/confmap v1.66.0 h1:9m9ZeZ17Iz0uuks5dK0JwLXwTrqfeCM9WWfqR+SO9C8= +go.opentelemetry.io/collector/confmap v1.66.0/go.mod h1:EsXDabkoH/H4mrfECHNdxE+MGQiBlCvyZ7caoK5xY7Y= +go.opentelemetry.io/collector/confmap/provider/envprovider v1.66.0 h1:P5D6oUJT9AHBZcfRptv1YUmZALCBhzAQK+ElrjGoWVo= +go.opentelemetry.io/collector/confmap/provider/envprovider v1.66.0/go.mod h1:U3l/G2BP8uayX61cZPhN7dNogrHle9j8Ld+a4hgwh+c= +go.opentelemetry.io/collector/confmap/provider/fileprovider v1.66.0 h1:bhK31LuUumquvIoOiWjTtOGyvuTeQE91YwOkbQqlEqo= +go.opentelemetry.io/collector/confmap/provider/fileprovider v1.66.0/go.mod h1:V465/UDMZhKVq/270P7NPM8hBnKgYY2P/I+IWLJLCi0= +go.opentelemetry.io/collector/confmap/provider/httpprovider v1.66.0 h1:2kUPx8Sze7qGAemRSNKFGxo6zgK4Iw+M7VXMZhmxUzk= +go.opentelemetry.io/collector/confmap/provider/httpprovider v1.66.0/go.mod h1:VKNw834NUcVseoF6WC1ifFhdN0dgSkNjL4U3YebL56Q= +go.opentelemetry.io/collector/confmap/provider/yamlprovider v1.66.0 h1:TaIPHkStoxmXa2M+QcrddrjNeYHvfg74n59Vtf8yxqk= +go.opentelemetry.io/collector/confmap/provider/yamlprovider v1.66.0/go.mod h1:gE38bpD2FlvmfrRkE6FywoUhXVs/FbfsZlyhQVXEUzU= +go.opentelemetry.io/collector/confmap/xconfmap v0.160.0 h1:QbS1LpwROwalfGQUub+hb09wvssBSKzKC6ObF9QncoM= +go.opentelemetry.io/collector/confmap/xconfmap v0.160.0/go.mod h1:7XBYTNmwNof7hdag7H/9+437Oy0ZwEslpivDQJQtNWk= +go.opentelemetry.io/collector/connector v0.160.0 h1:MzKdOOdK1J6xALHhN1HTXhRfFXgs+QtFjTeWIrVe4Ig= +go.opentelemetry.io/collector/connector v0.160.0/go.mod h1:8pRubHSt5CB5yN9ArgQl5qQHk5y++ZAcfsRhT0A0cg8= +go.opentelemetry.io/collector/connector/connectortest v0.160.0 h1:arhM/T8m+/KeVJWM3IrJRNkRzLGCryQgcDhNR+G3dGk= +go.opentelemetry.io/collector/connector/connectortest v0.160.0/go.mod h1:BhkNLCNyyGShbtchbNXsWAywtUzGZDdiGJ7LQybrFIo= +go.opentelemetry.io/collector/connector/xconnector v0.160.0 h1:3n14qQ7ARp6b/wTqbXjBMbOqZy3JyEF3HxppGXs/h6g= +go.opentelemetry.io/collector/connector/xconnector v0.160.0/go.mod h1:kByTaYOGl9ydTKQhxLJMAfiXQYzogM1dwtaCHcT86+Q= +go.opentelemetry.io/collector/consumer v1.66.0 h1:YcL8Y7jn0b9ko8hWKHIpilYnKNtp6pxjdrSpKFR9O40= +go.opentelemetry.io/collector/consumer v1.66.0/go.mod h1:CHUqFqerioBLb4usZEiHpzTKpEJH09a0CDyYECwQYFc= +go.opentelemetry.io/collector/consumer/consumererror v0.160.0 h1:MCByq8RbEPzeXcU8DHCJ9QsGxuhyh1A8tNs28Oqx8RY= +go.opentelemetry.io/collector/consumer/consumererror v0.160.0/go.mod h1:3sCDyUj55SUEnuVnPFBCS5mUl/yiB8J78cneqOUIjEQ= +go.opentelemetry.io/collector/consumer/consumererror/xconsumererror v0.160.0 h1:UQtWoxQ6OjGRPfdwQijUOkLvHXoXDnxKZjapz509Z8Q= +go.opentelemetry.io/collector/consumer/consumererror/xconsumererror v0.160.0/go.mod h1:13Hl4bTRKM38ngFBJsRhRY4/qrAHSLWnHQOnOIzZxG8= +go.opentelemetry.io/collector/consumer/consumertest v0.160.0 h1:ClfAK9pW6Hdk1Lks59RcUfodtvOhTSkUJs8XA6NuHUc= +go.opentelemetry.io/collector/consumer/consumertest v0.160.0/go.mod h1:tl5GPf6UdhI3kfonb46xTl7r017AhmvJM2uuJ+Z5NQ0= +go.opentelemetry.io/collector/consumer/xconsumer v0.160.0 h1:pbyjZUlDxLuFDa23cAxtDU6J7tvdXKfuZCe0RlJQV9s= +go.opentelemetry.io/collector/consumer/xconsumer v0.160.0/go.mod h1:GzOE87iaxj3dXiLFiXYMOi5qeps6F8X4jdjTWibwEI8= +go.opentelemetry.io/collector/exporter v1.66.0 h1:D69xYsoX/RBpL2ZOg22LLp3Jb7aBhwSxOCtepTjdWCY= +go.opentelemetry.io/collector/exporter v1.66.0/go.mod h1:jXFly200uQdAoaZQaY4dG/6j4RHCWuZ6vszd/qYSWss= +go.opentelemetry.io/collector/exporter/exporterhelper v0.160.0 h1:RoLOGzNUi930v9JOE9kTJmJdoOd2SlStorg6A7xsjBo= +go.opentelemetry.io/collector/exporter/exporterhelper v0.160.0/go.mod h1:tK8m0cSak6ooXxh2hESBgC5pWtfF83vGEDGtzGtTOAc= +go.opentelemetry.io/collector/exporter/exporterhelper/xexporterhelper v0.160.0 h1:yFXXyO8fXqxnwCmOmjF6/KSfB+q2yv4gE9odpVX7hOc= +go.opentelemetry.io/collector/exporter/exporterhelper/xexporterhelper v0.160.0/go.mod h1:dolEGQfJ5V5WgLKUy74gi5nefBT2xrmmaADVr/z2PO4= +go.opentelemetry.io/collector/exporter/exportertest v0.160.0 h1:hvrPUksoqKPMp0nLLac8puNqos+T6w/IOpnoIAWBuho= +go.opentelemetry.io/collector/exporter/exportertest v0.160.0/go.mod h1:Q4F0vY9nxf35juEs/9p11X839wdmJs3xUCIO8IF/Iho= +go.opentelemetry.io/collector/exporter/otlpexporter v0.160.0 h1:PrA9+i03Rpj6icqTMy2bbSr7KBH8zHykizQTe2xdzsI= +go.opentelemetry.io/collector/exporter/otlpexporter v0.160.0/go.mod h1:SruicBg+JDJfoCb6eXfLeuxT/zEyIkfSsZdxAIA1/E0= +go.opentelemetry.io/collector/exporter/otlphttpexporter v0.160.0 h1:2hXLMeVxC0jDBbT3AM2ELSzCpAhoubkO8KRP+vz1250= +go.opentelemetry.io/collector/exporter/otlphttpexporter v0.160.0/go.mod h1:9u1QO752s3lIJJvA7tsxX1x241WzjASWa+tTT2Wo89k= +go.opentelemetry.io/collector/exporter/xexporter v0.160.0 h1:RHRk4z8fzjH9YOTY2Yb/32sBqwkQPtd1YfxdiiIIaYs= +go.opentelemetry.io/collector/exporter/xexporter v0.160.0/go.mod h1:kJG/JDHTAVEKLGThxUqgoLIIyJvh85nFegiOrU+01eM= +go.opentelemetry.io/collector/extension v1.66.0 h1:aOxN7VEbdoV0uTUdcjTIfBQa3aRNkeNwMinwZP/uXSw= +go.opentelemetry.io/collector/extension v1.66.0/go.mod h1:lA+L69NHrq+FckGLkqIJCBvcVWi4y+BkZCI/qmPitAg= +go.opentelemetry.io/collector/extension/extensionauth v1.66.0 h1:38ydNyw1OKsYu4RzGFA+nEuYoMCvzbO66BS8jNawfPY= +go.opentelemetry.io/collector/extension/extensionauth v1.66.0/go.mod h1:9Et8qOBCXgc7QdN6z0OW/QWMyulHDhu2D8s0/lykKLo= +go.opentelemetry.io/collector/extension/extensionauth/extensionauthtest v0.160.0 h1:sAu5IoXo2VN95Q9b6OziuThA5GW+a5D9hL9PAYaUcyY= +go.opentelemetry.io/collector/extension/extensionauth/extensionauthtest v0.160.0/go.mod h1:RpXZFEFCcw53e2BD7J4hWbMLJtVRp0a4vi4395fRdLg= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.160.0 h1:B1YK2/3bPcaU4e7+zajiSL8UDuOkXGcRnOkK8LrFpxU= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.160.0/go.mod h1:qPblgbz4WDb9qDczFsnsKc34EO+ds22FYn+ArsPANuo= +go.opentelemetry.io/collector/extension/extensionmiddleware v0.160.0 h1:7YleoKcX86Aw/e/UWOx2DM0YF8qEK6dub4HgWlArO+k= +go.opentelemetry.io/collector/extension/extensionmiddleware v0.160.0/go.mod h1:vlTPYcKAadviSXKi2djz8GxT2ryjvqLXVFCWo1bPMWw= +go.opentelemetry.io/collector/extension/extensionmiddleware/extensionmiddlewaretest v0.160.0 h1:14ijqZ4Uqz20XP4y1FzmKwKKNMzoAANzFU+pL1ED4WI= +go.opentelemetry.io/collector/extension/extensionmiddleware/extensionmiddlewaretest v0.160.0/go.mod h1:mXZWQw38xd2pT2kb1fY7vEX87OySW9qZeocBOqpVlmQ= +go.opentelemetry.io/collector/extension/extensiontest v0.160.0 h1:WQkWvcAJwLh/wq+w6p9UqCxAV2K/SMPkGTZ9LD9LNYU= +go.opentelemetry.io/collector/extension/extensiontest v0.160.0/go.mod h1:xViM7+Wa4vNe1eH3DmFC8P4Vy1QlYlXt17sRFvBtXRE= +go.opentelemetry.io/collector/extension/xextension v0.160.0 h1:z7UrTp/2e7g1+UZBSTZ2OYZW6pw5M84imeZ/R67B3QU= +go.opentelemetry.io/collector/extension/xextension v0.160.0/go.mod h1:wuQnAxEgpLNWp3ItlRUvpMensdwivuoeV4o9QYwBPEQ= +go.opentelemetry.io/collector/extension/zpagesextension v0.160.0 h1:a+6Lx/DW3lTUyXEvSN/HC/0eht6tulrdlDqmeAgtQVs= +go.opentelemetry.io/collector/extension/zpagesextension v0.160.0/go.mod h1:DMgfWE8bEVcsGLQDW4VV7f/Uq7tGT5mw4QZH+alnS5Q= +go.opentelemetry.io/collector/featuregate v1.66.0 h1:zy7JvvPanCVexGnZnMWR/htZ1BKWPEo1/vZKMuhZSQ0= +go.opentelemetry.io/collector/featuregate v1.66.0/go.mod h1:dRYifiJa2vQ6LWpPwHny4mL82mnGWsWEVeVWw+DhYJw= +go.opentelemetry.io/collector/filter v0.160.0 h1:Ls2Gk8i1Tr0FBhIGrVm1yMGdqaYlQ6e7c19aESk6c4c= +go.opentelemetry.io/collector/filter v0.160.0/go.mod h1:wrbPwRqUUnGocb+wpgMpPHAwKO+AWYgtO3tONw5ROcw= +go.opentelemetry.io/collector/internal/componentalias v0.160.0 h1:rDRD1s22Z13Q9YzkaTOfkwrLD7Hf7T4tmE6IX0bndlk= +go.opentelemetry.io/collector/internal/componentalias v0.160.0/go.mod h1:vfDgm9skzaGSDWDJgZl3E5/J6MXY3pnWiXnda4NA3/k= +go.opentelemetry.io/collector/internal/fanoutconsumer v0.160.0 h1:L4KoduejWKVMobesmKCjkCNj27M5OuMZInaw01nrQOs= +go.opentelemetry.io/collector/internal/fanoutconsumer v0.160.0/go.mod h1:TWG2ZZy2O2B9usOE4POz5HkIsjB3X0AmcuR8c2Y036g= +go.opentelemetry.io/collector/internal/memorylimiter v0.160.0 h1:OarWHi/5hphIPpPQpoe8JKuFhZfUred5lHcancD0jjc= +go.opentelemetry.io/collector/internal/memorylimiter v0.160.0/go.mod h1:Wm6QiPa/4VrCORnS79I0G0XGEsZhUlNnqygAeK7F8ZQ= +go.opentelemetry.io/collector/internal/sharedcomponent v0.160.0 h1:Dl/jBWtkLzxcvDS8bzG+AUkclIGe4iamuS4idMyBh+0= +go.opentelemetry.io/collector/internal/sharedcomponent v0.160.0/go.mod h1:IXxPebVhnIOw8yGW8mcb9PbdHdQk2C+MSkaR4FIt+20= +go.opentelemetry.io/collector/internal/telemetry v0.160.0 h1:0fGdfaFpbutckKY2wjORLLXpyr6lq1pKuE2CaKwBHtQ= +go.opentelemetry.io/collector/internal/telemetry v0.160.0/go.mod h1:pxJvzK/LVuTjxgeOwzgtXWQaiwYRe7gYlCgxb+EtMsM= +go.opentelemetry.io/collector/internal/testutil v0.160.0 h1:Y6AeI8KjqglQzKYmNoSy9FB2CohuyoYxcLZEXQPW4og= +go.opentelemetry.io/collector/internal/testutil v0.160.0/go.mod h1:FV43FoAsh4fP615Sc5ZSh7iPMgZaSgha6ngavix9OEI= +go.opentelemetry.io/collector/otelcol v0.160.0 h1:pLmS1OVpPJhQqkQbjdkVfc/3DMHh/oWa9DEVmm9C2b4= +go.opentelemetry.io/collector/otelcol v0.160.0/go.mod h1:hYmAcYJ7tqwiabLnqsp1piI7LuHYR7e5qs6NQ6LwyNs= +go.opentelemetry.io/collector/otelcol/otelcoltest v0.160.0 h1:l8JhME/+NJWYT54BMHYoO+soeoO7opcUhRbfF7JeuCw= +go.opentelemetry.io/collector/otelcol/otelcoltest v0.160.0/go.mod h1:tNpfAgSYtitl4C0VkTqJDn9Z1zCkdHmMqcS/4EZksDA= +go.opentelemetry.io/collector/pdata v1.66.0 h1:GvbRMxks0XbmH7UEGvgcauboAgH+DE9xMtgIEGUBENw= +go.opentelemetry.io/collector/pdata v1.66.0/go.mod h1:tdtY5DNoyzgXQmdH7pPNyeM5EiH3dGsKdPC6wUXeigU= +go.opentelemetry.io/collector/pdata/pprofile v0.160.0 h1:+8euxO2GcGN7CRB0wa/ddloU6AWCMjW0vanbwUIQicE= +go.opentelemetry.io/collector/pdata/pprofile v0.160.0/go.mod h1:8lEJ1b9wLMl9nqlGdwXdXEp4a1eGWzkJ85E1HgvdXnE= +go.opentelemetry.io/collector/pdata/testdata v0.160.0 h1:yFq91t7uXtAoa/S6DvWf8RyT67yzrX4tOp9xHiyYftM= +go.opentelemetry.io/collector/pdata/testdata v0.160.0/go.mod h1:GOlN4aeYFhx8Z1d5ujhJlckwcs4L+Iy++2SxDlbWGGE= +go.opentelemetry.io/collector/pdata/xpdata v0.160.0 h1:e+Ou0lVvDjLYTFmFAYAf6l2TjmgH2Dx0s8y8sDYKCPU= +go.opentelemetry.io/collector/pdata/xpdata v0.160.0/go.mod h1:7cVHYDrt9W4nyKHSn7eh0y/1VMgE+8YXWOQAD9c2xPU= +go.opentelemetry.io/collector/pipeline v1.66.0 h1:wh7bydw4VXE/8wkO3Vq7msTSdCWciE5/y/HxXOwjN34= +go.opentelemetry.io/collector/pipeline v1.66.0/go.mod h1:4S7iD/7hGDNXg4yPi+5es5WTvwo/Uie2OoHio79xokI= +go.opentelemetry.io/collector/pipeline/xpipeline v0.160.0 h1:LU9e57Ozxm95wfaiIColv2Nyx+HcPVBYj7pmuCmNxLM= +go.opentelemetry.io/collector/pipeline/xpipeline v0.160.0/go.mod h1:i2/f6k2N1OxmIn8LXRYiE2ZNeHrYat/A99JOTuBwGS4= +go.opentelemetry.io/collector/processor v1.66.0 h1:oNsicM3SYORpR9IzqHD8rnMpasuXIOI2o3Qdv7PHqUA= +go.opentelemetry.io/collector/processor v1.66.0/go.mod h1:L/McJZAaCC1LtkDS9SXd65SL7fGV2MrI8sK6CvisOFY= +go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.160.0 h1:fu4qPOeEaje83SxD+xO0tcuOcA2vlcF/qbjN0LT0T94= +go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.160.0/go.mod h1:0Dg2X+MCpap9W9IG49e+88pPdB3z2BErgHK3eU86NFg= +go.opentelemetry.io/collector/processor/processorhelper v0.160.0 h1:ziKABIE/BEQ9hkivOUEpjKYC5hDPFwylKiBwQqvOfmA= +go.opentelemetry.io/collector/processor/processorhelper v0.160.0/go.mod h1:FIyFZDYU08OcYonDgY9HYYpKOfqqyRZI+jysEr/xegI= +go.opentelemetry.io/collector/processor/processorhelper/xprocessorhelper v0.160.0 h1:3EW51VSdk0tb03qOyE9v/TAUyzNIaFlA9pR9cbjpk2I= +go.opentelemetry.io/collector/processor/processorhelper/xprocessorhelper v0.160.0/go.mod h1:Q+dHxJMKATD30zbry6X/xV9Tp4lFpiSLiHCfNZmZtzU= +go.opentelemetry.io/collector/processor/processortest v0.160.0 h1:UpuV/guqpBd4TD8J3yim7pbOGO4tze1RSfu3/tEq598= +go.opentelemetry.io/collector/processor/processortest v0.160.0/go.mod h1:dZHObCxq4fFslWISI2fGK8vLZ+aQFyQv7h1d+5Wv6nE= +go.opentelemetry.io/collector/processor/xprocessor v0.160.0 h1:3bvK03H90nsPjS/Lh0XDaXm1/vGOxUCdNNZ/HPW4OcI= +go.opentelemetry.io/collector/processor/xprocessor v0.160.0/go.mod h1:21hAhrW9b7fsmqVg3mMPS9knPpjJ/EFMx8sKjPXQ6qU= +go.opentelemetry.io/collector/receiver v1.66.0 h1:H7BSRnT/N3aeCbibDNLGf/onHvIxB5xVfzB8UKNVqEQ= +go.opentelemetry.io/collector/receiver v1.66.0/go.mod h1:IwJHhJvBUShZdKIjMpy0zN4XontHofXNlkaCQOO54m8= +go.opentelemetry.io/collector/receiver/otlpreceiver v0.160.0 h1:Gh0lRFx05mLn1w2FJ4s+zvz+IDGGDL+ft0QTVNUnJR4= +go.opentelemetry.io/collector/receiver/otlpreceiver v0.160.0/go.mod h1:HvMfYAvq4ucAmfPoPrQfsfJb79R4D0Ueh4NNJEVapZw= +go.opentelemetry.io/collector/receiver/receiverhelper v0.160.0 h1:2MZMpojwjbtGgnBBErENw+9FSa+k4jl/CRwyNjfBmhc= +go.opentelemetry.io/collector/receiver/receiverhelper v0.160.0/go.mod h1:51EaCn3C9/7/FBb8zt/Hwh5cHBv/zC1ApglYt9tCkPU= +go.opentelemetry.io/collector/receiver/receivertest v0.160.0 h1:MJn7ty7+ojP704ZCbAm/EblHcPbnsgeK/0sYvJjf+a4= +go.opentelemetry.io/collector/receiver/receivertest v0.160.0/go.mod h1:sAfx7MEXf3tRmiJeoU9ytG8t3aTssdqUutP8lmGWYCQ= +go.opentelemetry.io/collector/receiver/xreceiver v0.160.0 h1:t6waOE3J5K4ay26zOclwzogxYZVkM8o/iAASxIi/UpU= +go.opentelemetry.io/collector/receiver/xreceiver v0.160.0/go.mod h1:D+nmhhnDducIDx1EszxkHIeQ3LfqSUwAGm/UcPniI5s= +go.opentelemetry.io/collector/service v0.160.0 h1:jHbuxxCfxRRimPqkdjQ3UyJgiyukTLZJ9Y8a/H2BAAc= +go.opentelemetry.io/collector/service v0.160.0/go.mod h1:W8vGxjIpTdHEv4xVkQIarKTOJMoXXHqziFLaBQxxqXs= +go.opentelemetry.io/collector/service/hostcapabilities v0.160.0 h1:XgTV+MbgOHdwfoR/AY53n3OmxBAoo6NoRw2FE145QQw= +go.opentelemetry.io/collector/service/hostcapabilities v0.160.0/go.mod h1:AR4zKEimC8dT6qqWIiCcUxk7XL+O7wpIU6rrnhO1nV8= +go.opentelemetry.io/collector/service/telemetry/telemetrytest v0.160.0 h1:cgse3g1EmsjZ2ItYjCV3H45B91xGBDtbA85NqWf4QLc= +go.opentelemetry.io/collector/service/telemetry/telemetrytest v0.160.0/go.mod h1:lA7gscAbNydq2zaMITROoJdBC27G+WLBk3JcgjWA8ro= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 h1:wgsHT2HLf1KEZtCkd6ZGynPdeIyFsCSKsHyDBW9vEJk= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0/go.mod h1:NZCU/Hi3EdSS6LxcNJOO39Y5z95R9QY5iQ2Ym6IOFTU= +go.opentelemetry.io/contrib/detectors/aws/ecs v1.45.0 h1:SIHXqRfox1Uz20yN0mtAidKU7SpMY1F08Kne52W5lIs= +go.opentelemetry.io/contrib/detectors/aws/ecs v1.45.0/go.mod h1:RkxcEsr+yajGeP6Q876PZq5ELVZxtfhbpogowN3xIuE= +go.opentelemetry.io/contrib/detectors/aws/eks v1.45.0 h1:nsQvMufr0boocNMXihxeeTCVvztsfO7web525q6jITQ= +go.opentelemetry.io/contrib/detectors/aws/eks v1.45.0/go.mod h1:Pu3y0mDdUbAx68Q7xrNEo8q2/1CRk4aY64YNIBEcXbU= +go.opentelemetry.io/contrib/detectors/azure/azurevm v0.17.0 h1:ySRed/MgKSYjGWkrlR1KiSRUBpe3RovCbhk+w/RM8as= +go.opentelemetry.io/contrib/detectors/azure/azurevm v0.17.0/go.mod h1:UUY5lMDJ3KcIuj/9GY2zLx8JUYx+Yrjh8t2igQJFh8E= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0 h1:9jR0ZPRok9ryaOQ2Wx8rg5F7Aon59mxrqbVI60/vlBk= +go.opentelemetry.io/contrib/detectors/gcp v1.45.0/go.mod h1:VSme3o2fvSg5bVg0dRzyHaj4Z5EVhG+g2Fde6LKzmQA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/contrib/otelconf v0.25.0 h1:rbVGzm5OsVQhdnjqv2hvKuJhVTVHux9Ltra+N4FKy7M= +go.opentelemetry.io/contrib/otelconf v0.25.0/go.mod h1:2Pf2b8pJDpLtymJ9Sv0z0ANPAxDpZ7QLezLdIKNusww= +go.opentelemetry.io/contrib/propagators/autoprop v0.70.0 h1:yNNN177cOlxAJ5F8l1YKiD6rJk9GOUi/HnRQbI83DeQ= +go.opentelemetry.io/contrib/propagators/autoprop v0.70.0/go.mod h1:6dIm7zAgfmLdrSmO7TWOnZ/l2naqO5qTkD5PuIa0FLY= +go.opentelemetry.io/contrib/propagators/aws v1.45.0 h1:XIsTznOtglVtajrcqKOfKJzMJtC6GsNYw7kWsnPPB8g= +go.opentelemetry.io/contrib/propagators/aws v1.45.0/go.mod h1:VL8mj7NKnMqLp0jn45wtWgKkcTacucgvBIJoOg2rZHw= +go.opentelemetry.io/contrib/propagators/b3 v1.45.0 h1:audI5r8RmWVSORhzA5Y57yGvEA1358PvGk0u0sMOTDA= +go.opentelemetry.io/contrib/propagators/b3 v1.45.0/go.mod h1:SiENIek0FnzLni3/jSCiumyCA2mwP8uGaE1686SOJug= +go.opentelemetry.io/contrib/propagators/jaeger v1.45.0 h1:e8U4utKt9oV2TfLKZFqUzz5shYKnUf3DISalTpLs4lA= +go.opentelemetry.io/contrib/propagators/jaeger v1.45.0/go.mod h1:lx91c/ZlmgS2rjGOuXB+Mmq+f0QxzC9UjYUuJwR4tvQ= +go.opentelemetry.io/contrib/propagators/ot v1.45.0 h1:BLFjHG1OjCEDaBk4os2+X1D6/uEhZxSY9jVUxmG7S+U= +go.opentelemetry.io/contrib/propagators/ot v1.45.0/go.mod h1:mGksO7kOmOSsRGbVA28x7kHNL4YrH5uJoTNuws70NDU= +go.opentelemetry.io/contrib/zpages v0.70.0 h1:uBcclHekIrCRLO4KZfXiebDcWB52QQXaHGgvhUJ1K5I= +go.opentelemetry.io/contrib/zpages v0.70.0/go.mod h1:Nz9A1+68HVoJrk+cg92zJbcbHFAa+Jbl1uINwAlcK94= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 h1:klTViGcsvLCd1xN3rZzfZ12NslC/OimbmR+k+A006RI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0/go.mod h1:jRsK04CWmXuY8A0O+wMpSf+t90RHZ53o5Qmxn2PQPfk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= +go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= +go.opentelemetry.io/otel/log/logtest v0.21.0 h1:/Zr/0DoraAjiX91pZMn72uSDkd7hA+jn3CPU2y+2rWY= +go.opentelemetry.io/otel/log/logtest v0.21.0/go.mod h1:dyswW/l7aXiiCAmbKlt+Eg2NUnN6p1YrHQPBiV7QrLU= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.opentelemetry.io/proto/slim/otlp v1.11.0 h1:zB37f+f99+y6UIZR4h7UpwbXd5kFNyip35U7GaJ/Jik= +go.opentelemetry.io/proto/slim/otlp v1.11.0/go.mod h1:mI3DeND+VXZuA4keqFPKDJ3BklwveYm1JqBcEWKDEOM= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.4.0 h1:mt+DWtks0biKnz0jXMpDbxWN0CHJi6OJDKe4GcREkcs= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.4.0/go.mod h1:7UXaX/7uT+kumUHd3LIWyjMlklEp0mPlrE9xmtbG6/8= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.4.0 h1:rLHkdB6eHDiRSIoz0cvNuTJsVJBxaL6IyS1e9BSaXLY= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.4.0/go.mod h1:BrX0dmOGsMuWNXXbFafTD7Gb6F3yK+2czVQ6+c24Cnk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.4 h1:RxrvqCL6vgH5/+UnTeu1IIFqYmGfy0hnyrod1rn35Oo= +k8s.io/api v0.36.4/go.mod h1:S2B3orCFBDhrgyWbLeuKcT2QdHIpQesBkCYSlWtwUOw= +k8s.io/apimachinery v0.36.4 h1:PT2UzkupGuAx/+xT5XjiMJ1WGpY3fn9/hdAvjweRet4= +k8s.io/apimachinery v0.36.4/go.mod h1:p2I2dipt7JHG+quVwQ1d02d28O4GdDi77RByQ13MTpk= +k8s.io/client-go v0.36.4 h1:MDvfDNvMSt0Br94SK8neviVlwL9qifw9B26hJCpD1K0= +k8s.io/client-go v0.36.4/go.mod h1:pNK4WKELbwlEDvtbE8l22lEZL5THYF61H5EealokZmA= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/projects/openshell-exporter/http.config.yaml b/projects/openshell-exporter/http.config.yaml new file mode 100644 index 00000000..9b57192c --- /dev/null +++ b/projects/openshell-exporter/http.config.yaml @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +# SPDX-License-Identifier: Apache-2.0 +extensions: + file_storage/checkpoints: + directory: ./state/checkpoints + create_directory: true + bearertokenauth/destination: + filename: ./secrets/destination-token + file_storage/cloudevents_queue: + directory: ./state/cloudevents-queue + create_directory: true + storagehealth: + paths: + cloudevents_queue: ./state/cloudevents-queue + checkpoints: ./state/checkpoints + recovery: ./output/events.json + health_check: + endpoint: ${env:EXPORTER_HEALTH_ENDPOINT:-127.0.0.1:13133} + +receivers: + file_log/ocsf: + include: [./input/*.jsonl, ./input/sandboxes/*/openshell-ocsf.*.log] + start_at: beginning + storage: file_storage/checkpoints + retry_on_failure: + enabled: true + max_elapsed_time: 0s + include_file_path: true + include_file_path_resolved: true + include_file_record_offset: true + include_file_record_number: true + on_truncate: read_whole_file + max_log_size: 8MiB + max_log_size_behavior: split + attributes: + cloudevents.type: com.nvidia.openshell.ocsf.v1 + cloudevents.source: openshell://local-gateway/default + openshell.acquisition.kind: ocsf.file + openshell.acquisition.source_instance: sandbox-ocsf-jsonl + operators: + - type: json_parser + parse_from: body + parse_to: body + on_error: send + +processors: + memory_limiter: + check_interval: 1s + limit_mib: 256 + spike_limit_mib: 64 + +# Raw OCSF delivery: this pipeline deliberately omits openshell normalization. +# Source records are not redacted. Use config.yaml for normalized recovery output. +exporters: + cloudevents: + endpoint: ${env:EXPORTER_DESTINATION_ENDPOINT} + auth: + authenticator: bearertokenauth/destination + tls: + ca_file: ./secrets/destination-ca.pem + default_source: openshell://local-gateway/default + retry_on_failure: + enabled: true + max_elapsed_time: 0s + sending_queue: + enabled: true + storage: file_storage/cloudevents_queue + queue_size: 1000 + num_consumers: 1 + block_on_overflow: true + file/recovery: + path: ./output/events.json + format: json + append: true + +service: + telemetry: + logs: + encoding: json + metrics: + level: none + extensions: [file_storage/checkpoints, file_storage/cloudevents_queue, bearertokenauth/destination, storagehealth, health_check] + pipelines: + logs: + receivers: [file_log/ocsf] + processors: [memory_limiter] + exporters: [cloudevents, file/recovery] diff --git a/projects/openshell-exporter/processor/evidencecontractprocessor/config.go b/projects/openshell-exporter/processor/evidencecontractprocessor/config.go new file mode 100644 index 00000000..1ce41e6f --- /dev/null +++ b/projects/openshell-exporter/processor/evidencecontractprocessor/config.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package evidencecontractprocessor + +import ( + "fmt" + "regexp" +) + +const ( + modeStamp = "stamp" + modeVerify = "verify" + versionV1 = "1.0" +) + +var tenantIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + +// Config controls the internal edge-to-central hand-off contract. +type Config struct { + Mode string `mapstructure:"mode"` + ContractVersion string `mapstructure:"contract_version"` + TenantID string `mapstructure:"tenant_id"` +} + +func (cfg *Config) Validate() error { + if cfg.Mode != modeStamp && cfg.Mode != modeVerify { + return fmt.Errorf("mode must be %q or %q, got %q", modeStamp, modeVerify, cfg.Mode) + } + if cfg.ContractVersion != versionV1 { + return fmt.Errorf("contract_version must be %q, got %q", versionV1, cfg.ContractVersion) + } + if !tenantIDPattern.MatchString(cfg.TenantID) { + return fmt.Errorf("tenant_id must match %s", tenantIDPattern) + } + return nil +} diff --git a/projects/openshell-exporter/processor/evidencecontractprocessor/factory.go b/projects/openshell-exporter/processor/evidencecontractprocessor/factory.go new file mode 100644 index 00000000..70246132 --- /dev/null +++ b/projects/openshell-exporter/processor/evidencecontractprocessor/factory.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package evidencecontractprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" +) + +var componentType = component.MustNewType("evidencecontract") + +func NewFactory() processor.Factory { + return processor.NewFactory( + componentType, + createDefaultConfig, + processor.WithLogs(createLogsProcessor, component.StabilityLevelAlpha), + processor.WithTraces(createTracesProcessor, component.StabilityLevelAlpha), + ) +} + +func createDefaultConfig() component.Config { + return &Config{Mode: modeVerify, ContractVersion: versionV1, TenantID: "default"} +} + +func createLogsProcessor(ctx context.Context, settings processor.Settings, cfg component.Config, next consumer.Logs) (processor.Logs, error) { + implementation, err := newProcessor(cfg.(*Config), settings.Logger, settings.MeterProvider) + if err != nil { + return nil, err + } + return processorhelper.NewLogs( + ctx, settings, cfg, next, implementation.processLogs, + processorhelper.WithCapabilities(consumer.Capabilities{MutatesData: implementation.config.Mode == modeStamp}), + ) +} + +func createTracesProcessor(ctx context.Context, settings processor.Settings, cfg component.Config, next consumer.Traces) (processor.Traces, error) { + implementation, err := newProcessor(cfg.(*Config), settings.Logger, settings.MeterProvider) + if err != nil { + return nil, err + } + return processorhelper.NewTraces( + ctx, settings, cfg, next, implementation.processTraces, + processorhelper.WithCapabilities(consumer.Capabilities{MutatesData: implementation.config.Mode == modeStamp}), + ) +} diff --git a/projects/openshell-exporter/processor/evidencecontractprocessor/processor.go b/projects/openshell-exporter/processor/evidencecontractprocessor/processor.go new file mode 100644 index 00000000..02de630c --- /dev/null +++ b/projects/openshell-exporter/processor/evidencecontractprocessor/processor.go @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package evidencecontractprocessor + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "strings" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/pdata/ptrace" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap" +) + +const ( + contractVersionAttribute = "openshell.exporter.contract.version" + contractStageAttribute = "openshell.exporter.contract.stage" + contractSignalAttribute = "openshell.exporter.contract.signal" + contractProducerAttribute = "openshell.exporter.contract.producer" + tenantIDAttribute = "openshell.exporter.tenant.id" + shardKeyAttribute = "openshell.exporter.shard.key" + shardVersionAttribute = "openshell.exporter.shard.version" + + contractProducer = "openshell-event-exporter" + logStage = "normalized_redacted" + traceStage = "privacy_filtered" + shardVersion = "1" +) + +type contractMetrics struct { + accepted metric.Int64Counter + rejected metric.Int64Counter +} + +type processorImpl struct { + config *Config + logger *zap.Logger + metrics contractMetrics +} + +func newProcessor(config *Config, logger *zap.Logger, provider metric.MeterProvider) (*processorImpl, error) { + if err := config.Validate(); err != nil { + return nil, err + } + if provider == nil { + provider = noop.NewMeterProvider() + } + meter := provider.Meter("github.com/NVIDIA-dev/OpenShell-exporter/evidencecontract") + accepted, err := meter.Int64Counter( + "openshell.exporter.internal_contract.accepted", + metric.WithDescription("Internal edge-to-central batches accepted by signal and role."), + metric.WithUnit("{batch}"), + ) + if err != nil { + return nil, fmt.Errorf("create accepted contract counter: %w", err) + } + rejected, err := meter.Int64Counter( + "openshell.exporter.internal_contract.rejected", + metric.WithDescription("Internal edge-to-central batches rejected by signal and bounded reason."), + metric.WithUnit("{batch}"), + ) + if err != nil { + return nil, fmt.Errorf("create rejected contract counter: %w", err) + } + return &processorImpl{ + config: config, + logger: logger, + metrics: contractMetrics{accepted: accepted, rejected: rejected}, + }, nil +} + +func (p *processorImpl) processLogs(ctx context.Context, logs plog.Logs) (plog.Logs, error) { + if p.config.Mode == modeStamp { + for resourceIndex := 0; resourceIndex < logs.ResourceLogs().Len(); resourceIndex++ { + resourceLogs := logs.ResourceLogs().At(resourceIndex) + stampResource(resourceLogs.Resource().Attributes(), p.config.ContractVersion, p.config.TenantID, "logs", logStage) + for scopeIndex := 0; scopeIndex < resourceLogs.ScopeLogs().Len(); scopeIndex++ { + records := resourceLogs.ScopeLogs().At(scopeIndex).LogRecords() + for recordIndex := 0; recordIndex < records.Len(); recordIndex++ { + record := records.At(recordIndex) + key, reason := logShardKey(record.Body(), p.config.TenantID) + if reason != "" { + return logs, p.reject(ctx, "logs", reason) + } + record.Attributes().PutStr(shardKeyAttribute, key) + } + } + } + p.recordAccepted(ctx, "logs") + return logs, nil + } + if reason := verifyLogs(logs, p.config.ContractVersion, p.config.TenantID); reason != "" { + return logs, p.reject(ctx, "logs", reason) + } + p.recordAccepted(ctx, "logs") + return logs, nil +} + +func (p *processorImpl) processTraces(ctx context.Context, traces ptrace.Traces) (ptrace.Traces, error) { + if p.config.Mode == modeStamp { + for i := 0; i < traces.ResourceSpans().Len(); i++ { + resource := traces.ResourceSpans().At(i).Resource().Attributes() + stampResource(resource, p.config.ContractVersion, p.config.TenantID, "traces", traceStage) + key, reason := traceShardKey(resource, p.config.TenantID) + if reason != "" { + return traces, p.reject(ctx, "traces", reason) + } + resource.PutStr(shardKeyAttribute, key) + } + p.recordAccepted(ctx, "traces") + return traces, nil + } + if reason := verifyTraces(traces, p.config.ContractVersion, p.config.TenantID); reason != "" { + return traces, p.reject(ctx, "traces", reason) + } + p.recordAccepted(ctx, "traces") + return traces, nil +} + +func stampResource(attributes pcommon.Map, version, tenantID, signal, stage string) { + attributes.PutStr(contractVersionAttribute, version) + attributes.PutStr(contractStageAttribute, stage) + attributes.PutStr(contractSignalAttribute, signal) + attributes.PutStr(contractProducerAttribute, contractProducer) + attributes.PutStr(tenantIDAttribute, tenantID) + attributes.PutStr(shardVersionAttribute, shardVersion) +} + +func verifyLogs(logs plog.Logs, version, tenantID string) string { + if logs.ResourceLogs().Len() == 0 { + return "empty_batch" + } + for resourceIndex := 0; resourceIndex < logs.ResourceLogs().Len(); resourceIndex++ { + resourceLogs := logs.ResourceLogs().At(resourceIndex) + if reason := verifyResource(resourceLogs.Resource().Attributes(), version, tenantID, "logs", logStage); reason != "" { + return reason + } + for scopeIndex := 0; scopeIndex < resourceLogs.ScopeLogs().Len(); scopeIndex++ { + records := resourceLogs.ScopeLogs().At(scopeIndex).LogRecords() + for recordIndex := 0; recordIndex < records.Len(); recordIndex++ { + record := records.At(recordIndex) + if reason := verifyEnvelope(record.Body()); reason != "" { + return reason + } + want, reason := logShardKey(record.Body(), tenantID) + if reason != "" { + return reason + } + if !exactString(record.Attributes(), shardKeyAttribute, want) { + return "invalid_shard_key" + } + } + } + } + return "" +} + +func verifyTraces(traces ptrace.Traces, version, tenantID string) string { + if traces.ResourceSpans().Len() == 0 { + return "empty_batch" + } + for i := 0; i < traces.ResourceSpans().Len(); i++ { + resource := traces.ResourceSpans().At(i).Resource().Attributes() + if reason := verifyResource(resource, version, tenantID, "traces", traceStage); reason != "" { + return reason + } + for _, key := range []string{"telemetry.source", "openshell.gateway.id", "openshell.workspace"} { + if !hasNonEmptyString(resource, key) { + return "missing_trace_context" + } + } + want, reason := traceShardKey(resource, tenantID) + if reason != "" { + return reason + } + if !exactString(resource, shardKeyAttribute, want) { + return "invalid_shard_key" + } + } + return "" +} + +func verifyResource(attributes pcommon.Map, version, tenantID, signal, stage string) string { + checks := map[string]string{ + contractVersionAttribute: version, + contractStageAttribute: stage, + contractSignalAttribute: signal, + contractProducerAttribute: contractProducer, + tenantIDAttribute: tenantID, + shardVersionAttribute: shardVersion, + } + for key, want := range checks { + if !exactString(attributes, key, want) { + return "invalid_marker" + } + } + return "" +} + +func logShardKey(body pcommon.Value, tenantID string) (string, string) { + if body.Type() != pcommon.ValueTypeMap { + return "", "invalid_envelope" + } + openshellValue, ok := body.Map().Get("openshell") + if !ok || openshellValue.Type() != pcommon.ValueTypeMap { + return "", "invalid_envelope" + } + openshell := openshellValue.Map() + gatewayID, ok := mapString(openshell, "gateway_id") + if !ok { + return "", "missing_shard_context" + } + workspace, ok := mapString(openshell, "workspace") + if !ok { + return "", "missing_shard_context" + } + sandboxID, _ := mapString(openshell, "sandbox_id") + return deterministicShardKey(tenantID, gatewayID, workspace, sandboxID), "" +} + +func traceShardKey(resource pcommon.Map, tenantID string) (string, string) { + gatewayID, ok := mapString(resource, "openshell.gateway.id") + if !ok { + return "", "missing_shard_context" + } + workspace, ok := mapString(resource, "openshell.workspace") + if !ok { + return "", "missing_shard_context" + } + sandboxID, _ := mapString(resource, "openshell.sandbox.id") + return deterministicShardKey(tenantID, gatewayID, workspace, sandboxID), "" +} + +func deterministicShardKey(values ...string) string { + digest := sha256.Sum256([]byte(strings.Join(values, "\x00"))) + return fmt.Sprintf("sha256:%x", digest) +} + +func verifyEnvelope(body pcommon.Value) string { + if body.Type() != pcommon.ValueTypeMap { + return "invalid_envelope" + } + envelope := body.Map() + if value, ok := envelope.Get("schema_version"); !ok || value.Type() != pcommon.ValueTypeStr || value.Str() != versionV1 { + return "invalid_envelope" + } + if !hasNonEmptyString(envelope, "observed_time") { + return "invalid_envelope" + } + for _, key := range []string{"acquisition", "openshell", "security", "correlation"} { + value, ok := envelope.Get(key) + if !ok || value.Type() != pcommon.ValueTypeMap { + return "invalid_envelope" + } + } + // Malformed non-object source records are valid evidence. The normalizer + // retains them as a recursively redacted scalar under original. + if _, ok := envelope.Get("original"); !ok { + return "invalid_envelope" + } + security, _ := envelope.Get("security") + for _, key := range []string{"validation", "redaction"} { + value, ok := security.Map().Get(key) + if !ok || value.Type() != pcommon.ValueTypeMap { + return "invalid_envelope" + } + } + return "" +} + +func mapString(attributes pcommon.Map, key string) (string, bool) { + value, ok := attributes.Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr || value.Str() == "" { + return "", false + } + return value.Str(), true +} + +func exactString(attributes pcommon.Map, key, want string) bool { + value, ok := attributes.Get(key) + return ok && value.Type() == pcommon.ValueTypeStr && value.Str() == want +} + +func hasNonEmptyString(attributes pcommon.Map, key string) bool { + _, ok := mapString(attributes, key) + return ok +} + +func (p *processorImpl) recordAccepted(ctx context.Context, signal string) { + p.metrics.accepted.Add(ctx, 1, metric.WithAttributes( + attribute.String("signal", signal), + attribute.String("mode", p.config.Mode), + )) +} + +func (p *processorImpl) reject(ctx context.Context, signal, reason string) error { + p.metrics.rejected.Add(ctx, 1, metric.WithAttributes( + attribute.String("signal", signal), + attribute.String("reason", reason), + )) + p.logger.Error("rejected evidence at internal role boundary", + zap.String("signal", signal), + zap.String("reason", reason), + zap.String("contract_version", p.config.ContractVersion), + ) + // Keep rejection retryable so an edge persistent queue never acknowledges + // evidence that the central role cannot prove was safely processed. + return errors.New("internal evidence contract rejected: " + reason) +} diff --git a/projects/openshell-exporter/processor/evidencecontractprocessor/processor_test.go b/projects/openshell-exporter/processor/evidencecontractprocessor/processor_test.go new file mode 100644 index 00000000..e5274921 --- /dev/null +++ b/projects/openshell-exporter/processor/evidencecontractprocessor/processor_test.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package evidencecontractprocessor + +import ( + "context" + "reflect" + "strings" + "testing" + + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/pdata/ptrace" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/zap/zaptest" +) + +func TestLogsRoundTripThroughInternalContract(t *testing.T) { + logs := validLogs() + stamp, err := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + if err != nil { + t.Fatal(err) + } + verify, err := newProcessor(testConfig(modeVerify), zaptest.NewLogger(t), noop.NewMeterProvider()) + if err != nil { + t.Fatal(err) + } + _, err = stamp.processLogs(context.Background(), logs) + if err != nil { + t.Fatal(err) + } + bodyBefore := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Body().AsRaw() + logs, err = verify.processLogs(context.Background(), logs) + if err != nil { + t.Fatal(err) + } + bodyAfter := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Body().AsRaw() + if !reflect.DeepEqual(bodyBefore, bodyAfter) { + t.Fatal("verification mutated the public evidence envelope") + } +} + +func TestVerifyLogsRejectsRawOrIncompleteEvidence(t *testing.T) { + verify, err := newProcessor(testConfig(modeVerify), zaptest.NewLogger(t), noop.NewMeterProvider()) + if err != nil { + t.Fatal(err) + } + if _, err := verify.processLogs(context.Background(), validLogs()); err == nil { + t.Fatal("raw logs without an edge marker were accepted") + } + logs := validLogs() + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + logs, _ = stamp.processLogs(context.Background(), logs) + logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Body().Map().Remove("security") + if _, err := verify.processLogs(context.Background(), logs); err == nil { + t.Fatal("incomplete normalized envelope was accepted") + } +} + +func TestVerifyLogsAcceptsRetainedMalformedScalar(t *testing.T) { + logs := validLogs() + body := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Body().Map() + body.PutStr("original", "malformed secret=[REDACTED]") + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + logs, err := stamp.processLogs(context.Background(), logs) + if err != nil { + t.Fatal(err) + } + verify, _ := newProcessor(testConfig(modeVerify), zaptest.NewLogger(t), noop.NewMeterProvider()) + if _, err := verify.processLogs(context.Background(), logs); err != nil { + t.Fatalf("retained malformed scalar was rejected: %v", err) + } +} + +func TestTracesRoundTripAndRequireNormalizedContext(t *testing.T) { + traces := validTraces() + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + verify, _ := newProcessor(testConfig(modeVerify), zaptest.NewLogger(t), noop.NewMeterProvider()) + traces, err := stamp.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + if _, err := verify.processTraces(context.Background(), traces); err != nil { + t.Fatal(err) + } + traces.ResourceSpans().At(0).Resource().Attributes().Remove("telemetry.source") + if _, err := verify.processTraces(context.Background(), traces); err == nil { + t.Fatal("trace without bounded source context was accepted") + } +} + +func TestConfigRejectsUnknownModesVersionsAndTenants(t *testing.T) { + for _, config := range []Config{ + {Mode: "bypass", ContractVersion: versionV1, TenantID: "tenant-a"}, + {Mode: modeVerify, ContractVersion: "2.0", TenantID: "tenant-a"}, + {Mode: modeVerify, ContractVersion: versionV1, TenantID: "Tenant A"}, + } { + if err := config.Validate(); err == nil { + t.Fatalf("invalid config accepted: %+v", config) + } + } +} + +func TestShardIdentityIsStableAndTenantScoped(t *testing.T) { + logs := validLogs() + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + logs, err := stamp.processLogs(context.Background(), logs) + if err != nil { + t.Fatal(err) + } + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + value, ok := record.Attributes().Get(shardKeyAttribute) + if !ok || !strings.HasPrefix(value.Str(), "sha256:") || len(value.Str()) != 71 { + t.Fatalf("unexpected shard key %q", value.Str()) + } + want := value.Str() + _, err = stamp.processLogs(context.Background(), logs) + if err != nil { + t.Fatal(err) + } + got, _ := record.Attributes().Get(shardKeyAttribute) + if got.Str() != want { + t.Fatal("replay changed the deterministic shard key") + } + otherTenant := deterministicShardKey("tenant-b", "gateway-a", "default", "sandbox-a") + if otherTenant == want { + t.Fatal("tenant boundary did not affect shard identity") + } +} + +func TestLogAndTraceForSandboxShareShardIdentity(t *testing.T) { + logs := validLogs() + traces := validTraces() + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + logs, _ = stamp.processLogs(context.Background(), logs) + traces, _ = stamp.processTraces(context.Background(), traces) + logKey, _ := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Attributes().Get(shardKeyAttribute) + traceKey, _ := traces.ResourceSpans().At(0).Resource().Attributes().Get(shardKeyAttribute) + if logKey.Str() != traceKey.Str() { + t.Fatalf("same sandbox routed to different shards: log=%s trace=%s", logKey.Str(), traceKey.Str()) + } +} + +func TestVerifyRejectsTenantAndShardForgery(t *testing.T) { + logs := validLogs() + stamp, _ := newProcessor(testConfig(modeStamp), zaptest.NewLogger(t), noop.NewMeterProvider()) + verify, _ := newProcessor(testConfig(modeVerify), zaptest.NewLogger(t), noop.NewMeterProvider()) + logs, _ = stamp.processLogs(context.Background(), logs) + logs.ResourceLogs().At(0).Resource().Attributes().PutStr(tenantIDAttribute, "tenant-b") + if _, err := verify.processLogs(context.Background(), logs); err == nil { + t.Fatal("forged tenant was accepted") + } + logs, _ = stamp.processLogs(context.Background(), logs) + logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Attributes().PutStr(shardKeyAttribute, "sha256:"+strings.Repeat("0", 64)) + if _, err := verify.processLogs(context.Background(), logs); err == nil { + t.Fatal("forged shard key was accepted") + } +} + +func testConfig(mode string) *Config { + return &Config{Mode: mode, ContractVersion: versionV1, TenantID: "tenant-a"} +} + +func validLogs() plog.Logs { + logs := plog.NewLogs() + record := logs.ResourceLogs().AppendEmpty().ScopeLogs().AppendEmpty().LogRecords().AppendEmpty() + body := record.Body().SetEmptyMap() + body.PutStr("schema_version", versionV1) + body.PutStr("observed_time", "2026-08-28T00:00:00Z") + for _, key := range []string{"acquisition", "openshell", "correlation", "original"} { + body.PutEmptyMap(key) + } + security := body.PutEmptyMap("security") + security.PutEmptyMap("validation") + security.PutEmptyMap("redaction") + openshell, _ := body.Get("openshell") + openshell.Map().PutStr("gateway_id", "gateway-a") + openshell.Map().PutStr("workspace", "default") + openshell.Map().PutStr("sandbox_id", "sandbox-a") + return logs +} + +func validTraces() ptrace.Traces { + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("telemetry.source", "nemo_relay") + resource.PutStr("openshell.gateway.id", "gateway-a") + resource.PutStr("openshell.workspace", "default") + resource.PutStr("openshell.sandbox.id", "sandbox-a") + resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty().SetName("relay.model") + return traces +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/attribute_redaction_test.go b/projects/openshell-exporter/processor/openshellprocessor/attribute_redaction_test.go new file mode 100644 index 00000000..a30884e3 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/attribute_redaction_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "strings" + "testing" + + "go.opentelemetry.io/collector/pdata/plog" + "go.uber.org/zap" +) + +func TestFullLogSerializationRedactsSourceAttributes(t *testing.T) { + logs := testLogs(t, validOCSF()) + resource := logs.ResourceLogs().At(0) + resource.Resource().Attributes().PutStr("authorization", "resource-placeholder") + scope := resource.ScopeLogs().At(0) + scope.Scope().Attributes().PutStr("password", "scope-placeholder") + record := scope.LogRecords().At(0) + record.Attributes().PutStr("authorization", "record-placeholder") + record.Attributes().PutStr("message", "Bearer pattern-placeholder") + record.Attributes().PutEmptySlice("nested").AppendEmpty().SetEmptyMap().PutStr("secret", "nested-placeholder") + record.Attributes().PutStr("keep", "useful-context") + + // Redaction settings must not change the identity calculated from input. + unfiltered := plog.NewLogs() + logs.CopyTo(unfiltered) + config := createDefaultConfig().(*Config) + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + config.Redaction.Keys = nil + config.Redaction.Patterns = nil + baseline, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if _, err := baseline.processLogs(context.Background(), unfiltered); err != nil { + t.Fatal(err) + } + id, _ := record.Attributes().Get("cloudevents.id") + baselineID, _ := unfiltered.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Attributes().Get("cloudevents.id") + if id.Str() == "" || id.Str() != baselineID.Str() { + t.Fatal("redaction changed the source identity") + } + encoded, err := (&plog.JSONMarshaler{}).MarshalLogs(logs) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"resource-placeholder", "scope-placeholder", "record-placeholder", "pattern-placeholder", "nested-placeholder"} { + if strings.Contains(string(encoded), secret) { + t.Errorf("full log serialization retained %s", secret) + } + } + if !strings.Contains(string(encoded), "useful-context") { + t.Fatal("redaction removed non-sensitive context") + } +} + +func TestDerivedSourceSerializationRedactsBeforeEncoding(t *testing.T) { + for _, field := range []string{"gateway", "workspace", "sandbox", "sandbox_body"} { + t.Run(field, func(t *testing.T) { + cfg := createDefaultConfig().(*Config) + logs := testLogs(t, validOCSF()) + rec := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + switch field { + case "gateway": + cfg.GatewayID = "Bearer derived-private-token" + case "workspace": + rec.Attributes().PutStr("openshell.workspace", "Bearer derived-private-token") + case "sandbox": + rec.Attributes().PutStr("openshell.sandbox.id", "Bearer derived-private-token") + case "sandbox_body": + rec.Body().Map().PutStr("sandbox_id", "Bearer derived-private-token") + } + original := plog.NewLogs() + logs.CopyTo(original) + p, err := newProcessor(cfg, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if _, err = p.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + encoded, err := (&plog.JSONMarshaler{}).MarshalLogs(logs) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "derived-private-token") { + t.Error("credential survived in serialized output") + } + cfg.Redaction.Patterns = nil + baseline, err := newProcessor(cfg, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if _, err = baseline.processLogs(context.Background(), original); err != nil { + t.Fatal(err) + } + id, _ := rec.Attributes().Get("cloudevents.id") + prior, _ := original.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Attributes().Get("cloudevents.id") + if id.Str() == "" || id.Str() != prior.Str() { + t.Error("redaction changed identity") + } + }) + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/config.go b/projects/openshell-exporter/processor/openshellprocessor/config.go new file mode 100644 index 00000000..19bbe1f7 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/config.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "errors" + "fmt" +) + +type Config struct { + SourceProfiles []string `mapstructure:"source_profiles"` + GatewayID string `mapstructure:"gateway_id"` + Workspace string `mapstructure:"workspace"` + SourceInstance string `mapstructure:"source_instance"` + DefaultSandboxID string `mapstructure:"default_sandbox_id"` + Validation ValidationConfig `mapstructure:"validation"` + Redaction RedactionConfig `mapstructure:"redaction"` + CorrelationFields []string `mapstructure:"correlation_fields"` +} + +type ValidationConfig struct { + Mode string `mapstructure:"mode"` +} + +var supportedSourceProfiles = []string{ + "ocsf.file", + "ocsf.forwarded", + "openshell.log", + "openshell.log.forwarded", + "watchsandbox", + "policy.reconciliation", + "kubernetes.context", + "nemo_relay.log", + "nemo_relay.trace", + "openshell.trace", +} + +type RedactionConfig struct { + ProfileID string `mapstructure:"profile_id"` + Version string `mapstructure:"version"` + Keys []string `mapstructure:"keys"` + Patterns []string `mapstructure:"patterns"` +} + +func (cfg *Config) Validate() error { + allowedProfiles := make(map[string]struct{}, len(supportedSourceProfiles)) + for _, profile := range supportedSourceProfiles { + allowedProfiles[profile] = struct{}{} + } + if len(cfg.SourceProfiles) == 0 { + return errors.New("source_profiles must declare at least one enabled profile") + } + seenProfiles := map[string]struct{}{} + for _, profile := range cfg.SourceProfiles { + if _, ok := allowedProfiles[profile]; !ok { + return fmt.Errorf("unsupported source profile %q", profile) + } + if _, duplicate := seenProfiles[profile]; duplicate { + return fmt.Errorf("duplicate source profile %q", profile) + } + seenProfiles[profile] = struct{}{} + } + if cfg.GatewayID == "" { + return errors.New("gateway_id must not be empty") + } + if cfg.Workspace == "" { + return errors.New("workspace must not be empty") + } + if cfg.SourceInstance == "" { + return errors.New("source_instance must not be empty") + } + switch cfg.Validation.Mode { + case "mark": + default: + return fmt.Errorf("validation.mode must be mark; dropping evidence is unsupported, got %q", cfg.Validation.Mode) + } + if cfg.Redaction.ProfileID == "" || cfg.Redaction.Version == "" { + return errors.New("redaction profile_id and version must not be empty") + } + return nil +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/envelope.go b/projects/openshell-exporter/processor/openshellprocessor/envelope.go new file mode 100644 index 00000000..65f7ed85 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/envelope.go @@ -0,0 +1,679 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" +) + +func buildEnvelope( + config *Config, + record plog.LogRecord, + kind string, + observed pcommon.Timestamp, + validation validationResult, + redactionCount int, + correlation map[string]any, + original any, + sourceAttributes any, +) map[string]any { + attributes := record.Attributes() + workspace := stringAttribute(record, "openshell.workspace", config.Workspace) + sourceInstance := stringAttribute( + record, + "openshell.acquisition.source_instance", + config.SourceInstance, + ) + sandboxID := stringAttribute( + record, + "openshell.sandbox.id", + sandboxIDFrom(original, config.DefaultSandboxID), + ) + acquisition := map[string]any{ + "kind": kind, + "durability": durability(kind), + "source_instance": sourceInstance, + } + for _, item := range []struct { + attr string + field string + }{ + {attr: "log.file.path", field: "file_path"}, + {attr: "log.file.path_resolved", field: "file_path_resolved"}, + {attr: "log.file.record_offset", field: "record_offset"}, + {attr: "log.file.record_number", field: "record_number"}, + {attr: "openshell.acquisition.transport", field: "transport"}, + {attr: "openshell.acquisition.api_operation", field: "api_operation"}, + {attr: "openshell.acquisition.payload_type", field: "payload_type"}, + {attr: "openshell.acquisition.stream_resumable", field: "stream_resumable"}, + {attr: "openshell.acquisition.follow_status", field: "follow_status"}, + {attr: "openshell.acquisition.follow_logs", field: "follow_logs"}, + {attr: "openshell.acquisition.follow_events", field: "follow_events"}, + {attr: "openshell.acquisition.stop_on_terminal", field: "stop_on_terminal"}, + {attr: "openshell.acquisition.log_since_ms", field: "log_since_ms"}, + {attr: "openshell.acquisition.log_sources", field: "log_sources"}, + {attr: "openshell.acquisition.log_min_level", field: "log_min_level"}, + {attr: "openshell.acquisition.log_tail_lines", field: "log_tail_lines"}, + {attr: "openshell.acquisition.event_tail", field: "event_tail"}, + {attr: "openshell.acquisition.reconciliation_trigger", field: "reconciliation_trigger"}, + {attr: "openshell.acquisition.notification_draft_version", field: "notification_draft_version"}, + {attr: "openshell.acquisition.consistency", field: "consistency"}, + } { + if value, ok := attributes.Get(item.attr); ok { + acquisition[item.field] = value.AsRaw() + } + } + if isFileEvidence(kind) { + acquisition["transport"] = "file" + acquisition["payload_type"] = "source_record" + } + acquisition["coverage"] = acquisitionCoverage(kind, attributes) + switch { + case isSourceCapability(kind): + acquisition["transport"] = "otlp_http" + acquisition["replay_limitations"] = + "topology diagnostic is re-emitted with the same stable identity when the forwarder restarts" + case isForwardedFile(kind): + acquisition["transport"] = "otlp_http" + acquisition["replay_limitations"] = + "checkpoint and file ownership remain with the authorized forwarder" + case isPolicyReconciliationKind(kind): + acquisition["replay_limitations"] = + "authoritative read snapshot with no event cursor; file_storage suppresses unchanged snapshots and downstream source+id deduplicates replay" + case !isFileEvidence(kind) && !isKubernetesContext(kind): + acquisition["replay_limitations"] = + "non-resumable stream; configured tails reduce but do not eliminate gaps" + } + + openshell := map[string]any{ + "gateway_id": config.GatewayID, + "workspace": workspace, + "sandbox_id": sandboxID, + } + copyStringAttribute( + attributes, + openshell, + "openshell.gateway.version", + "gateway_version", + ) + copyStringAttribute( + attributes, + openshell, + "openshell.sandbox.name", + "sandbox_name", + ) + copyAttribute(attributes, openshell, "openshell.policy.version", "policy_version") + copyAttribute(attributes, openshell, "openshell.policy.revision", "policy_revision") + correlation = canonicalCorrelationContext( + correlation, + config.GatewayID, + workspace, + sandboxID, + attributes, + ) + + security := map[string]any{ + "validation": map[string]any{ + "status": validation.Status, + "errors": stringsToAny(validation.Errors), + }, + "redaction": map[string]any{ + "profile_id": config.Redaction.ProfileID, + "profile_version": config.Redaction.Version, + "applied": redactionCount > 0, + "count": int64(redactionCount), + }, + } + if isOCSFEvidence(kind) { + security["ocsf"] = ocsfIdentifiers(original) + } + + envelope := map[string]any{ + "schema_version": "1.0", + "observed_time": observed.AsTime().UTC().Format(time.RFC3339Nano), + "acquisition": acquisition, + "openshell": openshell, + "security": security, + "correlation": correlation, + "original": original, + } + if attributes, ok := sourceAttributes.(map[string]any); ok && len(attributes) > 0 { + envelope["source_attributes"] = attributes + } + return envelope +} + +func canonicalCorrelationContext( + values map[string]any, + gatewayID string, + workspace string, + sandboxID string, + attributes pcommon.Map, +) map[string]any { + result := make(map[string]any, len(values)+5) + for key, value := range values { + result[key] = value + } + for key, value := range map[string]string{ + "openshell.gateway.id": gatewayID, + "openshell.workspace": workspace, + "openshell.sandbox.id": sandboxID, + } { + if value != "" { + result[key] = value + } + } + if value, ok := attributes.Get("openshell.policy.version"); ok { + switch value.Type() { + case pcommon.ValueTypeInt: + result["openshell.policy.version"] = value.Int() + case pcommon.ValueTypeStr: + if value.Str() != "" { + result["openshell.policy.version"] = value.Str() + } + } + } + if _, exists := result["agent.session.id"]; !exists { + if value, ok := result["agent_session_id"]; ok { + result["agent.session.id"] = value + } + } + return result +} + +func (p *processorImpl) setCloudEventAttributes( + record plog.LogRecord, + kind string, + eventID string, + original any, +) { + config := p.config + workspace := stringAttribute(record, "openshell.workspace", config.Workspace) + sandboxID := stringAttribute( + record, + "openshell.sandbox.id", + sandboxIDFrom(original, config.DefaultSandboxID), + ) + attributes := record.Attributes() + attributes.PutStr("event.id", eventID) + attributes.PutStr("event.domain", "openshell") + attributes.PutStr("cloudevents.specversion", "1.0") + attributes.PutStr("cloudevents.id", eventID) + attributes.PutStr( + "cloudevents.source", + fmt.Sprintf( + "openshell://%s/workspaces/%s/sandboxes/%s/sources/%s", + p.escapeSourceComponent(config.GatewayID), + p.escapeSourceComponent(workspace), + p.escapeSourceComponent(sandboxID), + p.escapeSourceComponent(kind), + ), + ) + attributes.PutStr("cloudevents.type", eventType(kind, original)) + attributes.PutStr("cloudevents.dataschema", "urn:openshell:event-envelope:1") + attributes.PutStr("cloudevents.datacontenttype", "application/json") + attributes.PutStr("cloudevents.subject", "sandboxes/"+sandboxID) + + if sourceMillis := sourceTimeMillis(kind, original, attributes); sourceMillis > 0 { + record.SetTimestamp(pcommon.Timestamp(sourceMillis * int64(time.Millisecond))) + } else { + record.SetTimestamp(0) + } +} + +func eventType(kind string, original any) string { + if isOCSFEvidence(kind) { + if object, ok := original.(map[string]any); ok { + if classUID, ok := exactInteger(object["class_uid"]); ok && classUID > 0 { + return "com.nvidia.openshell.ocsf." + + strconv.FormatInt(classUID, 10) + + ".v1" + } + } + return "com.nvidia.openshell.ocsf.unknown.v1" + } + types := map[string]string{ + "sandbox.lifecycle": "com.nvidia.openshell.sandbox.lifecycle.v1", + "gateway.log": "com.nvidia.openshell.gateway.log.v1", + "sandbox.log": "com.nvidia.openshell.sandbox.log.v1", + "nemo_relay.log": "com.nvidia.openshell.nemo_relay.log.v1", + "sandbox.file_log": "com.nvidia.openshell.sandbox.file_log.v1", + "openshell.log.forwarded": "com.nvidia.openshell.sandbox.file_log.v1", + "platform.event": "com.nvidia.openshell.platform.event.v1", + "policy.draft_updated": "com.nvidia.openshell.policy.draft_updated.v1", + "policy.draft.snapshot": "com.nvidia.openshell.policy.draft.snapshot.v1", + "policy.draft.chunk": "com.nvidia.openshell.policy.draft.chunk.v1", + "policy.draft.history": "com.nvidia.openshell.policy.draft.history.v1", + "policy.status": "com.nvidia.openshell.policy.status.v1", + "policy.revision": "com.nvidia.openshell.policy.revision.v1", + "policy.reconciliation.warning": "com.nvidia.openshell.policy.reconciliation.warning.v1", + "stream.warning": "com.nvidia.openshell.stream.warning.v1", + "source.capability": "com.nvidia.openshell.source.capability.v1", + "kubernetes.context": "com.nvidia.openshell.kubernetes.context.v1", + } + if value := types[kind]; value != "" { + return value + } + return "com.nvidia.openshell.stream.warning.v1" +} + +func sourceTimeMillis(kind string, original any, attributes pcommon.Map) int64 { + if isOCSFEvidence(kind) { + if object, ok := original.(map[string]any); ok { + value, _ := exactInteger(object["time"]) + return value + } + } + if value, ok := attributes.Get("openshell.event.time_unix_ms"); ok && + value.Type() == pcommon.ValueTypeInt { + return value.Int() + } + return 0 +} + +func ocsfIdentifiers(original any) map[string]any { + result := map[string]any{} + object, ok := original.(map[string]any) + if !ok { + return result + } + if metadata, ok := object["metadata"].(map[string]any); ok { + if version, ok := metadata["version"].(string); ok && version != "" { + result["version"] = version + } + } + for _, key := range []string{ + "class_uid", + "category_uid", + "activity_id", + "type_uid", + "severity_id", + } { + if value, exists := object[key]; exists { + result[key] = value + } + } + return result +} + +func correlations(original any, configured []string) map[string]any { + result := map[string]any{} + if value, ok := lookupPath(original, "metadata.original_event_uid"); ok { + result["original_event_uid"] = value + } + identifiers := []struct { + name string + aliases []string + }{ + {name: "trace_id", aliases: []string{"trace_id", "trace.id"}}, + {name: "span_id", aliases: []string{"span_id", "span.id"}}, + {name: "parent_span_id", aliases: []string{"parent_span_id", "parent.span.id"}}, + {name: "transaction_id", aliases: []string{"transaction_id", "transaction.id"}}, + {name: "request_id", aliases: []string{"request_id", "request.id", "gen_ai.request.id"}}, + {name: "session_id", aliases: []string{"session_id", "session.id", "gen_ai.conversation.id"}}, + {name: "agent_session_id", aliases: []string{"agent_session_id", "agent.session.id"}}, + {name: "tool_call_id", aliases: []string{"tool_call_id", "tool.call.id", "gen_ai.tool.call.id"}}, + {name: "correlation_id", aliases: []string{"correlation_id", "correlation.id"}}, + {name: "policy_chunk_id", aliases: []string{"policy_chunk_id", "policy.chunk.id"}}, + {name: "operation_id", aliases: []string{"operation_id", "operation.id"}}, + {name: "run_id", aliases: []string{"run_id", "run.id"}}, + {name: "invocation_id", aliases: []string{"invocation_id", "invocation.id"}}, + } + for _, containerPath := range []string{ + "", + "unmapped", + "fields", + "metadata", + "metadata.annotations", + "source_payload", + "source_payload.fields", + "source_payload.metadata", + "source_payload.metadata.annotations", + } { + container, ok := correlationContainer(original, containerPath) + if !ok { + continue + } + for _, identifier := range identifiers { + if _, exists := result[identifier.name]; exists { + continue + } + for _, alias := range identifier.aliases { + if value, ok := scalarMapValue(container, alias); ok { + result[identifier.name] = value + break + } + } + } + } + for _, path := range configured { + name := correlationName(path) + if _, exists := result[name]; exists { + continue + } + if value, ok := lookupPath(original, path); ok { + result[name] = value + } + } + return result +} + +func correlationContainer(root any, path string) (map[string]any, bool) { + current, ok := root.(map[string]any) + if !ok { + return nil, false + } + if path == "" { + return current, true + } + for _, segment := range strings.Split(path, ".") { + value, exists := current[segment] + if !exists { + return nil, false + } + current, ok = value.(map[string]any) + if !ok { + return nil, false + } + } + return current, true +} + +func scalarMapValue(object map[string]any, key string) (any, bool) { + value, ok := object[key] + if !ok { + return nil, false + } + switch typed := value.(type) { + case nil, map[string]any, []any: + return nil, false + case string: + return typed, typed != "" + default: + return typed, true + } +} + +func correlationName(path string) string { + name := path[strings.LastIndex(path, ".")+1:] + if name == "original_event_uid" { + return "original_event_uid" + } + return name +} + +func firstCorrelation(values map[string]any) string { + for _, key := range []string{ + "original_event_uid", + "trace_id", + "span_id", + "parent_span_id", + "transaction_id", + "request_id", + "session_id", + "agent_session_id", + "tool_call_id", + "correlation_id", + "policy_chunk_id", + "operation_id", + "run_id", + "invocation_id", + } { + if value, ok := values[key]; ok { + return fmt.Sprint(value) + } + } + return "" +} + +func lookupPath(root any, path string) (any, bool) { + current := root + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + switch value := current.(type) { + case nil, map[string]any, []any: + return nil, false + case string: + return value, value != "" + default: + return value, true + } +} + +func sandboxIDFrom(original any, fallback string) string { + for _, path := range []string{"sandbox.id", "sandbox_id", "unmapped.sandbox_id"} { + if value, ok := lookupPath(original, path); ok && fmt.Sprint(value) != "" { + return fmt.Sprint(value) + } + } + if fallback != "" { + return fallback + } + return "unknown" +} + +func isCheckpointedFile(kind string) bool { + return kind == "ocsf.file" || + kind == "sandbox.file_log" || + kind == "nemo_relay.log" +} + +func isFileEvidence(kind string) bool { + return isCheckpointedFile(kind) || isForwardedFile(kind) +} + +func isForwardedFile(kind string) bool { + return kind == "ocsf.forwarded" || kind == "openshell.log.forwarded" +} + +func isOCSFEvidence(kind string) bool { + return kind == "ocsf.file" || kind == "ocsf.forwarded" +} + +func isKubernetesContext(kind string) bool { + return kind == "kubernetes.context" +} + +func isSourceCapability(kind string) bool { + return kind == "source.capability" +} + +func durability(kind string) string { + if isSourceCapability(kind) { + return "upstream_buffered_diagnostic" + } + if isCheckpointedFile(kind) { + return "checkpointed_file" + } + if isForwardedFile(kind) { + return "upstream_checkpointed_file" + } + if isKubernetesContext(kind) { + return "resource_version_checkpointed_api" + } + if isPolicyReconciliationKind(kind) { + return "checkpointed_api_snapshot" + } + return "non_resumable_stream" +} + +func acquisitionCoverage(kind string, attributes pcommon.Map) map[string]any { + coverage := map[string]any{ + "status": "observed", + "source_profile": sourceProfile(kind), + } + switch { + case isSourceCapability(kind): + coverage["payload_preservation"] = "exporter_diagnostic" + coverage["known_limitations"] = []any{ + "reports_configured_sandbox_file_lanes_not_all_possible_gateway_evidence", + } + if value, ok := attributes.Get("openshell.source.network_file_lane"); ok && + value.Type() == pcommon.ValueTypeStr && value.Str() != "enabled" { + coverage["status"] = "gap_reported" + } + case isFileEvidence(kind): + coverage["payload_preservation"] = "redacted_source_record" + coverage["known_limitations"] = []any{ + "durability_requires_persistent_upstream_file_and_checkpoint_storage", + } + if isForwardedFile(kind) { + coverage["known_limitations"] = []any{ + "forwarder_owns_file_checkpoint_and_retry_state", + "exporter_cannot_verify_forwarder_file_coordinates_when_omitted", + } + } + case isPolicyReconciliationKind(kind): + coverage["payload_preservation"] = "full_public_proto_json" + coverage["known_limitations"] = []any{ + "read_only_polling_snapshot_without_event_cursor", + "review_tokens_are_never_exported", + "effective_policy_bodies_require_explicit_opt_in", + } + if kind == "policy.reconciliation.warning" { + coverage["status"] = "gap_reported" + coverage["payload_preservation"] = "exporter_diagnostic" + } + case isKubernetesContext(kind): + coverage["payload_preservation"] = "redacted_kubernetes_api_object" + coverage["known_limitations"] = []any{ + "namespace_and_object_allow_list_only", + "not_sandbox_application_or_host_logs", + } + case isWatchSandboxKind(kind): + payloadPreservation := "exporter_diagnostic" + if value, ok := attributes.Get("openshell.acquisition.payload_type"); ok && + value.Type() == pcommon.ValueTypeStr && + strings.HasPrefix(value.Str(), "openshell.v1.") { + payloadPreservation = "full_public_proto_json" + } + coverage["payload_preservation"] = payloadPreservation + coverage["known_limitations"] = []any{ + "non_resumable", + "best_effort_reconnect_tails", + "watch_events_unavailable", + } + if kind == "stream.warning" { + coverage["status"] = "gap_reported" + } + default: + coverage["payload_preservation"] = "redacted_source_record" + coverage["known_limitations"] = []any{"unclassified_source"} + } + return coverage +} + +func sourceProfile(kind string) string { + switch { + case kind == "ocsf.file": + return "ocsf.file" + case kind == "ocsf.forwarded": + return "ocsf.forwarded" + case kind == "openshell.log.forwarded": + return "openshell.log.forwarded" + case isSourceCapability(kind): + return "sandbox.forwarder" + case isKubernetesContext(kind): + return "kubernetes.context" + case isPolicyReconciliationKind(kind): + return "policy.reconciliation" + case kind == "sandbox.file_log": + return "openshell.log" + case kind == "nemo_relay.log": + return "nemo_relay.log" + case isWatchSandboxKind(kind): + return "watchsandbox" + default: + return "unsupported" + } +} + +func isPolicyReconciliationKind(kind string) bool { + switch kind { + case "policy.draft.snapshot", + "policy.draft.chunk", + "policy.draft.history", + "policy.status", + "policy.revision", + "policy.reconciliation.warning": + return true + default: + return false + } +} + +func isWatchSandboxKind(kind string) bool { + switch kind { + case "sandbox.lifecycle", + "gateway.log", + "sandbox.log", + "platform.event", + "policy.draft_updated", + "stream.warning": + return true + default: + return false + } +} + +func escape(value string) string { + return url.PathEscape(value) +} + +func stringAttribute(record plog.LogRecord, key, fallback string) string { + value, ok := record.Attributes().Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr || value.Str() == "" { + return fallback + } + return value.Str() +} + +func copyStringAttribute( + from pcommon.Map, + to map[string]any, + attribute string, + field string, +) { + if value, ok := from.Get(attribute); ok && + value.Type() == pcommon.ValueTypeStr && + value.Str() != "" { + to[field] = value.Str() + } +} + +func copyAttribute(from pcommon.Map, to map[string]any, attribute string, field string) { + if value, ok := from.Get(attribute); ok { + to[field] = value.AsRaw() + } +} + +func stringsToAny(values []string) []any { + result := make([]any, len(values)) + for index, value := range values { + result[index] = value + } + return result +} + +// Identity has already been calculated from the pristine source. Redact before +// escaping so encoded whitespace cannot hide credentials from the patterns. +func (p *processorImpl) escapeSourceComponent(value string) string { + sanitized, _ := p.redactString(value) + return escape(sanitized) +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/factory.go b/projects/openshell-exporter/processor/openshellprocessor/factory.go new file mode 100644 index 00000000..6d39ca19 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/factory.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" +) + +var componentType = component.MustNewType("openshell") + +func NewFactory() processor.Factory { + return processor.NewFactory( + componentType, + createDefaultConfig, + processor.WithLogs(createLogsProcessor, component.StabilityLevelAlpha), + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + SourceProfiles: []string{"ocsf.file"}, + GatewayID: "local-gateway", + Workspace: "default", + SourceInstance: "ocsf-jsonl", + DefaultSandboxID: "unknown", + Validation: ValidationConfig{ + Mode: "mark", + }, + Redaction: RedactionConfig{ + ProfileID: "openshell-default", + Version: "1", + Keys: []string{ + "access_token", + "api_key", + "authorization", + "client_secret", + "password", + "refresh_token", + "secret", + }, + Patterns: []string{ + `(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*`, + `(?i)\b(api[_-]?key|password|secret)\s*[:=]\s*[^\s,;]+`, + }, + }, + CorrelationFields: []string{ + "metadata.original_event_uid", + "unmapped.correlation_id", + "unmapped.policy_chunk_id", + }, + } +} + +func createLogsProcessor( + ctx context.Context, + settings processor.Settings, + cfg component.Config, + next consumer.Logs, +) (processor.Logs, error) { + processorConfig := cfg.(*Config) + implementation, err := newProcessorWithMeter( + processorConfig, + settings.Logger, + settings.MeterProvider, + ) + if err != nil { + return nil, err + } + return processorhelper.NewLogs( + ctx, + settings, + cfg, + next, + implementation.processLogs, + processorhelper.WithStart(implementation.start), + processorhelper.WithCapabilities(consumer.Capabilities{MutatesData: true}), + ) +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/forwarded_test.go b/projects/openshell-exporter/processor/openshellprocessor/forwarded_test.go new file mode 100644 index 00000000..7220aaf2 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/forwarded_test.go @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "strings" + "testing" + + "go.uber.org/zap" +) + +func TestForwardedOCSFIsValidatedRedactedAndUpstreamCheckpointed(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"ocsf.forwarded"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + + original := validOCSF() + original["authorization"] = "Bearer forwarded-secret" + logs := testLogs(t, original) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.forwarded") + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-a/fluent-bit") + record.Attributes().PutStr("log.file.path", "/var/log/openshell/openshell-ocsf.log") + record.Attributes().PutInt("log.file.record_offset", 512) + record.Attributes().PutInt("log.file.record_number", 9) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + + typeValue, _ := record.Attributes().Get("cloudevents.type") + if typeValue.Str() != "com.nvidia.openshell.ocsf.4001.v1" { + t.Fatalf("type=%q", typeValue.Str()) + } + envelope := record.Body().Map().AsRaw() + acquisition := envelope["acquisition"].(map[string]any) + if acquisition["durability"] != "upstream_checkpointed_file" || + acquisition["transport"] != "otlp_http" { + t.Fatalf("acquisition=%#v", acquisition) + } + security := envelope["security"].(map[string]any) + validation := security["validation"].(map[string]any) + if validation["status"] != validationValid { + t.Fatalf("validation=%#v", validation) + } + redactedOriginal := envelope["original"].(map[string]any) + if strings.Contains(redactedOriginal["authorization"].(string), "forwarded-secret") { + t.Fatal("forwarded credential was not redacted") + } + if _, ok := security["ocsf"]; !ok { + t.Fatal("forwarded OCSF identifiers are missing") + } +} + +func TestForwardedOCSFPromotesBodyProvenanceBeforeIdentity(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"ocsf.forwarded"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + body := validOCSF() + body["log.file.path"] = "/var/log/openshell/openshell-ocsf.log" + body["log.file.record_offset"] = int64(64) + body["log.file.record_number"] = int64(2) + body["openshell.acquisition.source_instance"] = "sandbox-a/fluent-bit" + body["openshell.sandbox.id"] = "sandbox-a" + logs := testLogs(t, body) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.forwarded") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + for _, key := range []string{ + "log.file.path", + "log.file.record_offset", + "log.file.record_number", + "openshell.acquisition.source_instance", + "openshell.sandbox.id", + } { + if _, ok := record.Attributes().Get(key); !ok { + t.Fatalf("promoted provenance %q is missing", key) + } + } +} + +func TestForwardedOCSFIdentityIncludesForwarderInstanceAndCoordinates(t *testing.T) { + config := createDefaultConfig().(*Config) + record := onlyRecord(testLogs(t, validOCSF())) + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-a/fluent-bit") + record.Attributes().PutStr("log.file.path", "/var/log/openshell/openshell-ocsf.log") + record.Attributes().PutInt("log.file.record_offset", 1) + first := stableID(config, record, "ocsf.forwarded", validOCSF()) + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-b/fluent-bit") + second := stableID(config, record, "ocsf.forwarded", validOCSF()) + if first == second { + t.Fatal("forwarder source instance did not distinguish stable identity") + } +} + +func TestKubernetesContextPreservesUnknownObjectFields(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"kubernetes.context"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + body := map[string]any{ + "type": "MODIFIED", + "object": map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "sandbox-a", + "namespace": "openshell-sandboxes", + "uid": "pod-uid-1", + "resourceVersion": "42", + "futureField": "preserved", + "annotations": map[string]any{ + "openshell.io/sandbox-id": "sandbox-id-1", + }, + "labels": map[string]any{ + "openshell.ai/sandbox-workspace": "tenant-a", + }, + }, + }, + } + logs := testLogs(t, body) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "kubernetes.context") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + envelope := record.Body().Map().AsRaw() + if envelope["original"].(map[string]any)["type"] != "MODIFIED" { + t.Fatalf("original=%#v", envelope["original"]) + } + acquisition := envelope["acquisition"].(map[string]any) + if acquisition["durability"] != "resource_version_checkpointed_api" { + t.Fatalf("acquisition=%#v", acquisition) + } + typeValue, _ := record.Attributes().Get("cloudevents.type") + if typeValue.Str() != "com.nvidia.openshell.kubernetes.context.v1" { + t.Fatalf("type=%q", typeValue.Str()) + } + for key, want := range map[string]string{ + "openshell.sandbox.id": "sandbox-id-1", + "openshell.workspace": "tenant-a", + "k8s.object.uid": "pod-uid-1", + "k8s.object.resource_version": "42", + } { + value, ok := record.Attributes().Get(key) + if !ok || value.Str() != want { + t.Fatalf("attribute %s=%q, want %q", key, value.Str(), want) + } + } +} + +func TestForwardedRawOCSFIsDecodedWithoutLosingProvenance(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"ocsf.forwarded"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, `{"class_uid":4001,"category_uid":4,"activity_id":1,"type_uid":400101,"time":1770000000000,"metadata":{"version":"1.8.0"},"future":{"kept":true}}`) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.forwarded") + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-a/fluent-bit") + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-a") + record.Attributes().PutStr("log.file.path", "/evidence/agent/openshell-ocsf.1.log") + record.Attributes().PutInt("log.file.record_offset", 128) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + envelope := record.Body().Map().AsRaw() + original := envelope["original"].(map[string]any) + if original["future"].(map[string]any)["kept"] != true { + t.Fatalf("original=%#v", original) + } + validation := envelope["security"].(map[string]any)["validation"].(map[string]any) + if validation["status"] != validationValid { + t.Fatalf("validation=%#v", validation) + } +} + +func TestForwardedOperationalLogKeepsRawLineAndIsNotOCSF(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"openshell.log.forwarded"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, "supervisor connected request_id=req-42 secret=secret-value") + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "openshell.log.forwarded") + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-a/fluent-bit") + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-a") + record.Attributes().PutStr("log.file.path", "/evidence/network/openshell.1.log") + record.Attributes().PutInt("log.file.record_offset", 256) + record.Attributes().PutStr("trace.id", "00112233445566778899aabbccddeeff") + record.Attributes().PutStr("future.attribute", "preserved") + record.Attributes().PutStr("api_key", "attribute-secret") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + envelope := record.Body().Map().AsRaw() + if envelope["original"] == "supervisor connected request_id=req-42 secret=secret-value" { + t.Fatal("operational secret-like value was not redacted") + } + sourceAttributes := envelope["source_attributes"].(map[string]any) + if sourceAttributes["future.attribute"] != "preserved" || sourceAttributes["api_key"] != redacted { + t.Fatalf("source_attributes=%#v", sourceAttributes) + } + correlation := envelope["correlation"].(map[string]any) + if correlation["trace_id"] != "00112233445566778899aabbccddeeff" { + t.Fatalf("correlation=%#v", correlation) + } + security := envelope["security"].(map[string]any) + if _, exists := security["ocsf"]; exists { + t.Fatal("operational log was incorrectly classified as OCSF") + } + validation := security["validation"].(map[string]any) + if validation["status"] != validationNotApplicable { + t.Fatalf("validation=%#v", validation) + } + acquisition := envelope["acquisition"].(map[string]any) + if acquisition["durability"] != "upstream_checkpointed_file" || acquisition["transport"] != "otlp_http" { + t.Fatalf("acquisition=%#v", acquisition) + } + typeValue, _ := record.Attributes().Get("cloudevents.type") + if typeValue.Str() != "com.nvidia.openshell.sandbox.file_log.v1" { + t.Fatalf("type=%q", typeValue.Str()) + } +} + +func TestSourceCapabilityReportsUnavailableNetworkLane(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"ocsf.forwarded", "openshell.log.forwarded"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, "network file evidence lane unavailable") + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "source.capability") + record.Attributes().PutStr("openshell.acquisition.source_instance", "sandbox-a/fluent-bit") + record.Attributes().PutStr("openshell.acquisition.transport", "otlp_http") + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-a") + record.Attributes().PutStr("openshell.source.agent_file_lane", "enabled") + record.Attributes().PutStr("openshell.source.network_file_lane", "unavailable") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + typeValue, _ := record.Attributes().Get("cloudevents.type") + if typeValue.Str() != "com.nvidia.openshell.source.capability.v1" { + t.Fatalf("type=%q", typeValue.Str()) + } + envelope := record.Body().Map().AsRaw() + acquisition := envelope["acquisition"].(map[string]any) + coverage := acquisition["coverage"].(map[string]any) + if acquisition["durability"] != "upstream_buffered_diagnostic" || + acquisition["transport"] != "otlp_http" || coverage["status"] != "gap_reported" || + coverage["source_profile"] != "sandbox.forwarder" { + t.Fatalf("acquisition=%#v", acquisition) + } + validation := envelope["security"].(map[string]any)["validation"].(map[string]any) + if validation["status"] != validationNotApplicable { + t.Fatalf("validation=%#v", validation) + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/identity.go b/projects/openshell-exporter/processor/openshellprocessor/identity.go new file mode 100644 index 00000000..969f1830 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/identity.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + + "go.opentelemetry.io/collector/pdata/plog" +) + +func stableID(config *Config, record plog.LogRecord, kind string, original any) string { + identitySource := original + if !isFileEvidence(kind) { + if object, ok := original.(map[string]any); ok { + compatibilityProjection := make(map[string]any, len(object)) + for key, value := range object { + if key != "source_payload" { + compatibilityProjection[key] = value + } + } + identitySource = compatibilityProjection + } + } + body, _ := json.Marshal(identitySource) + bodyHash := sha256.Sum256(body) + identity := map[string]any{ + "gateway_id": config.GatewayID, + "workspace": stringAttribute(record, "openshell.workspace", config.Workspace), + "kind": kind, + "body_hash": hex.EncodeToString(bodyHash[:]), + } + attributes := record.Attributes() + if isFileEvidence(kind) { + identity["source_instance"] = config.SourceInstance + if isForwardedFile(kind) { + identity["source_instance"] = stringAttribute( + record, + "openshell.acquisition.source_instance", + config.SourceInstance, + ) + } + for _, key := range []string{ + "log.file.path", + "log.file.path_resolved", + "log.file.record_offset", + "log.file.record_number", + } { + if value, ok := attributes.Get(key); ok { + identity[key] = value.AsRaw() + } + } + } else { + for _, key := range []string{ + "openshell.sandbox.id", + "openshell.resource.version", + "openshell.event.kind", + "k8s.object.uid", + "k8s.object.resource_version", + } { + if value, ok := attributes.Get(key); ok { + identity[key] = value.AsRaw() + } + } + } + encoded, _ := json.Marshal(identity) + sum := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(sum[:]) +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/metrics.go b/projects/openshell-exporter/processor/openshellprocessor/metrics.go new file mode 100644 index 00000000..9e5c5df3 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/metrics.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +const processorMeterName = "github.com/NVIDIA-dev/OpenShell-exporter/processor" + +type processorMetrics struct { + sourceEnabled metric.Int64Gauge + sourceConfigured metric.Int64Gauge + sourceObserved metric.Int64Gauge + sourceHealthClass metric.Int64Gauge + sourceCapabilityInfo metric.Int64Gauge + received metric.Int64Counter + invalid metric.Int64Counter + redactions metric.Int64Counter + lastSuccess metric.Int64Gauge +} + +func newProcessorMetrics(provider metric.MeterProvider) (*processorMetrics, error) { + if provider == nil { + provider = noop.NewMeterProvider() + } + meter := provider.Meter(processorMeterName) + sourceEnabled, err := meter.Int64Gauge( + "openshell.exporter.source.enabled", + metric.WithDescription("Configured source profile capability; one means enabled."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceConfigured, err := meter.Int64Gauge( + "openshell.exporter.source.configured", + metric.WithDescription("Configured state for each bounded evidence lane; one means configured."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceObserved, err := meter.Int64Gauge( + "openshell.exporter.source.observed", + metric.WithDescription("Observed state for each bounded evidence lane; one means at least one record was observed."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceHealthClass, err := meter.Int64Gauge( + "openshell.exporter.source.health_class", + metric.WithDescription("Bounded evidence-lane health: 0 disabled, 1 unobserved, 2 healthy, 3 gap reported, 4 unavailable."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceCapabilityInfo, err := meter.Int64Gauge( + "openshell.exporter.source.capability_info", + metric.WithDescription("Static bounded durability and limitation metadata for each evidence lane."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + received, err := meter.Int64Counter( + "openshell.exporter.records.received", + metric.WithDescription("Source records normalized by the OpenShell processor."), + metric.WithUnit("{record}"), + ) + if err != nil { + return nil, err + } + invalid, err := meter.Int64Counter( + "openshell.exporter.records.invalid", + metric.WithDescription("Source records retained with structural validation errors."), + metric.WithUnit("{record}"), + ) + if err != nil { + return nil, err + } + redactions, err := meter.Int64Counter( + "openshell.exporter.redactions", + metric.WithDescription("Secret values replaced by the configured redaction profile."), + metric.WithUnit("{value}"), + ) + if err != nil { + return nil, err + } + lastSuccess, err := meter.Int64Gauge( + "openshell.exporter.source.last_success_unixtime", + metric.WithDescription("Unix time of the last normalized record by bounded source kind."), + metric.WithUnit("s"), + ) + if err != nil { + return nil, err + } + return &processorMetrics{ + sourceEnabled: sourceEnabled, + sourceConfigured: sourceConfigured, + sourceObserved: sourceObserved, sourceHealthClass: sourceHealthClass, + sourceCapabilityInfo: sourceCapabilityInfo, + received: received, invalid: invalid, redactions: redactions, lastSuccess: lastSuccess, + }, nil +} + +func (m *processorMetrics) capabilitiesConfigured(ctx context.Context, enabledProfiles []string, capabilities []sourceCapabilitySummary) { + enabled := make(map[string]struct{}, len(enabledProfiles)) + for _, profile := range enabledProfiles { + enabled[profile] = struct{}{} + } + for _, profile := range supportedSourceProfiles { + value := int64(0) + if _, ok := enabled[profile]; ok { + value = 1 + } + m.sourceEnabled.Record(ctx, value, metric.WithAttributes( + attribute.String("source", profile), + )) + } + for _, capability := range capabilities { + configured := int64(0) + if capability.Configured { + configured = 1 + } + m.sourceConfigured.Record(ctx, configured, metric.WithAttributes( + attribute.String("source", capability.Source), + )) + m.sourceObserved.Record(ctx, 0, metric.WithAttributes( + attribute.String("source", capability.Source), + )) + m.sourceHealthClass.Record(ctx, sourceCapabilityHealthClass(capability.Health), metric.WithAttributes( + attribute.String("source", capability.Source), + )) + m.sourceCapabilityInfo.Record(ctx, 1, metric.WithAttributes( + attribute.String("source", capability.Source), + attribute.String("durability", capability.Durability), + attribute.String("limitation", capability.LimitationCode), + )) + } +} + +func (m *processorMetrics) observeSource(ctx context.Context, kind string) { + lane := sourceCapabilityLane(kind) + if lane == "" { + return + } + health := sourceHealthHealthy + if sourceKindReportsGap(kind) { + health = sourceHealthGapReported + } + attrs := metric.WithAttributes(attribute.String("source", lane)) + m.sourceObserved.Record(ctx, 1, attrs) + m.sourceHealthClass.Record(ctx, sourceCapabilityHealthClass(health), attrs) +} + +func (m *processorMetrics) record( + ctx context.Context, + kind string, + validation validationResult, + redactionCount int, + profileID string, +) { + metricSource := boundedMetricSource(kind) + attrs := metric.WithAttributes( + attribute.String("source", metricSource), + attribute.String("validation_status", validation.Status), + ) + m.received.Add(ctx, 1, attrs) + lastSuccessSource := sourceCapabilityLane(kind) + if lastSuccessSource == "" { + lastSuccessSource = metricSource + } + m.lastSuccess.Record(ctx, time.Now().Unix(), metric.WithAttributes( + attribute.String("source", lastSuccessSource), + )) + if validation.Status == validationInvalid { + m.invalid.Add(ctx, 1, metric.WithAttributes(attribute.String("source", metricSource))) + } + if redactionCount > 0 { + m.redactions.Add(ctx, int64(redactionCount), metric.WithAttributes( + attribute.String("source", metricSource), + attribute.String("profile", profileID), + )) + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/metrics_test.go b/projects/openshell-exporter/processor/openshellprocessor/metrics_test.go new file mode 100644 index 00000000..e471ba48 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/metrics_test.go @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "sort" + "strings" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestSourceCapabilityMetricsDescribeEveryBoundedLane(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + metrics, err := newProcessorMetrics(provider) + if err != nil { + t.Fatal(err) + } + capabilities := sourceCapabilitySummaries(supportedSourceProfiles) + metrics.capabilitiesConfigured(context.Background(), supportedSourceProfiles, capabilities) + + collected := collectInt64Metrics(t, reader) + wantSources := []string{ + "driver_otlp", + "gateway_otlp", + "kubernetes_context", + "ocsf_files", + "operational_files", + "policy_reconciliation", + "relay_files", + "relay_otlp", + "watch_events", + "watchsandbox", + } + for _, name := range []string{ + "openshell.exporter.source.configured", + "openshell.exporter.source.observed", + "openshell.exporter.source.health_class", + "openshell.exporter.source.capability_info", + } { + points := collected[name] + if got := metricSources(points); !equalStrings(got, wantSources) { + t.Fatalf("metric %q sources=%v, want %v", name, got, wantSources) + } + } + + assertMetricPointValue(t, collected["openshell.exporter.source.configured"], "watch_events", 0) + assertMetricPointValue(t, collected["openshell.exporter.source.observed"], "watch_events", 0) + assertMetricPointValue(t, collected["openshell.exporter.source.health_class"], "watch_events", sourceHealthClassUnavailable) + for _, source := range wantSources { + if source == "watch_events" { + continue + } + assertMetricPointValue(t, collected["openshell.exporter.source.configured"], source, 1) + assertMetricPointValue(t, collected["openshell.exporter.source.observed"], source, 0) + assertMetricPointValue(t, collected["openshell.exporter.source.health_class"], source, sourceHealthClassUnobserved) + } + + for _, point := range collected["openshell.exporter.source.capability_info"] { + keys := make([]string, 0, point.Attributes.Len()) + for _, item := range point.Attributes.ToSlice() { + keys = append(keys, string(item.Key)) + } + sort.Strings(keys) + if !equalStrings(keys, []string{"durability", "limitation", "source"}) { + t.Fatalf("capability labels=%v, want bounded metadata labels", keys) + } + for _, key := range []string{"durability", "limitation", "source"} { + value, ok := point.Attributes.Value(attribute.Key(key)) + if !ok || value.AsString() == "" { + t.Fatalf("capability label %q is missing", key) + } + } + } +} + +func TestSourceObservationMetricsDistinguishHealthyAndGap(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + metrics, err := newProcessorMetrics(provider) + if err != nil { + t.Fatal(err) + } + metrics.observeSource(context.Background(), "ocsf.file") + metrics.observeSource(context.Background(), "stream.warning") + metrics.observeSource(context.Background(), "customer-controlled-unknown") + + collected := collectInt64Metrics(t, reader) + observed := collected["openshell.exporter.source.observed"] + health := collected["openshell.exporter.source.health_class"] + if got := metricSources(observed); !equalStrings(got, []string{"ocsf_files", "watchsandbox"}) { + t.Fatalf("observed sources=%v", got) + } + assertMetricPointValue(t, observed, "ocsf_files", 1) + assertMetricPointValue(t, observed, "watchsandbox", 1) + assertMetricPointValue(t, health, "ocsf_files", sourceHealthClassHealthy) + assertMetricPointValue(t, health, "watchsandbox", sourceHealthClassGapReported) +} + +func TestProcessorMetricSourceLabelsFailClosed(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + metrics, err := newProcessorMetrics(provider) + if err != nil { + t.Fatal(err) + } + + const untrustedKind = "https://customer.example/sandboxes/secret-session?token=must-not-leak" + metrics.record(context.Background(), untrustedKind, validationResult{Status: validationInvalid}, 1, "default") + + collected := collectInt64Metrics(t, reader) + for _, name := range []string{ + "openshell.exporter.records.received", + "openshell.exporter.records.invalid", + "openshell.exporter.redactions", + "openshell.exporter.source.last_success_unixtime", + } { + points := collected[name] + if len(points) != 1 { + t.Fatalf("metric %q points=%d, want 1", name, len(points)) + } + source, ok := points[0].Attributes.Value(attribute.Key("source")) + if !ok || source.AsString() != "unsupported" { + t.Fatalf("metric %q source=%q, want unsupported", name, source.AsString()) + } + if strings.Contains(points[0].Attributes.Encoded(attribute.DefaultEncoder()), untrustedKind) { + t.Fatalf("metric %q leaked untrusted source label", name) + } + } +} + +func collectInt64Metrics(t *testing.T, reader *sdkmetric.ManualReader) map[string][]metricdata.DataPoint[int64] { + t.Helper() + var resourceMetrics metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resourceMetrics); err != nil { + t.Fatal(err) + } + result := map[string][]metricdata.DataPoint[int64]{} + for _, scope := range resourceMetrics.ScopeMetrics { + for _, metric := range scope.Metrics { + switch data := metric.Data.(type) { + case metricdata.Gauge[int64]: + result[metric.Name] = append(result[metric.Name], data.DataPoints...) + case metricdata.Sum[int64]: + result[metric.Name] = append(result[metric.Name], data.DataPoints...) + } + } + } + return result +} + +func metricSources(points []metricdata.DataPoint[int64]) []string { + result := make([]string, 0, len(points)) + for _, point := range points { + value, ok := point.Attributes.Value(attribute.Key("source")) + if ok { + result = append(result, value.AsString()) + } + } + sort.Strings(result) + return result +} + +func assertMetricPointValue(t *testing.T, points []metricdata.DataPoint[int64], source string, want int64) { + t.Helper() + for _, point := range points { + value, ok := point.Attributes.Value(attribute.Key("source")) + if ok && value.AsString() == source { + if point.Value != want { + t.Fatalf("source %q value=%d, want %d", source, point.Value, want) + } + return + } + } + t.Fatalf("source %q not found", source) +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for index := range got { + if got[index] != want[index] { + return false + } + } + return true +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/processor.go b/projects/openshell-exporter/processor/openshellprocessor/processor.go new file mode 100644 index 00000000..2d6f081b --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/processor.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" +) + +const redacted = "[REDACTED]" + +type processorImpl struct { + config *Config + logger *zap.Logger + metrics *processorMetrics + redactedKeys map[string]struct{} + patterns []*regexp.Regexp +} + +func newProcessor(config *Config, logger *zap.Logger) (*processorImpl, error) { + return newProcessorWithMeter(config, logger, nil) +} + +func newProcessorWithMeter( + config *Config, + logger *zap.Logger, + meterProvider metric.MeterProvider, +) (*processorImpl, error) { + if err := config.Validate(); err != nil { + return nil, err + } + metrics, err := newProcessorMetrics(meterProvider) + if err != nil { + return nil, fmt.Errorf("create processor metrics: %w", err) + } + keys := make(map[string]struct{}, len(config.Redaction.Keys)) + for _, key := range config.Redaction.Keys { + keys[strings.ToLower(key)] = struct{}{} + } + patterns := make([]*regexp.Regexp, 0, len(config.Redaction.Patterns)) + for _, pattern := range config.Redaction.Patterns { + compiled, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("compile redaction pattern: %w", err) + } + patterns = append(patterns, compiled) + } + return &processorImpl{ + config: config, + logger: logger, + metrics: metrics, + redactedKeys: keys, + patterns: patterns, + }, nil +} + +func (p *processorImpl) start(ctx context.Context, _ component.Host) error { + capabilities := sourceCapabilitySummaries(p.config.SourceProfiles) + p.logger.Info( + "OpenShell source capability summary", + zap.String("schema_version", "1.0"), + zap.Any("sources", capabilities), + ) + p.metrics.capabilitiesConfigured(ctx, p.config.SourceProfiles, capabilities) + return nil +} + +var unavailableSourceCapabilities = []string{ + "gateway_structured_ocsf_export", + "watch_events", +} + +func sourceCapabilitySets(enabledProfiles []string) ([]string, []string) { + disabledProfiles := make([]string, 0, len(supportedSourceProfiles)) + enabled := make(map[string]struct{}, len(enabledProfiles)) + for _, profile := range enabledProfiles { + enabled[profile] = struct{}{} + } + for _, profile := range supportedSourceProfiles { + if _, ok := enabled[profile]; !ok { + disabledProfiles = append(disabledProfiles, profile) + } + } + declaredUnobserved := []string{} + if _, ok := enabled["watchsandbox"]; ok { + declaredUnobserved = append( + declaredUnobserved, + "policy.draft_updated", + "stream.warning", + ) + } + return disabledProfiles, declaredUnobserved +} + +func (p *processorImpl) processLogs(ctx context.Context, logs plog.Logs) (plog.Logs, error) { + for resourceIndex := 0; resourceIndex < logs.ResourceLogs().Len(); resourceIndex++ { + resourceLogs := logs.ResourceLogs().At(resourceIndex) + resourceLogs.Resource().Attributes().PutStr("service.name", "openshell") + resourceLogs.Resource().Attributes().PutStr("openshell.gateway.id", p.config.GatewayID) + p.redactAttributes(resourceLogs.Resource().Attributes()) + for scopeIndex := 0; scopeIndex < resourceLogs.ScopeLogs().Len(); scopeIndex++ { + p.redactAttributes(resourceLogs.ScopeLogs().At(scopeIndex).Scope().Attributes()) + records := resourceLogs.ScopeLogs().At(scopeIndex).LogRecords() + for recordIndex := 0; recordIndex < records.Len(); recordIndex++ { + p.normalize(ctx, records.At(recordIndex)) + } + } + } + return logs, nil +} + +func (p *processorImpl) normalize(ctx context.Context, record plog.LogRecord) { + observed := record.ObservedTimestamp() + if observed == 0 { + observed = pcommon.NewTimestampFromTime(time.Now().UTC()) + record.SetObservedTimestamp(observed) + } + + original := record.Body().AsRaw() + kind := acquisitionKind(record, original) + if isForwardedFile(kind) { + original = decodeForwardedRecord(original) + } + if isCheckpointedFile(kind) { + promoteCheckpointedFileContext(record) + } + if isForwardedFile(kind) { + promoteForwardedFileProvenance(record, original) + } + if isKubernetesContext(kind) { + promoteKubernetesContext(record, original) + } + eventID := stableID(p.config, record, kind, original) + validation := validateSource(kind, original) + sourceAttributes := record.Attributes().AsRaw() + redactedOriginal, bodyRedactionCount := p.redactRaw(original) + redactedSourceAttributes, attributeRedactionCount := p.redactRaw(sourceAttributes) + redactionCount := bodyRedactionCount + attributeRedactionCount + correlation := correlations(original, p.config.CorrelationFields) + mergeMissingCorrelations(correlation, correlations(sourceAttributes, p.config.CorrelationFields)) + envelope := buildEnvelope( + p.config, + record, + kind, + observed, + validation, + redactionCount, + correlation, + redactedOriginal, + redactedSourceAttributes, + ) + + if err := record.Body().FromRaw(envelope); err != nil { + p.logger.Error( + "construct event envelope", + zap.Error(err), + ) + return + } + p.metrics.observeSource(ctx, kind) + p.metrics.record(ctx, kind, validation, redactionCount, p.config.Redaction.ProfileID) + p.setCloudEventAttributes(record, kind, eventID, original) + switch validation.Status { + case validationInvalid: + record.Attributes().PutBool("openshell.ocsf.valid", false) + record.Attributes().PutStr( + "openshell.ocsf.validation_error", + strings.Join(validation.Errors, "; "), + ) + p.logger.Warn( + "invalid source evidence retained", + zap.Strings("errors", validation.Errors), + ) + case validationValid: + record.Attributes().PutBool("openshell.ocsf.valid", true) + } + if value := firstCorrelation(correlation); value != "" { + record.Attributes().PutStr("openshell.correlation.id", value) + } + // Identity is already fixed. Sanitize original attributes and any derived + // strings that exporters serialize outside the redacted source copy. + p.redactAttributes(record.Attributes()) + p.redactValue(record.Body()) +} + +func decodeForwardedRecord(original any) any { + text, ok := original.(string) + if !ok { + return original + } + var decoded any + if err := json.Unmarshal([]byte(text), &decoded); err != nil { + return original + } + return decoded +} + +func mergeMissingCorrelations(destination, source map[string]any) { + for key, value := range source { + if _, exists := destination[key]; !exists { + destination[key] = value + } + } +} + +func acquisitionKind(record plog.LogRecord, original any) string { + if value, ok := record.Attributes().Get("openshell.acquisition.kind"); ok && + value.Type() == pcommon.ValueTypeStr { + return value.Str() + } + if object, ok := original.(map[string]any); ok { + if _, isOCSF := object["class_uid"]; isOCSF { + return "ocsf.file" + } + } + return "unknown" +} + +func promoteCheckpointedFileContext(record plog.LogRecord) { + attributes := record.Attributes() + if _, exists := attributes.Get("openshell.sandbox.name"); exists { + return + } + for _, key := range []string{"log.file.path_resolved", "log.file.path"} { + value, ok := attributes.Get(key) + if !ok { + continue + } + if sandboxName := sandboxNameFromEvidencePath(value.Str()); sandboxName != "" { + attributes.PutStr("openshell.sandbox.name", sandboxName) + return + } + } +} + +func sandboxNameFromEvidencePath(value string) string { + parts := strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") + for index := 0; index+1 < len(parts); index++ { + if parts[index] == "sandboxes" && isDNS1123Label(parts[index+1]) { + return parts[index+1] + } + } + return "" +} + +func isDNS1123Label(value string) bool { + if len(value) == 0 || len(value) > 63 || !isLowerAlphanumeric(value[0]) || + !isLowerAlphanumeric(value[len(value)-1]) { + return false + } + for index := 1; index+1 < len(value); index++ { + if !isLowerAlphanumeric(value[index]) && value[index] != '-' { + return false + } + } + return true +} + +func isLowerAlphanumeric(value byte) bool { + return value >= 'a' && value <= 'z' || value >= '0' && value <= '9' +} + +func promoteForwardedFileProvenance(record plog.LogRecord, original any) { + object, ok := original.(map[string]any) + if !ok { + return + } + attributes := record.Attributes() + for _, key := range []string{ + "log.file.path", + "log.file.path_resolved", + "openshell.acquisition.source_instance", + "openshell.sandbox.id", + } { + if _, exists := attributes.Get(key); exists { + continue + } + if value, ok := object[key].(string); ok && value != "" { + attributes.PutStr(key, value) + } + } + for _, key := range []string{"log.file.record_offset", "log.file.record_number"} { + if _, exists := attributes.Get(key); exists { + continue + } + if value, ok := exactInteger(object[key]); ok && value >= 0 { + attributes.PutInt(key, value) + } + } +} + +func promoteKubernetesContext(record plog.LogRecord, original any) { + object := kubernetesObject(original) + metadata, ok := object["metadata"].(map[string]any) + if !ok { + return + } + attributes := record.Attributes() + putStringAttributeIfMissing(attributes, "k8s.object.uid", metadata["uid"]) + putStringAttributeIfMissing( + attributes, + "k8s.object.resource_version", + metadata["resourceVersion"], + ) + for _, containerName := range []string{"annotations", "labels"} { + container, ok := metadata[containerName].(map[string]any) + if !ok { + continue + } + putStringAttributeIfMissing( + attributes, + "openshell.sandbox.id", + firstNonEmpty(container["openshell.io/sandbox-id"], container["openshell.ai/sandbox-id"]), + ) + putStringAttributeIfMissing( + attributes, + "openshell.workspace", + container["openshell.ai/sandbox-workspace"], + ) + } +} + +func kubernetesObject(original any) map[string]any { + object, _ := original.(map[string]any) + if nested, ok := object["object"].(map[string]any); ok { + return nested + } + return object +} + +func putStringAttributeIfMissing(attributes pcommon.Map, key string, raw any) { + if _, exists := attributes.Get(key); exists { + return + } + if value, ok := raw.(string); ok && value != "" { + attributes.PutStr(key, value) + } +} + +func firstNonEmpty(values ...any) any { + for _, raw := range values { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + return nil +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/processor_test.go b/projects/openshell-exporter/processor/openshellprocessor/processor_test.go new file mode 100644 index 00000000..e83f88f2 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/processor_test.go @@ -0,0 +1,716 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "strings" + "testing" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.uber.org/zap" +) + +func TestConfigRejectsUnknownOrEmptySourceProfiles(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = nil + if err := config.Validate(); err == nil { + t.Fatal("empty source profiles should fail") + } + config.SourceProfiles = []string{"arbitrary.host.logs"} + if err := config.Validate(); err == nil { + t.Fatal("unknown source profile should fail") + } +} + +func TestSourceCapabilitySetsDistinguishDisabledAndUnobserved(t *testing.T) { + disabled, declaredUnobserved := sourceCapabilitySets([]string{"openshell.log"}) + if strings.Join(disabled, ",") != + "ocsf.file,ocsf.forwarded,openshell.log.forwarded,watchsandbox,policy.reconciliation,kubernetes.context,nemo_relay.log,nemo_relay.trace,openshell.trace" { + t.Fatalf("disabled profiles=%v", disabled) + } + if len(declaredUnobserved) != 0 { + t.Fatalf("non-WatchSandbox profile reported stream variants: %v", declaredUnobserved) + } + + disabled, declaredUnobserved = sourceCapabilitySets([]string{"watchsandbox"}) + if strings.Contains(strings.Join(disabled, ","), "watchsandbox") { + t.Fatalf("enabled WatchSandbox was reported disabled: %v", disabled) + } + if strings.Join(declaredUnobserved, ",") != + "policy.draft_updated,stream.warning" { + t.Fatalf("declared unobserved variants=%v", declaredUnobserved) + } +} + +func TestUnavailableCapabilitiesIncludeUpstreamGatewayOCSFGate(t *testing.T) { + if strings.Join(unavailableSourceCapabilities, ",") != + "gateway_structured_ocsf_export,watch_events" { + t.Fatalf("unavailable capabilities=%v", unavailableSourceCapabilities) + } +} + +func TestEnvelopeRedactionValidationAndStableIdentity(t *testing.T) { + config := createDefaultConfig().(*Config) + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + original := validOCSF() + original["authorization"] = "Bearer secret-value" + original["tokens_in"] = int64(42) + original["unknown_extension"] = map[string]any{"keep": true} + logs := testLogs(t, original) + record := onlyRecord(logs) + record.Attributes().PutStr("log.file.path", "/var/log/openshell-ocsf.log") + record.Attributes().PutInt("log.file.record_offset", 128) + record.Attributes().PutInt("openshell.policy.version", 17) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + + firstID, _ := record.Attributes().Get("event.id") + if !strings.HasPrefix(firstID.Str(), "sha256:") || len(firstID.Str()) != 71 { + t.Fatalf("unexpected id %q", firstID.Str()) + } + envelope := record.Body().Map() + originalValue, _ := envelope.Get("original") + redactedValue, _ := originalValue.Map().Get("authorization") + if redactedValue.Str() != redacted { + t.Fatalf("authorization=%q", redactedValue.Str()) + } + tokens, _ := originalValue.Map().Get("tokens_in") + if tokens.Int() != 42 { + t.Fatalf("tokens_in=%d", tokens.Int()) + } + if _, ok := originalValue.Map().Get("unknown_extension"); !ok { + t.Fatal("unknown source field was lost") + } + security, _ := envelope.Get("security") + validation, _ := security.Map().Get("validation") + status, _ := validation.Map().Get("status") + if status.Str() != validationValid { + t.Fatalf("validation status=%q", status.Str()) + } + correlationValue, _ := envelope.Get("correlation") + correlation := correlationValue.Map() + for key, expected := range map[string]string{ + "openshell.gateway.id": config.GatewayID, + "openshell.workspace": config.Workspace, + "openshell.sandbox.id": sandboxIDFrom(original, config.DefaultSandboxID), + } { + value, ok := correlation.Get(key) + if !ok { + t.Fatalf("canonical correlation field %q is missing", key) + } + if value.Str() != expected { + t.Fatalf("correlation[%q]=%#v, want %q", key, value.AsRaw(), expected) + } + } + policyVersion, ok := correlation.Get("openshell.policy.version") + if !ok { + t.Fatal("canonical policy version is missing") + } + if policyVersion.Int() != 17 { + t.Fatalf("canonical policy version=%#v, want 17", policyVersion.AsRaw()) + } + if record.Timestamp() == 0 { + t.Fatal("OCSF source time was not set") + } + + second := testLogs(t, original) + secondRecord := onlyRecord(second) + secondRecord.Attributes().PutStr("log.file.path", "/var/log/openshell-ocsf.log") + secondRecord.Attributes().PutInt("log.file.record_offset", 128) + secondRecord.Attributes().PutInt("openshell.policy.version", 18) + otherConfig := *config + otherConfig.Redaction = config.Redaction + otherConfig.Redaction.Patterns = []string{"secret-value"} + other, err := newProcessor(&otherConfig, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + if _, err := other.processLogs(context.Background(), second); err != nil { + t.Fatal(err) + } + secondID, _ := secondRecord.Attributes().Get("event.id") + if firstID.Str() != secondID.Str() { + t.Fatalf("redaction or additive correlation changed identity: %q != %q", firstID.Str(), secondID.Str()) + } +} + +func TestSandboxOperationalFileIsCheckpointedRedactedEvidence(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"openshell.log"} + config.SourceInstance = "sandbox-operational-log" + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, "2026-08-21T12:00:00Z INFO openshell_sandbox: authorization=Bearer source-secret") + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "sandbox.file_log") + record.Attributes().PutStr( + "openshell.acquisition.source_instance", + "sandbox-operational-log", + ) + record.Attributes().PutStr("log.file.path", "/var/log/openshell.2026-08-21.log") + record.Attributes().PutStr( + "log.file.path_resolved", + "/var/log/openshell.2026-08-21.log", + ) + record.Attributes().PutInt("log.file.record_offset", 128) + record.Attributes().PutInt("log.file.record_number", 4) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + eventType, _ := record.Attributes().Get("cloudevents.type") + if eventType.Str() != "com.nvidia.openshell.sandbox.file_log.v1" { + t.Fatalf("type=%q", eventType.Str()) + } + envelope := record.Body().Map().AsRaw() + original := envelope["original"].(string) + if strings.Contains(original, "source-secret") || !strings.Contains(original, redacted) { + t.Fatalf("sandbox operational log was not redacted: %q", original) + } + acquisition := envelope["acquisition"].(map[string]any) + if acquisition["durability"] != "checkpointed_file" || + acquisition["transport"] != "file" || + acquisition["payload_type"] != "source_record" { + t.Fatalf("acquisition=%#v", acquisition) + } + coverage := acquisition["coverage"].(map[string]any) + if coverage["source_profile"] != "openshell.log" || + coverage["payload_preservation"] != "redacted_source_record" { + t.Fatalf("coverage=%#v", coverage) + } + security := envelope["security"].(map[string]any) + validation := security["validation"].(map[string]any) + if validation["status"] != validationNotApplicable { + t.Fatalf("validation=%#v", validation) + } +} + +func TestCheckpointedFilePromotesSandboxNameFromQualifiedPVCLayout(t *testing.T) { + config := createDefaultConfig().(*Config) + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, validOCSF()) + record := onlyRecord(logs) + record.Attributes().PutStr( + "log.file.path_resolved", + "/var/log/openshell/sandboxes/agent-primary/openshell-ocsf.2026-08-23.log", + ) + processor.normalize(context.Background(), record) + + name, ok := record.Attributes().Get("openshell.sandbox.name") + if !ok || name.Str() != "agent-primary" { + t.Fatalf("sandbox name attribute=%v, present=%t", name.AsRaw(), ok) + } + envelope := record.Body().AsRaw().(map[string]any) + openshell := envelope["openshell"].(map[string]any) + if openshell["sandbox_name"] != "agent-primary" || openshell["sandbox_id"] != "sandbox-1" { + t.Fatalf("openshell context=%#v", openshell) + } +} + +func TestSandboxNameFromEvidencePathRejectsUnqualifiedSegments(t *testing.T) { + for _, path := range []string{ + "/var/log/openshell/openshell-ocsf.log", + "/var/log/openshell/sandboxes/../openshell-ocsf.log", + "/var/log/openshell/sandboxes/Agent_Primary/openshell-ocsf.log", + } { + if name := sandboxNameFromEvidencePath(path); name != "" { + t.Fatalf("path %q produced sandbox name %q", path, name) + } + } +} + +func TestFileIdentityGoldenForQualificationReconciliation(t *testing.T) { + config := createDefaultConfig().(*Config) + config.GatewayID = "gateway-a" + config.Workspace = "default" + config.SourceInstance = "openshell-ocsf" + record := onlyRecord(plog.NewLogs()) + record.Attributes().PutStr("log.file.path", "/var/log/openshell/openshell-ocsf.jsonl") + record.Attributes().PutStr("log.file.path_resolved", "/var/log/openshell/openshell-ocsf.jsonl") + record.Attributes().PutInt("log.file.record_offset", 0) + record.Attributes().PutInt("log.file.record_number", 1) + + const expected = "sha256:28a1b7abca7378184dba5dbc629107951088c88a23a8b6d99cc43d48e54014db" + if identifier := stableID(config, record, "ocsf.file", "body-gateway-a-1"); identifier != expected { + t.Fatalf("production file identity = %q, want qualification golden %q", identifier, expected) + } +} + +func TestPublicPayloadEnrichmentDoesNotRedefineStreamIdentity(t *testing.T) { + config := createDefaultConfig().(*Config) + logs := testLogs(t, map[string]any{"message": "sandbox ready"}) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-a") + record.Attributes().PutInt("openshell.resource.version", 42) + record.Attributes().PutStr("openshell.event.kind", "sandbox.lifecycle") + + first := map[string]any{ + "event_type": "updated", + "sandbox": map[string]any{"resource_version": int64(42)}, + "source_payload": map[string]any{"future_field": "first"}, + } + second := map[string]any{ + "event_type": "updated", + "sandbox": map[string]any{"resource_version": int64(42)}, + "source_payload": map[string]any{"future_field": "second", "more": true}, + } + firstID := stableID(config, record, "sandbox.lifecycle", first) + secondID := stableID(config, record, "sandbox.lifecycle", second) + if firstID != secondID { + t.Fatalf("public payload enrichment changed stable identity: %q != %q", firstID, secondID) + } + second["event_type"] = "deleted" + if changed := stableID(config, record, "sandbox.lifecycle", second); changed == firstID { + t.Fatal("compatibility identity fields did not change stable identity") + } +} + +func TestMalformedStringIsRetainedInvalidAndRedacted(t *testing.T) { + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, "authorization=Bearer abc.def.ghi") + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.file") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if logs.LogRecordCount() != 1 { + t.Fatal("malformed evidence was dropped") + } + original, _ := record.Body().Map().Get("original") + if strings.Contains(original.Str(), "abc.def.ghi") { + t.Fatalf("malformed string was not redacted: %q", original.Str()) + } + valid, _ := record.Attributes().Get("openshell.ocsf.valid") + if valid.Bool() { + t.Fatal("malformed OCSF marked valid") + } +} + +func TestStructuralValidationAcceptsExactJSONNumbers(t *testing.T) { + event := validOCSF() + event["class_uid"] = float64(4001) + event["category_uid"] = float64(4) + event["activity_id"] = float64(1) + event["type_uid"] = float64(400101) + event["severity_id"] = float64(4) + event["time"] = float64(1_700_000_000_000) + result := validateSource("ocsf.file", event) + if result.Status != validationValid { + t.Fatalf("result=%#v", result) + } +} + +func TestStructuralValidationAcceptsCurrentAndPreviousOCSFVersions(t *testing.T) { + for _, version := range []string{"1.7.0", currentOCSFVersion} { + event := validOCSF() + event["metadata"].(map[string]any)["version"] = version + result := validateSource("ocsf.file", event) + if result.Status != validationValid { + t.Fatalf("version %s: result=%#v", version, result) + } + identifiers := ocsfIdentifiers(event) + if identifiers["version"] != version { + t.Fatalf("version %s: identifiers=%#v", version, identifiers) + } + } + event := validOCSF() + event["metadata"].(map[string]any)["version"] = "1.9.0" + result := validateSource("ocsf.file", event) + if result.Status != validationInvalid { + t.Fatalf("unsupported version result=%#v", result) + } +} + +func TestStructuralValidationReportsAllErrors(t *testing.T) { + result := validateSource("ocsf.file", map[string]any{ + "class_uid": 4001.5, + "severity_id": "high", + "metadata": map[string]any{"version": "1.6.0"}, + }) + if result.Status != validationInvalid || len(result.Errors) < 6 { + t.Fatalf("result=%#v", result) + } +} + +func TestLifecycleHasNoSyntheticSourceTime(t *testing.T) { + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, map[string]any{ + "event_type": "MODIFIED", + "sandbox": map[string]any{ + "id": "sandbox-1", + "resource_version": int64(9), + }, + }) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "sandbox.lifecycle") + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-1") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if record.Timestamp() != 0 { + t.Fatal("lifecycle event without source time received a synthetic time") + } + eventType, _ := record.Attributes().Get("cloudevents.type") + if eventType.Str() != "com.nvidia.openshell.sandbox.lifecycle.v1" { + t.Fatalf("type=%q", eventType.Str()) + } +} + +func TestOCSF18AIInferencePreservesSourceVersionAndUnknownFields(t *testing.T) { + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, map[string]any{ + "activity_id": int64(99), + "category_uid": int64(6), + "class_uid": int64(6003), + "time": int64(1_700_000_000_000), + "type_uid": int64(600399), + "metadata": map[string]any{ + "version": currentOCSFVersion, + "profiles": []any{"container", "host", "ai_operation"}, + }, + "api": map[string]any{"operation": "chat.completions"}, + "ai_model": map[string]any{ + "name": "nvidia/nemotron-3-super", + "provider": "NVIDIA", + }, + "unmapped": map[string]any{ + "latency_ms": int64(42), + "input_tokens": int64(128), + }, + }) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.file") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + eventType, _ := record.Attributes().Get("cloudevents.type") + if eventType.Str() != "com.nvidia.openshell.ocsf.6003.v1" { + t.Fatalf("type=%q", eventType.Str()) + } + envelope := record.Body().Map() + security, _ := envelope.Get("security") + ocsf, _ := security.Map().Get("ocsf") + version, _ := ocsf.Map().Get("version") + if version.Str() != currentOCSFVersion { + t.Fatalf("security.ocsf.version=%q", version.Str()) + } + validation, _ := security.Map().Get("validation") + status, _ := validation.Map().Get("status") + if status.Str() != validationValid { + t.Fatalf("validation status=%q", status.Str()) + } + original, _ := envelope.Get("original") + metadata, _ := original.Map().Get("metadata") + profiles, _ := metadata.Map().Get("profiles") + if profiles.Slice().Len() != 3 || + profiles.Slice().At(2).Str() != "ai_operation" { + t.Fatalf("profiles=%#v", profiles.AsRaw()) + } + if _, ok := original.Map().Get("ai_model"); !ok { + t.Fatal("AI model fields were lost") + } +} + +func TestWatchSandboxEnvelopePreservesMaximumAuthorizedContext(t *testing.T) { + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"watchsandbox"} + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, map[string]any{ + "event_type": "MODIFIED", + "sandbox": map[string]any{ + "id": "sandbox-1", + "resource_version": int64(11), + }, + "fields": map[string]any{ + "trace.id": "trace-from-fields", + "span.id": "span-from-fields", + "request.id": "request-from-fields", + }, + "source_payload": map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "session.id": "session-from-annotation", + "invocation.id": "invocation-from-annotation", + }, + }, + "spec": map[string]any{ + "environment": map[string]any{ + "authorization": "Bearer source-secret", + }, + "future_public_field": map[string]any{"preserve": true}, + }, + "status": map[string]any{ + "agent_pod": "agent-pod-1", + "conditions": []any{map[string]any{ + "type": "Ready", + "status": "True", + }}, + }, + }, + }) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "sandbox.lifecycle") + record.Attributes().PutStr("openshell.acquisition.source_instance", "watchsandbox") + record.Attributes().PutStr("openshell.acquisition.transport", "grpc") + record.Attributes().PutStr( + "openshell.acquisition.api_operation", + "openshell.v1.OpenShell/WatchSandbox", + ) + record.Attributes().PutStr("openshell.acquisition.payload_type", "openshell.v1.Sandbox") + record.Attributes().PutBool("openshell.acquisition.stream_resumable", false) + record.Attributes().PutBool("openshell.acquisition.follow_status", true) + record.Attributes().PutBool("openshell.acquisition.follow_logs", true) + record.Attributes().PutBool("openshell.acquisition.follow_events", true) + record.Attributes().PutInt("openshell.acquisition.log_tail_lines", 200) + record.Attributes().PutInt("openshell.acquisition.event_tail", 200) + record.Attributes().PutStr("openshell.sandbox.id", "sandbox-1") + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + + envelope := record.Body().Map().AsRaw() + acquisition := envelope["acquisition"].(map[string]any) + for key, expected := range map[string]any{ + "transport": "grpc", + "api_operation": "openshell.v1.OpenShell/WatchSandbox", + "payload_type": "openshell.v1.Sandbox", + "stream_resumable": false, + "follow_status": true, + "follow_logs": true, + "follow_events": true, + "log_tail_lines": int64(200), + "event_tail": int64(200), + } { + if acquisition[key] != expected { + t.Fatalf("acquisition[%q]=%#v, want %#v", key, acquisition[key], expected) + } + } + coverage := acquisition["coverage"].(map[string]any) + if coverage["status"] != "observed" || + coverage["source_profile"] != "watchsandbox" || + coverage["payload_preservation"] != "full_public_proto_json" { + t.Fatalf("coverage=%#v", coverage) + } + limitations := coverage["known_limitations"].([]any) + if len(limitations) != 3 || limitations[2] != "watch_events_unavailable" { + t.Fatalf("known limitations=%#v", limitations) + } + + original := envelope["original"].(map[string]any) + sourcePayload := original["source_payload"].(map[string]any) + spec := sourcePayload["spec"].(map[string]any) + environment := spec["environment"].(map[string]any) + if environment["authorization"] != redacted { + t.Fatalf("nested authorization=%#v", environment["authorization"]) + } + if spec["future_public_field"].(map[string]any)["preserve"] != true { + t.Fatalf("unknown public source fields were lost: %#v", spec) + } + status := sourcePayload["status"].(map[string]any) + if status["agent_pod"] != "agent-pod-1" || + len(status["conditions"].([]any)) != 1 { + t.Fatalf("status=%#v", status) + } + + correlation := envelope["correlation"].(map[string]any) + for key, expected := range map[string]any{ + "trace_id": "trace-from-fields", + "span_id": "span-from-fields", + "request_id": "request-from-fields", + "session_id": "session-from-annotation", + "invocation_id": "invocation-from-annotation", + } { + if correlation[key] != expected { + t.Fatalf("correlation[%q]=%#v, want %#v", key, correlation[key], expected) + } + } +} + +func TestCorrelationPrefersCanonicalRootIdentifiers(t *testing.T) { + got := correlations(map[string]any{ + "trace_id": "root-trace", + "fields": map[string]any{ + "trace.id": "nested-trace", + "span.id": "nested-span", + }, + "source_payload": map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{"trace.id": "annotation-trace"}, + }, + }, + }, nil) + if got["trace_id"] != "root-trace" || got["span_id"] != "nested-span" { + t.Fatalf("correlation precedence=%#v", got) + } +} + +func TestStreamWarningCoverageIsExplicitGapEvidence(t *testing.T) { + attributes := pcommon.NewMap() + attributes.PutStr( + "openshell.acquisition.payload_type", + "openshell.exporter.watchsandbox.Diagnostic", + ) + got := acquisitionCoverage("stream.warning", attributes) + if got["status"] != "gap_reported" || + got["payload_preservation"] != "exporter_diagnostic" { + t.Fatalf("coverage=%#v", got) + } +} + +func FuzzMalformedEvidenceIsRetained(f *testing.F) { + f.Add("authorization=Bearer abc.def.ghi") + f.Add("{not-json") + f.Fuzz(func(t *testing.T, body string) { + if len(body) > 64*1024 { + t.Skip() + } + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, body) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.file") + record.Attributes().PutStr("log.file.path", "/var/log/fuzz.jsonl") + record.Attributes().PutInt("log.file.record_offset", 1) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + if logs.LogRecordCount() != 1 { + t.Fatal("evidence was dropped") + } + id, ok := record.Attributes().Get("event.id") + if !ok || !strings.HasPrefix(id.Str(), "sha256:") { + t.Fatalf("invalid event ID %q", id.Str()) + } + if _, ok := record.Body().Map().Get("original"); !ok { + t.Fatal("original evidence is missing") + } + }) +} + +func BenchmarkOCSFNormalization(b *testing.B) { + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop()) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + logs := plog.NewLogs() + record := onlyRecord(logs) + if err := record.Body().FromRaw(validOCSF()); err != nil { + b.Fatal(err) + } + record.Attributes().PutStr("openshell.acquisition.kind", "ocsf.file") + record.Attributes().PutStr("log.file.path", "/var/log/openshell-ocsf.jsonl") + record.Attributes().PutInt("log.file.record_offset", 128) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + b.Fatal(err) + } + } +} + +func validOCSF() map[string]any { + return map[string]any{ + "class_uid": int64(4001), + "category_uid": int64(4), + "activity_id": int64(1), + "type_uid": int64(400101), + "severity_id": int64(4), + "time": int64(1_700_000_000_000), + "metadata": map[string]any{ + "version": currentOCSFVersion, + "original_event_uid": "uid-1", + }, + "unmapped": map[string]any{"sandbox_id": "sandbox-1"}, + } +} + +func testLogs(t *testing.T, body any) plog.Logs { + t.Helper() + logs := plog.NewLogs() + record := onlyRecord(logs) + if err := record.Body().FromRaw(body); err != nil { + t.Fatal(err) + } + return logs +} + +func onlyRecord(logs plog.Logs) plog.LogRecord { + if logs.ResourceLogs().Len() == 0 { + logs.ResourceLogs(). + AppendEmpty(). + ScopeLogs(). + AppendEmpty(). + LogRecords(). + AppendEmpty() + } + return logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) +} + +func TestAgentAndToolCorrelationFieldsAreRetained(t *testing.T) { + got := correlations(map[string]any{ + "agent_session_id": "session-1", + "tool_call_id": "call-1", + }, nil) + if got["agent_session_id"] != "session-1" || got["tool_call_id"] != "call-1" { + t.Fatalf("correlation=%#v", got) + } +} + +func TestCanonicalCorrelationContextAddsTrustedIdentity(t *testing.T) { + attributes := pcommon.NewMap() + attributes.PutInt("openshell.policy.version", 17) + got := canonicalCorrelationContext( + map[string]any{ + "agent_session_id": "session-1", + "openshell.gateway.id": "source-forgery", + "request_id": "request-1", + "trace_id": "00112233445566778899aabbccddeeff", + }, + "gateway-1", + "default", + "sandbox-1", + attributes, + ) + want := map[string]any{ + "agent.session.id": "session-1", + "openshell.gateway.id": "gateway-1", + "openshell.policy.version": int64(17), + "openshell.sandbox.id": "sandbox-1", + "openshell.workspace": "default", + "request_id": "request-1", + "trace_id": "00112233445566778899aabbccddeeff", + } + for key, expected := range want { + if got[key] != expected { + t.Fatalf("correlation[%q]=%#v, want %#v", key, got[key], expected) + } + } + if got["agent_session_id"] != "session-1" { + t.Fatal("source-compatible agent_session_id was removed") + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/redaction.go b/projects/openshell-exporter/processor/openshellprocessor/redaction.go new file mode 100644 index 00000000..b4214bbd --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/redaction.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "strings" + + "go.opentelemetry.io/collector/pdata/pcommon" +) + +// Sanitize the pdata containers as well as the envelope's copies. File and OTLP +// exporters serialize these attributes independently of the log body. +func (p *processorImpl) redactAttributes(attributes pcommon.Map) { + attributes.Range(func(key string, value pcommon.Value) bool { + if _, sensitive := p.redactedKeys[strings.ToLower(key)]; sensitive { + value.SetStr(redacted) + } else { + p.redactValue(value) + } + return true + }) +} + +func (p *processorImpl) redactValue(value pcommon.Value) { + switch value.Type() { + case pcommon.ValueTypeMap: + p.redactAttributes(value.Map()) + case pcommon.ValueTypeSlice: + values := value.Slice() + for i := 0; i < values.Len(); i++ { + p.redactValue(values.At(i)) + } + case pcommon.ValueTypeStr: + if text, count := p.redactString(value.Str()); count > 0 { + value.SetStr(text) + } + } +} + +func (p *processorImpl) redactString(value string) (string, int) { + count := 0 + for _, pattern := range p.patterns { + // Most evidence strings contain no secret. Avoid allocating a replacement + // copy on that path, including when checking an already-redacted envelope. + if !pattern.MatchString(value) { + continue + } + next := pattern.ReplaceAllString(value, redacted) + if next != value { + count++ + } + value = next + } + return value, count +} + +func (p *processorImpl) redactRaw(value any) (any, int) { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + count := 0 + for key, child := range typed { + if _, sensitive := p.redactedKeys[strings.ToLower(key)]; sensitive { + result[key] = redacted + count++ + continue + } + redactedChild, childCount := p.redactRaw(child) + result[key] = redactedChild + count += childCount + } + return result, count + case []any: + result := make([]any, len(typed)) + count := 0 + for index, child := range typed { + redactedChild, childCount := p.redactRaw(child) + result[index] = redactedChild + count += childCount + } + return result, count + case string: + return p.redactString(typed) + default: + return value, 0 + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/relay_file_test.go b/projects/openshell-exporter/processor/openshellprocessor/relay_file_test.go new file mode 100644 index 00000000..e22d39d7 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/relay_file_test.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "strings" + "testing" + + "go.uber.org/zap" +) + +func TestNeMoRelayFileProfileProducesTypedRedactedEnvelope(t *testing.T) { + config := createDefaultConfig().(*Config) + processor, err := newProcessor(config, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, map[string]any{ + "level": "INFO", + "message": "authorization=Bearer relay-secret", + "unknown": map[string]any{"preserved": true}, + }) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "nemo_relay.log") + record.Attributes().PutStr("openshell.acquisition.source_instance", "nemo-relay-file") + record.Attributes().PutStr("log.file.path", "/var/log/nemo-relay/relay.jsonl") + record.Attributes().PutStr("log.file.path_resolved", "/srv/relay/relay.jsonl") + record.Attributes().PutInt("log.file.record_offset", 512) + record.Attributes().PutInt("log.file.record_number", 9) + + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + eventType, _ := record.Attributes().Get("cloudevents.type") + if eventType.Str() != "com.nvidia.openshell.nemo_relay.log.v1" { + t.Fatalf("type=%q", eventType.Str()) + } + envelope := record.Body().Map() + acquisition, _ := envelope.Get("acquisition") + kind, _ := acquisition.Map().Get("kind") + if kind.Str() != "nemo_relay.log" { + t.Fatalf("kind=%q", kind.Str()) + } + durability, _ := acquisition.Map().Get("durability") + if durability.Str() != "checkpointed_file" { + t.Fatalf("durability=%q", durability.Str()) + } + security, _ := envelope.Get("security") + validation, _ := security.Map().Get("validation") + status, _ := validation.Map().Get("status") + if status.Str() != validationNotApplicable { + t.Fatalf("validation=%q", status.Str()) + } + original, _ := envelope.Get("original") + message, _ := original.Map().Get("message") + if strings.Contains(message.Str(), "relay-secret") { + t.Fatalf("Relay secret was not redacted: %q", message.Str()) + } + unknown, _ := original.Map().Get("unknown") + if preserved, ok := unknown.Map().Get("preserved"); !ok || !preserved.Bool() { + t.Fatal("unknown Relay field was lost") + } +} + +func TestNeMoRelayFileIdentityUsesCoordinatesBeforeRedaction(t *testing.T) { + body := map[string]any{"message": "authorization=Bearer relay-secret", "unknown": true} + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"nemo_relay.log"} + process := func(offset int64, patterns []string) string { + t.Helper() + local := *config + local.Redaction = config.Redaction + local.Redaction.Patterns = patterns + processor, err := newProcessor(&local, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + logs := testLogs(t, body) + record := onlyRecord(logs) + record.Attributes().PutStr("openshell.acquisition.kind", "nemo_relay.log") + record.Attributes().PutStr("log.file.path", "/var/log/nemo-relay/relay.jsonl") + record.Attributes().PutStr("log.file.path_resolved", "/srv/relay/relay.jsonl") + record.Attributes().PutInt("log.file.record_offset", offset) + record.Attributes().PutInt("log.file.record_number", 9) + if _, err := processor.processLogs(context.Background(), logs); err != nil { + t.Fatal(err) + } + id, _ := record.Attributes().Get("event.id") + return id.Str() + } + first := process(512, config.Redaction.Patterns) + second := process(512, []string{"relay-secret"}) + if first != second { + t.Fatalf("redaction profile changed Relay identity: %q != %q", first, second) + } + if third := process(513, config.Redaction.Patterns); third == first { + t.Fatal("different Relay file coordinate reused identity") + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/source_capability.go b/projects/openshell-exporter/processor/openshellprocessor/source_capability.go new file mode 100644 index 00000000..b9bdf135 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/source_capability.go @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +const ( + sourceHealthDisabled = "disabled" + sourceHealthUnobserved = "unobserved" + sourceHealthHealthy = "healthy" + sourceHealthGapReported = "gap_reported" + sourceHealthUnavailable = "unavailable" +) + +const ( + sourceHealthClassDisabled int64 = iota + sourceHealthClassUnobserved + sourceHealthClassHealthy + sourceHealthClassGapReported + sourceHealthClassUnavailable +) + +type sourceCapabilitySummary struct { + Source string `json:"source"` + Configured bool `json:"configured"` + Observed bool `json:"observed"` + Durability string `json:"durability"` + Health string `json:"health"` + LimitationCode string `json:"limitation_code"` +} + +type sourceCapabilityDefinition struct { + source string + profiles []string + durability string + limitationCode string + available bool +} + +var sourceCapabilityDefinitions = []sourceCapabilityDefinition{ + { + source: "ocsf_files", profiles: []string{"ocsf.file", "ocsf.forwarded"}, + durability: "checkpointed_file", + limitationCode: "persistent_upstream_file_and_checkpoint_required", available: true, + }, + { + source: "operational_files", profiles: []string{"openshell.log", "openshell.log.forwarded"}, + durability: "checkpointed_file", + limitationCode: "persistent_upstream_file_and_checkpoint_required", available: true, + }, + { + source: "watchsandbox", profiles: []string{"watchsandbox"}, + durability: "non_resumable_stream", + limitationCode: "no_resume_cursor_best_effort_tails", available: true, + }, + { + source: "policy_reconciliation", profiles: []string{"policy.reconciliation"}, + durability: "checkpointed_api_snapshot", + limitationCode: "current_state_read_without_event_cursor", available: true, + }, + { + source: "gateway_otlp", profiles: []string{"openshell.trace"}, + durability: "sender_dependent", + limitationCode: "sender_delivery_not_source_replay", available: true, + }, + { + source: "driver_otlp", profiles: []string{"openshell.trace"}, + durability: "sender_dependent", + limitationCode: "sender_delivery_not_source_replay", available: true, + }, + { + source: "relay_otlp", profiles: []string{"nemo_relay.trace"}, + durability: "sender_dependent", + limitationCode: "privacy_filtered_no_source_replay", available: true, + }, + { + source: "relay_files", profiles: []string{"nemo_relay.log"}, + durability: "checkpointed_file", + limitationCode: "persistent_upstream_file_and_checkpoint_required", available: true, + }, + { + source: "kubernetes_context", profiles: []string{"kubernetes.context"}, + durability: "resource_version_checkpointed_api", + limitationCode: "namespace_and_object_allow_list_only", available: true, + }, + { + source: "watch_events", + durability: "unavailable", + limitationCode: "public_rpc_absent", available: false, + }, +} + +func sourceCapabilitySummaries(enabledProfiles []string) []sourceCapabilitySummary { + enabled := make(map[string]struct{}, len(enabledProfiles)) + for _, profile := range enabledProfiles { + enabled[profile] = struct{}{} + } + summaries := make([]sourceCapabilitySummary, 0, len(sourceCapabilityDefinitions)) + for _, definition := range sourceCapabilityDefinitions { + configured := false + for _, profile := range definition.profiles { + if _, ok := enabled[profile]; ok { + configured = true + break + } + } + health := sourceHealthDisabled + if !definition.available { + configured = false + health = sourceHealthUnavailable + } else if configured { + health = sourceHealthUnobserved + } + summaries = append(summaries, sourceCapabilitySummary{ + Source: definition.source, Configured: configured, Observed: false, + Durability: definition.durability, Health: health, + LimitationCode: definition.limitationCode, + }) + } + return summaries +} + +func sourceCapabilityLane(kind string) string { + switch sourceProfile(kind) { + case "ocsf.file", "ocsf.forwarded": + return "ocsf_files" + case "openshell.log", "openshell.log.forwarded": + return "operational_files" + case "watchsandbox": + return "watchsandbox" + case "policy.reconciliation": + return "policy_reconciliation" + case "kubernetes.context": + return "kubernetes_context" + case "nemo_relay.log": + return "relay_files" + default: + return "" + } +} + +// boundedMetricSource prevents source-controlled acquisition kinds from becoming +// unbounded metric labels. Keep this list aligned with eventType; unsupported +// source records remain exportable, but their metric label fails closed. +func boundedMetricSource(kind string) string { + switch kind { + case "ocsf.file", + "ocsf.forwarded", + "sandbox.lifecycle", + "gateway.log", + "sandbox.log", + "nemo_relay.log", + "sandbox.file_log", + "openshell.log.forwarded", + "platform.event", + "policy.draft_updated", + "policy.draft.snapshot", + "policy.draft.chunk", + "policy.draft.history", + "policy.status", + "policy.revision", + "policy.reconciliation.warning", + "stream.warning", + "source.capability", + "kubernetes.context": + return kind + default: + return "unsupported" + } +} + +func sourceCapabilityHealthClass(health string) int64 { + switch health { + case sourceHealthDisabled: + return sourceHealthClassDisabled + case sourceHealthUnobserved: + return sourceHealthClassUnobserved + case sourceHealthHealthy: + return sourceHealthClassHealthy + case sourceHealthGapReported: + return sourceHealthClassGapReported + case sourceHealthUnavailable: + return sourceHealthClassUnavailable + default: + return sourceHealthClassUnavailable + } +} + +func sourceKindReportsGap(kind string) bool { + return kind == "stream.warning" || kind == "policy.reconciliation.warning" +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/source_capability_test.go b/projects/openshell-exporter/processor/openshellprocessor/source_capability_test.go new file mode 100644 index 00000000..78cbd6bb --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/source_capability_test.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestSourceCapabilitySummariesCoverEveryProfile(t *testing.T) { + t.Parallel() + tests := []struct { + profile string + sources []string + }{ + {profile: "ocsf.file", sources: []string{"ocsf_files"}}, + {profile: "ocsf.forwarded", sources: []string{"ocsf_files"}}, + {profile: "openshell.log", sources: []string{"operational_files"}}, + {profile: "openshell.log.forwarded", sources: []string{"operational_files"}}, + {profile: "watchsandbox", sources: []string{"watchsandbox"}}, + {profile: "policy.reconciliation", sources: []string{"policy_reconciliation"}}, + {profile: "kubernetes.context", sources: []string{"kubernetes_context"}}, + {profile: "nemo_relay.log", sources: []string{"relay_files"}}, + {profile: "nemo_relay.trace", sources: []string{"relay_otlp"}}, + {profile: "openshell.trace", sources: []string{"gateway_otlp", "driver_otlp"}}, + } + for _, test := range tests { + test := test + t.Run(test.profile, func(t *testing.T) { + t.Parallel() + summaries := sourceCapabilitySummaries([]string{test.profile}) + configured := map[string]bool{} + for _, summary := range summaries { + if summary.Configured { + configured[summary.Source] = true + if summary.Observed || summary.Health != sourceHealthUnobserved { + t.Fatalf("configured source %#v must start unobserved", summary) + } + } + } + if len(configured) != len(test.sources) { + t.Fatalf("configured=%v, want %v", configured, test.sources) + } + for _, source := range test.sources { + if !configured[source] { + t.Fatalf("source %q was not configured by profile %q", source, test.profile) + } + } + }) + } +} + +func TestSourceCapabilitySummariesDistinguishDisabledAndUnavailable(t *testing.T) { + t.Parallel() + summaries := sourceCapabilitySummaries(nil) + for _, summary := range summaries { + if summary.Source == "watch_events" { + if summary.Configured || summary.Observed || summary.Health != sourceHealthUnavailable || + summary.LimitationCode != "public_rpc_absent" { + t.Fatalf("WatchEvents summary=%#v", summary) + } + continue + } + if summary.Configured || summary.Observed || summary.Health != sourceHealthDisabled { + t.Fatalf("disabled summary=%#v", summary) + } + } +} + +func TestSourceCapabilityKindMappingAndGapHealthAreBounded(t *testing.T) { + t.Parallel() + tests := []struct { + kind string + source string + gap bool + }{ + {kind: "ocsf.file", source: "ocsf_files"}, + {kind: "ocsf.forwarded", source: "ocsf_files"}, + {kind: "sandbox.file_log", source: "operational_files"}, + {kind: "openshell.log.forwarded", source: "operational_files"}, + {kind: "sandbox.lifecycle", source: "watchsandbox"}, + {kind: "stream.warning", source: "watchsandbox", gap: true}, + {kind: "policy.draft.chunk", source: "policy_reconciliation"}, + {kind: "policy.reconciliation.warning", source: "policy_reconciliation", gap: true}, + {kind: "kubernetes.context", source: "kubernetes_context"}, + {kind: "nemo_relay.log", source: "relay_files"}, + } + for _, test := range tests { + if got := sourceCapabilityLane(test.kind); got != test.source { + t.Fatalf("kind %q source=%q, want %q", test.kind, got, test.source) + } + if got := sourceKindReportsGap(test.kind); got != test.gap { + t.Fatalf("kind %q gap=%t, want %t", test.kind, got, test.gap) + } + } + for health, want := range map[string]int64{ + sourceHealthDisabled: sourceHealthClassDisabled, sourceHealthUnobserved: sourceHealthClassUnobserved, + sourceHealthHealthy: sourceHealthClassHealthy, sourceHealthGapReported: sourceHealthClassGapReported, + sourceHealthUnavailable: sourceHealthClassUnavailable, + } { + if got := sourceCapabilityHealthClass(health); got != want { + t.Fatalf("health %q class=%d, want %d", health, got, want) + } + } +} + +func TestMetricSourceVocabularyIsBounded(t *testing.T) { + t.Parallel() + for _, kind := range []string{ + "ocsf.file", "ocsf.forwarded", "sandbox.lifecycle", "gateway.log", "sandbox.log", + "nemo_relay.log", "sandbox.file_log", "openshell.log.forwarded", "platform.event", + "policy.draft_updated", "policy.draft.snapshot", "policy.draft.chunk", + "policy.draft.history", "policy.status", "policy.revision", + "policy.reconciliation.warning", "stream.warning", "source.capability", "kubernetes.context", + } { + if got := boundedMetricSource(kind); got != kind { + t.Fatalf("kind %q metric source=%q", kind, got) + } + } + for _, kind := range []string{"", "unknown", "https://customer.example/secret", "sandbox-123"} { + if got := boundedMetricSource(kind); got != "unsupported" { + t.Fatalf("untrusted kind %q metric source=%q, want unsupported", kind, got) + } + } +} + +func TestStartupSourceCapabilitySummaryExcludesDeploymentIdentifiers(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.InfoLevel) + config := createDefaultConfig().(*Config) + config.SourceProfiles = []string{"ocsf.file", "watchsandbox", "nemo_relay.trace"} + config.GatewayID = "gateway-must-not-appear" + config.SourceInstance = "source-instance-must-not-appear" + processor, err := newProcessor(config, zap.New(core)) + if err != nil { + t.Fatal(err) + } + if err := processor.start(context.Background(), nil); err != nil { + t.Fatal(err) + } + entries := observed.AllUntimed() + if len(entries) != 1 || entries[0].Message != "OpenShell source capability summary" { + t.Fatalf("startup entries=%#v", entries) + } + encoded, err := json.Marshal(entries[0].ContextMap()) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + for _, forbidden := range []string{"gateway-must-not-appear", "source-instance-must-not-appear", "credential", "token"} { + if strings.Contains(strings.ToLower(text), strings.ToLower(forbidden)) { + t.Fatalf("startup summary leaked %q: %s", forbidden, text) + } + } + for _, required := range []string{`"schema_version":"1.0"`, `"source":"watch_events"`, `"health":"unavailable"`} { + if !strings.Contains(text, required) { + t.Fatalf("startup summary missing %q: %s", required, text) + } + } +} diff --git a/projects/openshell-exporter/processor/openshellprocessor/validation.go b/projects/openshell-exporter/processor/openshellprocessor/validation.go new file mode 100644 index 00000000..17461037 --- /dev/null +++ b/projects/openshell-exporter/processor/openshellprocessor/validation.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package openshellprocessor + +import ( + "fmt" + "math" +) + +const currentOCSFVersion = "1.8.0" + +var supportedOCSFVersions = map[string]struct{}{ + "1.7.0": {}, + currentOCSFVersion: {}, +} + +const ( + validationValid = "valid" + validationInvalid = "invalid" + validationNotApplicable = "not_applicable" +) + +type validationResult struct { + Status string + Errors []string +} + +func validateSource(kind string, original any) validationResult { + if !isOCSFEvidence(kind) { + return validationResult{Status: validationNotApplicable, Errors: []string{}} + } + event, ok := original.(map[string]any) + if !ok { + return validationResult{ + Status: validationInvalid, + Errors: []string{"body must be a JSON object"}, + } + } + errs := make([]string, 0) + for _, field := range []string{"class_uid", "category_uid", "activity_id", "type_uid"} { + value, ok := exactInteger(event[field]) + if !ok || value < 0 || (field != "activity_id" && value == 0) { + errs = append(errs, fmt.Sprintf("%s must be a positive integer", field)) + } + } + if value, ok := exactInteger(event["time"]); !ok || value <= 0 { + errs = append(errs, "time must be a positive epoch-millisecond integer") + } + if severity, present := event["severity_id"]; present { + if _, ok := exactInteger(severity); !ok { + errs = append(errs, "severity_id must be an integer when present") + } + } + metadata, ok := event["metadata"].(map[string]any) + if !ok { + errs = append(errs, "metadata must be an object") + } else { + version, ok := metadata["version"].(string) + if !ok { + errs = append(errs, "metadata.version must be a string") + } else if _, supported := supportedOCSFVersions[version]; !supported { + errs = append( + errs, + "metadata.version must equal 1.7.0 or 1.8.0", + ) + } + } + if len(errs) > 0 { + return validationResult{Status: validationInvalid, Errors: errs} + } + return validationResult{Status: validationValid, Errors: []string{}} +} + +func exactInteger(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int8: + return int64(typed), true + case int16: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case uint: + return int64(typed), uint(int64(typed)) == typed + case uint8: + return int64(typed), true + case uint16: + return int64(typed), true + case uint32: + return int64(typed), true + case uint64: + return int64(typed), uint64(int64(typed)) == typed + case float32: + value := float64(typed) + if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value { + return 0, false + } + return int64(value), float64(int64(value)) == value + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed { + return 0, false + } + return int64(typed), float64(int64(typed)) == typed + default: + return 0, false + } +} diff --git a/projects/openshell-exporter/processor/relayprocessor/config.go b/projects/openshell-exporter/processor/relayprocessor/config.go new file mode 100644 index 00000000..72781413 --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/config.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "errors" + "fmt" + "path" +) + +const ( + privacyAllow = "allow" + privacyDeny = "deny" +) + +type Config struct { + GatewayID string `mapstructure:"gateway_id"` + Workspace string `mapstructure:"workspace"` + TelemetrySource string `mapstructure:"telemetry_source"` + CanonicalizeSpanNames bool `mapstructure:"canonicalize_span_names"` + RequiredCorrelationAttributes []string `mapstructure:"required_correlation_attributes"` + Privacy PrivacyConfig `mapstructure:"privacy"` + Aliases map[string]string `mapstructure:"aliases"` +} + +type PrivacyConfig struct { + Mode string `mapstructure:"mode"` + AllowedAttributes []string `mapstructure:"allowed_attributes"` + DeniedAttributes []string `mapstructure:"denied_attributes"` +} + +func (cfg *Config) Validate() error { + if cfg.GatewayID == "" { + return errors.New("gateway_id must not be empty") + } + if !isSafeCorrelationString(cfg.GatewayID) { + return errors.New("gateway_id must be a bounded ASCII identifier") + } + if cfg.Workspace == "" { + return errors.New("workspace must not be empty") + } + if cfg.TelemetrySource == "" { + return errors.New("telemetry_source must not be empty") + } + if !isSafeCorrelationString(cfg.TelemetrySource) { + return errors.New("telemetry_source must be a bounded ASCII identifier") + } + seenRequired := map[string]struct{}{} + for _, attribute := range cfg.RequiredCorrelationAttributes { + if _, allowed := protectedAttributes[attribute]; !allowed { + return fmt.Errorf("required correlation attribute %q is not protected", attribute) + } + if _, duplicate := seenRequired[attribute]; duplicate { + return fmt.Errorf("duplicate required correlation attribute %q", attribute) + } + seenRequired[attribute] = struct{}{} + } + if !isSafeCorrelationString(cfg.Workspace) { + return errors.New("workspace must be a bounded ASCII identifier") + } + switch cfg.Privacy.Mode { + case privacyAllow: + if len(cfg.Privacy.AllowedAttributes) == 0 { + return errors.New("privacy.allowed_attributes must not be empty in allow mode") + } + case privacyDeny: + if len(cfg.Privacy.DeniedAttributes) == 0 { + return errors.New("privacy.denied_attributes must not be empty in deny mode") + } + default: + return fmt.Errorf("privacy.mode must be allow or deny, got %q", cfg.Privacy.Mode) + } + for _, pattern := range append(append([]string{}, cfg.Privacy.AllowedAttributes...), cfg.Privacy.DeniedAttributes...) { + if pattern == "" { + return errors.New("privacy attribute patterns must not be empty") + } + if _, err := path.Match(pattern, pattern); err != nil { + return fmt.Errorf("invalid privacy attribute pattern %q: %w", pattern, err) + } + } + for canonical, alias := range cfg.Aliases { + if canonical == "" || alias == "" { + return errors.New("correlation aliases must have non-empty keys and values") + } + } + return nil +} diff --git a/projects/openshell-exporter/processor/relayprocessor/factory.go b/projects/openshell-exporter/processor/relayprocessor/factory.go new file mode 100644 index 00000000..a974f80d --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/factory.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" +) + +var componentType = component.MustNewType("relay") + +func NewFactory() processor.Factory { + return processor.NewFactory( + componentType, + createDefaultConfig, + processor.WithTraces(createTracesProcessor, component.StabilityLevelBeta), + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + GatewayID: "local-gateway", + Workspace: "default", + TelemetrySource: "nemo_relay", + CanonicalizeSpanNames: true, + RequiredCorrelationAttributes: []string{"openshell.sandbox.id", "agent.session.id"}, + Privacy: PrivacyConfig{ + Mode: privacyAllow, + AllowedAttributes: []string{ + "deployment.environment", + "error.category", + "error.code", + "error.type", + "exception.type", + "gen_ai.conversation.id", + "gen_ai.operation.name", + "gen_ai.request.model", + "gen_ai.response.finish_reasons", + "gen_ai.response.id", + "gen_ai.response.model", + "gen_ai.tool.call.id", + "gen_ai.usage.*", + "http.response.status_code", + "llm.cost.*", + "llm.model_name", + "llm.provider", + "llm.token_count.*", + "nemo_relay.agent.kind", + "nemo_relay.llm.cost.*", + "nemo_relay.mark.parent_uuid", + "nemo_relay.mark.uuid", + "nemo_relay.model_name", + "nemo_relay.scope_type", + "nemo_relay.session.instance_id", + "nemo_relay.tool_call_id", + "nemo_relay.uuid", + "openinference.span.kind", + "openshell.sandbox.name", + "request.id", + "service.instance.id", + "service.name", + "service.namespace", + "service.version", + "session.id", + "telemetry.source", + "token_type", + "tokens_in", + "tokens_out", + "tool.name", + "tool_call.function.name", + "tool_call.id", + }, + DeniedAttributes: []string{ + "*authorization*", + "*credential*", + "*input*", + "*output*", + "*password*", + "*prompt*", + "*response*content*", + "*secret*", + "*token*", + "*tool*argument*", + "*tool*result*", + }, + }, + Aliases: defaultAliases(), + } +} + +func createTracesProcessor( + ctx context.Context, + settings processor.Settings, + cfg component.Config, + next consumer.Traces, +) (processor.Traces, error) { + implementation, err := newProcessor(cfg.(*Config), settings.Logger, settings.MeterProvider) + if err != nil { + return nil, err + } + return processorhelper.NewTraces( + ctx, + settings, + cfg, + next, + implementation.processTraces, + processorhelper.WithStart(implementation.start), + processorhelper.WithCapabilities(consumer.Capabilities{MutatesData: true}), + ) +} diff --git a/projects/openshell-exporter/processor/relayprocessor/metrics.go b/projects/openshell-exporter/processor/relayprocessor/metrics.go new file mode 100644 index 00000000..eeb6be21 --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/metrics.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +const relayMeterName = "github.com/NVIDIA-dev/OpenShell-exporter/relayprocessor" + +const relayMetricProducer = "relayprocessor" + +type relayMetrics struct { + spans metric.Int64Counter + attributesRemoved metric.Int64Counter + sourceEnabled metric.Int64Gauge + sourceConfigured metric.Int64Gauge + sourceObserved metric.Int64Gauge + sourceHealthClass metric.Int64Gauge + sourceCapabilityInfo metric.Int64Gauge + lastSuccess metric.Int64Gauge +} + +func newRelayMetrics(provider metric.MeterProvider) (*relayMetrics, error) { + if provider == nil { + provider = noop.NewMeterProvider() + } + meter := provider.Meter(relayMeterName) + spans, err := meter.Int64Counter( + "openshell.exporter.relay.spans", + metric.WithDescription("Relay spans processed by bounded correlation status and privacy mode."), + metric.WithUnit("{span}"), + ) + if err != nil { + return nil, err + } + attributesRemoved, err := meter.Int64Counter( + "openshell.exporter.relay.attributes_removed", + metric.WithDescription("Relay attributes removed by privacy filtering by bounded telemetry scope."), + metric.WithUnit("{attribute}"), + ) + if err != nil { + return nil, err + } + sourceEnabled, err := meter.Int64Gauge( + "openshell.exporter.source.enabled", + metric.WithDescription("Configured source profile capability; one means enabled."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceConfigured, err := meter.Int64Gauge( + "openshell.exporter.source.configured", + metric.WithDescription("Configured state for each bounded evidence lane; one means configured."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceObserved, err := meter.Int64Gauge( + "openshell.exporter.source.observed", + metric.WithDescription("Observed state for each bounded OTLP evidence lane; one means at least one resource was observed."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceCapabilityInfo, err := meter.Int64Gauge( + "openshell.exporter.source.capability_info", + metric.WithDescription("Static bounded evidence-lane capability metadata."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + sourceHealthClass, err := meter.Int64Gauge( + "openshell.exporter.source.health_class", + metric.WithDescription("Bounded evidence-lane health: 2 means healthy."), + metric.WithUnit("1"), + ) + if err != nil { + return nil, err + } + lastSuccess, err := meter.Int64Gauge( + "openshell.exporter.source.last_success_unixtime", + metric.WithDescription("Unix time of the last processed resource by bounded OTLP source."), + metric.WithUnit("s"), + ) + if err != nil { + return nil, err + } + return &relayMetrics{ + spans: spans, attributesRemoved: attributesRemoved, + sourceEnabled: sourceEnabled, sourceConfigured: sourceConfigured, + sourceObserved: sourceObserved, sourceHealthClass: sourceHealthClass, + sourceCapabilityInfo: sourceCapabilityInfo, lastSuccess: lastSuccess, + }, nil +} + +func (m *relayMetrics) capabilitiesConfigured(ctx context.Context, capabilities []relaySourceCapability) { + for _, capability := range capabilities { + attrs := metric.WithAttributes( + attribute.String("source", capability.Source), + attribute.String("producer", relayMetricProducer), + ) + m.sourceEnabled.Record(ctx, 1, metric.WithAttributes( + attribute.String("source", capability.Profile), + attribute.String("producer", relayMetricProducer), + )) + m.sourceConfigured.Record(ctx, 1, attrs) + m.sourceObserved.Record(ctx, 0, attrs) + m.sourceHealthClass.Record(ctx, 1, attrs) + m.sourceCapabilityInfo.Record(ctx, 1, metric.WithAttributes( + attribute.String("source", capability.Source), + attribute.String("durability", capability.Durability), + attribute.String("limitation", capability.Limitation), + attribute.String("producer", relayMetricProducer), + )) + } +} + +func (m *relayMetrics) recordSpan(ctx context.Context, status, privacyMode string) { + m.spans.Add(ctx, 1, metric.WithAttributes( + attribute.String("correlation_status", status), + attribute.String("privacy_mode", privacyMode), + )) +} + +func (m *relayMetrics) observeSource(ctx context.Context, source string) { + attrs := metric.WithAttributes( + attribute.String("source", boundedOTLPMetricSource(source)), + attribute.String("producer", relayMetricProducer), + ) + m.sourceObserved.Record(ctx, 1, attrs) + m.sourceHealthClass.Record(ctx, 2, attrs) + m.lastSuccess.Record(ctx, time.Now().Unix(), attrs) +} + +func boundedOTLPMetricSource(source string) string { + switch source { + case "relay_otlp", "gateway_otlp", "driver_otlp", "native_otlp_unclassified", "other_otlp": + return source + default: + return "other_otlp" + } +} + +func (m *relayMetrics) recordRemoved(ctx context.Context, scope string, count int) { + if count == 0 { + return + } + m.attributesRemoved.Add(ctx, int64(count), metric.WithAttributes( + attribute.String("scope", scope), + )) +} diff --git a/projects/openshell-exporter/processor/relayprocessor/metrics_test.go b/projects/openshell-exporter/processor/relayprocessor/metrics_test.go new file mode 100644 index 00000000..515cd148 --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/metrics_test.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +func TestRelayMetricSourceLabelsFailClosed(t *testing.T) { + t.Parallel() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + metrics, err := newRelayMetrics(provider) + if err != nil { + t.Fatal(err) + } + metrics.observeSource(context.Background(), "https://customer.example/secret-session?token=must-not-leak") + + var resourceMetrics metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resourceMetrics); err != nil { + t.Fatal(err) + } + found := 0 + for _, scope := range resourceMetrics.ScopeMetrics { + for _, metric := range scope.Metrics { + if metric.Name != "openshell.exporter.source.observed" && + metric.Name != "openshell.exporter.source.health_class" && + metric.Name != "openshell.exporter.source.last_success_unixtime" { + continue + } + data, ok := metric.Data.(metricdata.Gauge[int64]) + if !ok || len(data.DataPoints) != 1 { + t.Fatalf("metric %q data=%T points=%d", metric.Name, metric.Data, len(data.DataPoints)) + } + source, ok := data.DataPoints[0].Attributes.Value(attribute.Key("source")) + if !ok || source.AsString() != "other_otlp" { + t.Fatalf("metric %q source=%q, want other_otlp", metric.Name, source.AsString()) + } + producer, ok := data.DataPoints[0].Attributes.Value(attribute.Key("producer")) + if !ok || producer.AsString() != relayMetricProducer { + t.Fatalf("metric %q producer=%q, want %q", metric.Name, producer.AsString(), relayMetricProducer) + } + found++ + } + } + if found != 3 { + t.Fatalf("found %d source metrics, want 3", found) + } +} + +func TestRelayMetricSourceVocabularyIsBounded(t *testing.T) { + t.Parallel() + for _, source := range []string{ + "relay_otlp", "gateway_otlp", "driver_otlp", "native_otlp_unclassified", "other_otlp", + } { + if got := boundedOTLPMetricSource(source); got != source { + t.Fatalf("source %q bounded to %q", source, got) + } + } + for _, source := range []string{"", "secret-session", "https://customer.example/token"} { + if got := boundedOTLPMetricSource(source); got != "other_otlp" { + t.Fatalf("untrusted source %q bounded to %q", source, got) + } + } +} diff --git a/projects/openshell-exporter/processor/relayprocessor/native_test.go b/projects/openshell-exporter/processor/relayprocessor/native_test.go new file mode 100644 index 00000000..be20b897 --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/native_test.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + "testing" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/ptrace" + "go.uber.org/zap" +) + +func TestNativeOpenShellTracePreservesNamesAndDoesNotRequireRelayIDs(t *testing.T) { + config := createDefaultConfig().(*Config) + config.GatewayID = "gateway-1" + config.Workspace = "workspace-1" + config.TelemetrySource = "openshell_native" + config.CanonicalizeSpanNames = false + config.RequiredCorrelationAttributes = nil + config.Privacy.Mode = privacyDeny + processor, err := newProcessor(config, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetName("openshell.gateway.request") + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr("http.route", "/openshell.v1.OpenShell/WatchSandbox") + span.Attributes().PutStr("authorization.token", "must-not-leave") + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + assertString(t, gotResource, "telemetry.source", "openshell_native") + got := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0) + if got.Name() != "openshell.gateway.request" { + t.Fatalf("native span name=%q", got.Name()) + } + assertString(t, got.Attributes(), "openshell.correlation.status", "complete") + if _, ok := got.Attributes().Get("authorization.token"); ok { + t.Fatal("native trace credential was not removed") + } + if route, ok := got.Attributes().Get("http.route"); !ok || route.Str() == "" { + t.Fatal("non-sensitive native trace context was lost") + } +} diff --git a/projects/openshell-exporter/processor/relayprocessor/processor.go b/projects/openshell-exporter/processor/relayprocessor/processor.go new file mode 100644 index 00000000..71229fdc --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/processor.go @@ -0,0 +1,525 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + "fmt" + "path" + "sort" + "strings" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/ptrace" + "go.opentelemetry.io/otel/metric" + "go.uber.org/zap" +) + +var protectedAttributes = map[string]struct{}{ + "agent.session.id": {}, + "openshell.correlation.invalid": {}, + "openshell.correlation.missing": {}, + "openshell.correlation.status": {}, + "openshell.gateway.id": {}, + "openshell.policy.version": {}, + "openshell.sandbox.id": {}, + "openshell.workspace": {}, + "request_id": {}, + "telemetry.source": {}, + "trace_id": {}, + "tool_call_id": {}, +} + +const maxCorrelationIdentifierLength = 256 + +func isBenignUsageAttribute(key string, value pcommon.Value) bool { + lower := strings.ToLower(key) + if lower == "token_type" { + return value.Type() == pcommon.ValueTypeStr + } + isNumeric := value.Type() == pcommon.ValueTypeInt || value.Type() == pcommon.ValueTypeDouble + if !isNumeric { + return false + } + if lower == "tokens_in" || lower == "tokens_out" { + return true + } + for _, prefix := range []string{ + "gen_ai.usage.", + "llm.token_count.", + "nemo_relay.llm.token_count.", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func isBenignCostAttribute(key string, value pcommon.Value) bool { + isNumeric := value.Type() == pcommon.ValueTypeInt || value.Type() == pcommon.ValueTypeDouble + if !isNumeric { + return false + } + lower := strings.ToLower(key) + return strings.HasPrefix(lower, "llm.cost.") || strings.HasPrefix(lower, "nemo_relay.llm.cost.") +} + +func isUsageOrCostAttribute(key string) bool { + lower := strings.ToLower(key) + if lower == "token_type" || lower == "tokens_in" || lower == "tokens_out" { + return true + } + for _, prefix := range []string{ + "gen_ai.usage.", + "llm.cost.", + "llm.token_count.", + "nemo_relay.llm.cost.", + "nemo_relay.llm.token_count.", + } { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +type processorImpl struct { + config *Config + logger *zap.Logger + metrics *relayMetrics +} + +type relaySourceCapability struct { + Source string `json:"source"` + Profile string `json:"profile"` + Durability string `json:"durability"` + Limitation string `json:"limitation_code"` +} + +func relaySourceCapabilities(telemetrySource string) []relaySourceCapability { + switch telemetrySource { + case "nemo_relay": + return []relaySourceCapability{{ + Source: "relay_otlp", Profile: "nemo_relay.trace", + Durability: "sender_dependent", Limitation: "privacy_filtered_no_source_replay", + }} + case "openshell_native": + return []relaySourceCapability{ + {Source: "gateway_otlp", Profile: "openshell.trace", Durability: "sender_dependent", Limitation: "sender_delivery_not_source_replay"}, + {Source: "driver_otlp", Profile: "openshell.trace", Durability: "sender_dependent", Limitation: "sender_delivery_not_source_replay"}, + } + default: + return []relaySourceCapability{{ + Source: "other_otlp", Profile: "other_otlp", + Durability: "sender_dependent", Limitation: "unclassified_sender_no_source_replay", + }} + } +} + +func (p *processorImpl) start(ctx context.Context, _ component.Host) error { + capabilities := relaySourceCapabilities(p.config.TelemetrySource) + p.logger.Info( + "OpenShell OTLP source capability summary", + zap.String("schema_version", "1.0"), + zap.Any("sources", capabilities), + ) + p.metrics.capabilitiesConfigured(ctx, capabilities) + return nil +} + +func newProcessor(config *Config, logger *zap.Logger, meterProvider metric.MeterProvider) (*processorImpl, error) { + if err := config.Validate(); err != nil { + return nil, err + } + metrics, err := newRelayMetrics(meterProvider) + if err != nil { + return nil, fmt.Errorf("create Relay processor metrics: %w", err) + } + return &processorImpl{config: config, logger: logger, metrics: metrics}, nil +} + +func defaultAliases() map[string]string { + return map[string]string{ + "agent.session.id": "session.id", + "openshell.policy.version": "policy.version", + "request_id": "gen_ai.request.id", + "tool_call_id": "nemo_relay.tool_call_id", + } +} + +func boundedOTLPSource(telemetrySource string, resource pcommon.Map) string { + if telemetrySource == "nemo_relay" { + return "relay_otlp" + } + if telemetrySource != "openshell_native" { + return "other_otlp" + } + for _, key := range []string{"service.name", "service.namespace", "openshell.component"} { + value, ok := resource.Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr { + continue + } + name := strings.ToLower(value.Str()) + switch { + case strings.Contains(name, "driver"): + return "driver_otlp" + case strings.Contains(name, "gateway"): + return "gateway_otlp" + } + } + return "native_otlp_unclassified" +} + +func (p *processorImpl) processTraces(ctx context.Context, traces ptrace.Traces) (ptrace.Traces, error) { + for resourceIndex := 0; resourceIndex < traces.ResourceSpans().Len(); resourceIndex++ { + resourceSpans := traces.ResourceSpans().At(resourceIndex) + resource := resourceSpans.Resource().Attributes() + source := boundedOTLPSource(p.config.TelemetrySource, resource) + traceContexts := collectTraceContexts(resourceSpans) + resource.PutStr("telemetry.source", p.config.TelemetrySource) + resource.PutStr("openshell.gateway.id", p.config.GatewayID) + resource.PutStr("openshell.workspace", p.config.Workspace) + resourceInvalid := sanitizeCorrelationIdentifiers(resource) + + for scopeIndex := 0; scopeIndex < resourceSpans.ScopeSpans().Len(); scopeIndex++ { + scopeSpans := resourceSpans.ScopeSpans().At(scopeIndex) + spans := scopeSpans.Spans() + for spanIndex := 0; spanIndex < spans.Len(); spanIndex++ { + span := spans.At(spanIndex) + status := p.normalize(resource, span.Attributes(), traceContexts[span.TraceID().String()], span.TraceID(), resourceInvalid) + p.metrics.recordSpan(ctx, status, p.config.Privacy.Mode) + p.metrics.recordRemoved(ctx, "span", p.filter(span.Attributes(), true)) + span.Status().SetMessage("") + if p.config.Privacy.Mode == privacyAllow { + if p.config.CanonicalizeSpanNames { + span.SetName(canonicalSpanName(span.Attributes())) + } + span.TraceState().FromRaw("") + } + for eventIndex := 0; eventIndex < span.Events().Len(); eventIndex++ { + event := span.Events().At(eventIndex) + sanitizeCorrelationIdentifiers(event.Attributes()) + p.metrics.recordRemoved(ctx, "event", p.filter(event.Attributes(), false)) + if p.config.Privacy.Mode == privacyAllow { + event.SetName("relay.event") + } + } + for linkIndex := 0; linkIndex < span.Links().Len(); linkIndex++ { + link := span.Links().At(linkIndex) + sanitizeCorrelationIdentifiers(link.Attributes()) + p.metrics.recordRemoved(ctx, "link", p.filter(link.Attributes(), false)) + if p.config.Privacy.Mode == privacyAllow { + link.TraceState().FromRaw("") + } + } + } + sanitizeCorrelationIdentifiers(scopeSpans.Scope().Attributes()) + p.metrics.recordRemoved(ctx, "scope", p.filter(scopeSpans.Scope().Attributes(), false)) + } + p.metrics.recordRemoved(ctx, "resource", p.filter(resource, false)) + p.metrics.observeSource(ctx, source) + } + return traces, nil +} + +func canonicalSpanName(attributes pcommon.Map) string { + for _, key := range []string{"openinference.span.kind", "gen_ai.operation.name", "nemo_relay.scope_type"} { + value, ok := attributes.Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr { + continue + } + name := strings.ToLower(value.Str()) + switch { + case strings.Contains(name, "tool"): + return "relay.tool" + case strings.Contains(name, "llm"), strings.Contains(name, "model"), strings.Contains(name, "chat"): + return "relay.model" + case strings.Contains(name, "chain"): + return "relay.chain" + case strings.Contains(name, "agent"), strings.Contains(name, "session"): + return "relay.agent" + } + } + return "relay.span" +} + +func (p *processorImpl) normalize( + resource pcommon.Map, + attributes pcommon.Map, + traceContext map[string]pcommon.Value, + traceID pcommon.TraceID, + resourceInvalid []string, +) string { + for _, canonical := range []string{ + "openshell.gateway.id", + "openshell.workspace", + "openshell.sandbox.id", + "openshell.policy.version", + } { + attributes.Remove(canonical) + value, exists := firstValue(canonical, resource) + if !exists { + if alias, configured := p.config.Aliases[canonical]; configured { + value, exists = firstValue(alias, resource) + } + } + if exists { + value.CopyTo(attributes.PutEmpty(canonical)) + } + } + if traceID == (pcommon.TraceID{}) { + attributes.Remove("trace_id") + } else { + attributes.PutStr("trace_id", traceID.String()) + } + + for canonical, alias := range p.config.Aliases { + if _, exists := firstValue(canonical, attributes); exists { + continue + } + if value, ok := firstValue(alias, attributes, resource); ok { + value.CopyTo(attributes.PutEmpty(canonical)) + continue + } + if value, ok := traceContext[canonical]; ok { + value.CopyTo(attributes.PutEmpty(canonical)) + } + } + if _, exists := firstValue("tool_call_id", attributes); !exists { + for _, alias := range []string{"gen_ai.tool.call.id", "tool_call.id", "tool_call_id"} { + if value, ok := firstValue(alias, attributes, resource); ok { + value.CopyTo(attributes.PutEmpty("tool_call_id")) + break + } + } + } + if _, exists := firstValue("request_id", attributes); !exists { + for _, alias := range []string{"request.id", "nemo_relay.request_id"} { + if value, ok := firstValue(alias, attributes, resource); ok { + value.CopyTo(attributes.PutEmpty("request_id")) + break + } + } + } + if _, exists := firstValue("agent.session.id", attributes); !exists { + for _, alias := range []string{"nemo_relay.session.instance_id", "nemo_relay.session_id"} { + if value, ok := firstValue(alias, attributes, resource); ok { + value.CopyTo(attributes.PutEmpty("agent.session.id")) + break + } + } + } + + invalid := append([]string{}, resourceInvalid...) + invalid = append(invalid, sanitizeCorrelationIdentifiers(attributes)...) + invalid = uniqueSortedStrings(invalid) + if len(invalid) == 0 { + attributes.Remove("openshell.correlation.invalid") + } else { + attributes.PutStr("openshell.correlation.invalid", strings.Join(invalid, ",")) + } + + missing := make([]string, 0, len(p.config.RequiredCorrelationAttributes)) + for _, key := range p.config.RequiredCorrelationAttributes { + value, ok := firstValue(key, attributes, resource) + if !ok || value.Type() != pcommon.ValueTypeStr || value.Str() == "" { + missing = append(missing, key) + } + } + if len(missing) == 0 && len(invalid) == 0 { + attributes.PutStr("openshell.correlation.status", "complete") + attributes.Remove("openshell.correlation.missing") + return "complete" + } + attributes.PutStr("openshell.correlation.status", "partial") + attributes.PutStr("openshell.correlation.missing", strings.Join(missing, ",")) + return "partial" +} + +func sanitizeCorrelationIdentifiers(attributes pcommon.Map) []string { + invalid := make([]string, 0) + for _, key := range []string{ + "openshell.gateway.id", + "openshell.workspace", + "openshell.sandbox.id", + "agent.session.id", + "trace_id", + "request_id", + "tool_call_id", + "openshell.policy.version", + } { + value, ok := attributes.Get(key) + if !ok { + continue + } + if isSafeCorrelationValue(key, value) { + continue + } + attributes.Remove(key) + invalid = append(invalid, key) + } + return invalid +} + +func isSafeCorrelationValue(key string, value pcommon.Value) bool { + if key == "openshell.policy.version" && value.Type() == pcommon.ValueTypeInt { + return value.Int() >= 0 + } + if value.Type() != pcommon.ValueTypeStr { + return false + } + if key == "trace_id" { + candidate := value.Str() + if len(candidate) != 32 { + return false + } + for index := range len(candidate) { + character := candidate[index] + if (character < '0' || character > '9') && (character < 'a' || character > 'f') { + return false + } + } + return true + } + return isSafeCorrelationString(value.Str()) +} + +func isSafeCorrelationString(candidate string) bool { + if len(candidate) == 0 || len(candidate) > maxCorrelationIdentifierLength { + return false + } + for index := range len(candidate) { + character := candidate[index] + if (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') { + continue + } + switch character { + case '.', '_', ':', '/', '@', '+', '-': + continue + default: + return false + } + } + return true +} + +func uniqueSortedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + sort.Strings(values) + unique := values[:0] + for _, value := range values { + if len(unique) == 0 || unique[len(unique)-1] != value { + unique = append(unique, value) + } + } + return unique +} + +func collectTraceContexts(resourceSpans ptrace.ResourceSpans) map[string]map[string]pcommon.Value { + contexts := make(map[string]map[string]pcommon.Value) + for scopeIndex := 0; scopeIndex < resourceSpans.ScopeSpans().Len(); scopeIndex++ { + spans := resourceSpans.ScopeSpans().At(scopeIndex).Spans() + for spanIndex := 0; spanIndex < spans.Len(); spanIndex++ { + span := spans.At(spanIndex) + traceID := span.TraceID().String() + context := contexts[traceID] + if context == nil { + context = make(map[string]pcommon.Value) + contexts[traceID] = context + } + for canonical, aliases := range map[string][]string{ + "agent.session.id": {"agent.session.id", "session.id", "nemo_relay.session.instance_id", "nemo_relay.session_id"}, + "request_id": {"request_id", "request.id", "gen_ai.request.id", "nemo_relay.request_id"}, + } { + if _, exists := context[canonical]; exists { + continue + } + for _, alias := range aliases { + if value, ok := firstValue(alias, span.Attributes()); ok { + copyValue := pcommon.NewValueEmpty() + value.CopyTo(copyValue) + context[canonical] = copyValue + break + } + } + } + } + } + return contexts +} + +func firstValue(key string, maps ...pcommon.Map) (pcommon.Value, bool) { + for _, values := range maps { + if value, ok := values.Get(key); ok { + if value.Type() == pcommon.ValueTypeStr && value.Str() == "" { + continue + } + return value, true + } + } + return pcommon.Value{}, false +} + +// normalizedSpan is true only for span attributes after normalize has regenerated +// the internal correlation markers. In allow mode, other containers cannot supply them. +func (p *processorImpl) filter(attributes pcommon.Map, normalizedSpan bool) int { + removed := 0 + keys := make([]string, 0, attributes.Len()) + attributes.Range(func(key string, _ pcommon.Value) bool { + keys = append(keys, key) + return true + }) + sort.Strings(keys) + for _, key := range keys { + if p.config.Privacy.Mode == privacyAllow && !normalizedSpan { + switch key { + case "openshell.correlation.status", "openshell.correlation.invalid", "openshell.correlation.missing": + attributes.Remove(key) + removed++ + continue + } + } + if _, protected := protectedAttributes[key]; protected { + continue + } + value, _ := attributes.Get(key) + if p.config.Privacy.Mode == privacyAllow && + isUsageOrCostAttribute(key) && + !isBenignUsageAttribute(key, value) && + !isBenignCostAttribute(key, value) { + attributes.Remove(key) + removed++ + continue + } + if p.config.Privacy.Mode == privacyDeny && isBenignUsageAttribute(key, value) { + continue + } + lower := strings.ToLower(key) + keep := p.config.Privacy.Mode == privacyDeny + patterns := p.config.Privacy.DeniedAttributes + if p.config.Privacy.Mode == privacyAllow { + patterns = p.config.Privacy.AllowedAttributes + keep = false + } + for _, pattern := range patterns { + matched, _ := path.Match(strings.ToLower(pattern), lower) + if !matched { + continue + } + keep = p.config.Privacy.Mode == privacyAllow + break + } + if !keep { + attributes.Remove(key) + removed++ + } + } + return removed +} diff --git a/projects/openshell-exporter/processor/relayprocessor/processor_test.go b/projects/openshell-exporter/processor/relayprocessor/processor_test.go new file mode 100644 index 00000000..911936ae --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/processor_test.go @@ -0,0 +1,653 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "testing" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/ptrace" + "go.uber.org/zap" +) + +func TestNormalizeAndAllowListRelayTrace(t *testing.T) { + cfg := createDefaultConfig().(*Config) + cfg.GatewayID = "gateway-1" + cfg.Workspace = "workspace-1" + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("service.name", "hermes-agent") + resource.PutStr("openshell.sandbox.id", "sandbox-1") + resource.PutInt("openshell.policy.version", 7) + resource.PutStr("credential.value", "must-not-leave") + spans := resourceSpans.ScopeSpans().AppendEmpty().Spans() + root := spans.AppendEmpty() + root.SetTraceID(pcommon.TraceID([16]byte{1})) + root.SetSpanID(pcommon.SpanID([8]byte{1})) + root.Attributes().PutStr("nemo_relay.session.instance_id", "session-1") + root.Attributes().PutStr("input.value", "private prompt") + root.Attributes().PutInt("llm.token_count.total", 42) + root.Status().SetMessage("response contained a secret") + child := spans.AppendEmpty() + child.SetTraceID(root.TraceID()) + child.SetSpanID(pcommon.SpanID([8]byte{2})) + child.Attributes().PutStr("openshell.gateway.id", "forged-gateway") + child.Attributes().PutStr("openshell.workspace", "forged-workspace") + child.Attributes().PutStr("openshell.sandbox.id", "forged-sandbox") + child.Attributes().PutStr("trace_id", "forged-trace") + child.Attributes().PutStr("nemo_relay.tool_call_id", "call-1") + child.Attributes().PutStr("tool.parameters", `{"password":"secret"}`) + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + assertString(t, gotResource, "openshell.gateway.id", "gateway-1") + assertString(t, gotResource, "openshell.workspace", "workspace-1") + assertString(t, gotResource, "openshell.sandbox.id", "sandbox-1") + if _, ok := gotResource.Get("credential.value"); ok { + t.Fatal("credential resource attribute was not removed") + } + gotSpans := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans() + for index := 0; index < gotSpans.Len(); index++ { + attributes := gotSpans.At(index).Attributes() + assertString(t, attributes, "openshell.gateway.id", "gateway-1") + assertString(t, attributes, "openshell.workspace", "workspace-1") + assertString(t, attributes, "openshell.sandbox.id", "sandbox-1") + assertString(t, attributes, "agent.session.id", "session-1") + assertString(t, attributes, "trace_id", pcommon.TraceID([16]byte{1}).String()) + policyVersion, ok := attributes.Get("openshell.policy.version") + if !ok || policyVersion.Type() != pcommon.ValueTypeInt || policyVersion.Int() != 7 { + t.Fatalf("span %d openshell.policy.version=%v, want 7", index, policyVersion.AsRaw()) + } + } + assertString(t, gotSpans.At(1).Attributes(), "tool_call_id", "call-1") + assertString(t, gotSpans.At(1).Attributes(), "openshell.correlation.status", "complete") + if _, ok := gotSpans.At(0).Attributes().Get("input.value"); ok { + t.Fatal("prompt content was not removed") + } + if _, ok := gotSpans.At(1).Attributes().Get("tool.parameters"); ok { + t.Fatal("tool arguments were not removed") + } + if gotSpans.At(0).Status().Message() != "" { + t.Fatal("status message was not removed") + } +} + +func TestDenyListPreservesBenignUsageAndRemovesSensitiveContent(t *testing.T) { + cfg := createDefaultConfig().(*Config) + cfg.GatewayID = "gateway-1" + cfg.Workspace = "workspace-1" + cfg.Privacy.Mode = privacyDeny + cfg.Privacy.DeniedAttributes = append(cfg.Privacy.DeniedAttributes, "*cost*") + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("openshell.sandbox.id", "sandbox-1") + resource.PutStr("authorization.token", "must-not-leave") + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr("session.id", "session-1") + span.Attributes().PutStr("input.value", "private prompt") + span.Attributes().PutStr("output.value", "private response") + span.Attributes().PutStr("tool.arguments", `{"password":"secret"}`) + span.Attributes().PutStr("gen_ai.usage.prompt", "usage-shaped secret") + span.Attributes().PutStr("llm.token_count.raw", "counter-shaped secret") + span.Attributes().PutInt("gen_ai.usage.input_tokens", 21) + span.Attributes().PutInt("llm.token_count.total", 34) + span.Attributes().PutInt("nemo_relay.llm.token_count.output", 13) + span.Attributes().PutInt("tokens_in", 21) + span.Attributes().PutInt("tokens_out", 13) + span.Attributes().PutStr("token_type", "billing") + span.Attributes().PutDouble("llm.cost.total", 0.25) + span.Status().SetMessage("response contained a secret") + event := span.Events().AppendEmpty() + event.Attributes().PutStr("prompt.content", "event secret") + event.Attributes().PutInt("gen_ai.usage.output_tokens", 13) + link := span.Links().AppendEmpty() + link.Attributes().PutStr("credential.value", "link secret") + link.Attributes().PutInt("llm.token_count.input", 21) + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + if _, ok := gotResource.Get("authorization.token"); ok { + t.Fatal("authorization token was not removed") + } + gotSpan := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0) + attributes := gotSpan.Attributes() + assertString(t, attributes, "agent.session.id", "session-1") + assertString(t, attributes, "openshell.correlation.status", "complete") + for _, key := range []string{"input.value", "output.value", "tool.arguments", "gen_ai.usage.prompt", "llm.token_count.raw", "llm.cost.total"} { + if _, ok := attributes.Get(key); ok { + t.Fatalf("sensitive attribute %s was not removed", key) + } + } + for key, expected := range map[string]int64{ + "gen_ai.usage.input_tokens": 21, + "llm.token_count.total": 34, + "nemo_relay.llm.token_count.output": 13, + "tokens_in": 21, + "tokens_out": 13, + } { + value, ok := attributes.Get(key) + if !ok || value.Type() != pcommon.ValueTypeInt || value.Int() != expected { + t.Fatalf("%s=%v, want %d", key, value.AsRaw(), expected) + } + } + assertString(t, attributes, "token_type", "billing") + if gotSpan.Status().Message() != "" { + t.Fatal("status message was not removed") + } + if _, ok := gotSpan.Events().At(0).Attributes().Get("prompt.content"); ok { + t.Fatal("event prompt was not removed") + } + if _, ok := gotSpan.Events().At(0).Attributes().Get("gen_ai.usage.output_tokens"); !ok { + t.Fatal("event usage counter was removed") + } + if _, ok := gotSpan.Links().At(0).Attributes().Get("credential.value"); ok { + t.Fatal("link credential was not removed") + } + if _, ok := gotSpan.Links().At(0).Attributes().Get("llm.token_count.input"); !ok { + t.Fatal("link usage counter was removed") + } +} + +func TestDefaultAllowListDoesNotTrustBroadPrefixesOrUsageShapes(t *testing.T) { + cfg := createDefaultConfig().(*Config) + cfg.GatewayID = "gateway-1" + cfg.Workspace = "workspace-1" + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("service.name", "hermes-agent") + resource.PutStr("service.password", "must-not-leave") + resource.PutStr("openshell.sandbox.id", "sandbox-1") + resource.PutStr("openshell.sandbox.name", "sandbox-name") + resource.PutStr("openshell.prompt", "must-not-leave") + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr("session.id", "session-1") + span.Attributes().PutStr("error.type", "TimeoutError") + span.Attributes().PutStr("error.message", "credential-shaped failure detail") + span.Attributes().PutStr("nemo_relay.mark.uuid", "mark-1") + span.Attributes().PutStr("nemo_relay.mark.data.result", "tool result") + span.Attributes().PutStr("gen_ai.usage.prompt", "private prompt") + span.Attributes().PutInt("gen_ai.usage.input_tokens", 21) + span.Attributes().PutStr("llm.cost.explanation", "private billing context") + span.Attributes().PutDouble("llm.cost.total", 0.25) + span.Attributes().PutInt("tokens_in", 21) + span.Attributes().PutStr("token_type", "billing") + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + assertString(t, gotResource, "service.name", "hermes-agent") + assertString(t, gotResource, "openshell.sandbox.id", "sandbox-1") + assertString(t, gotResource, "openshell.sandbox.name", "sandbox-name") + for _, key := range []string{"service.password", "openshell.prompt"} { + if _, ok := gotResource.Get(key); ok { + t.Fatalf("content-bearing resource attribute %s survived the default allow-list", key) + } + } + + attributes := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + for key, expected := range map[string]string{ + "agent.session.id": "session-1", + "error.type": "TimeoutError", + "nemo_relay.mark.uuid": "mark-1", + "token_type": "billing", + } { + assertString(t, attributes, key, expected) + } + for _, key := range []string{ + "error.message", + "gen_ai.usage.prompt", + "llm.cost.explanation", + "nemo_relay.mark.data.result", + } { + if _, ok := attributes.Get(key); ok { + t.Fatalf("content-bearing span attribute %s survived the default allow-list", key) + } + } + for key, expected := range map[string]float64{ + "gen_ai.usage.input_tokens": 21, + "llm.cost.total": 0.25, + "tokens_in": 21, + } { + value, ok := attributes.Get(key) + if !ok { + t.Fatalf("safe quantitative attribute %s was removed", key) + } + if value.Type() == pcommon.ValueTypeInt { + if float64(value.Int()) != expected { + t.Fatalf("%s=%v, want %v", key, value.AsRaw(), expected) + } + continue + } + if value.Type() != pcommon.ValueTypeDouble || value.Double() != expected { + t.Fatalf("%s=%v, want %v", key, value.AsRaw(), expected) + } + } +} + +func TestResourceAliasesAreNormalizedBeforePrivacyFiltering(t *testing.T) { + cfg := createDefaultConfig().(*Config) + cfg.GatewayID = "gateway-1" + cfg.Workspace = "workspace-1" + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("openshell.sandbox.id", "sandbox-resource") + resource.PutStr("nemo_relay.session_id", "session-resource") + resource.PutStr("nemo_relay.request_id", "request-resource") + resource.PutStr("gen_ai.tool.call.id", "call-resource") + resource.PutInt("policy.version", 42) + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr("agent.session.id", "") + span.Attributes().PutStr("request_id", "") + span.Attributes().PutStr("tool_call_id", "") + span.Attributes().PutStr("session.id", "") + span.Attributes().PutStr("nemo_relay.session_id", "") + span.Attributes().PutStr("gen_ai.request.id", "") + span.Attributes().PutStr("nemo_relay.request_id", "") + span.Attributes().PutStr("nemo_relay.tool_call_id", "") + span.Attributes().PutStr("gen_ai.tool.call.id", "") + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + for _, removedAlias := range []string{"nemo_relay.session_id", "nemo_relay.request_id", "policy.version"} { + if _, ok := gotResource.Get(removedAlias); ok { + t.Fatalf("resource alias %s bypassed the allow-list", removedAlias) + } + } + attributes := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + assertString(t, attributes, "agent.session.id", "session-resource") + assertString(t, attributes, "request_id", "request-resource") + assertString(t, attributes, "tool_call_id", "call-resource") + assertString(t, attributes, "openshell.gateway.id", "gateway-1") + assertString(t, attributes, "openshell.workspace", "workspace-1") + assertString(t, attributes, "openshell.sandbox.id", "sandbox-resource") + assertString(t, attributes, "trace_id", pcommon.TraceID([16]byte{1}).String()) + assertString(t, attributes, "openshell.correlation.status", "complete") + policyVersion, ok := attributes.Get("openshell.policy.version") + if !ok || policyVersion.Type() != pcommon.ValueTypeInt || policyVersion.Int() != 42 { + t.Fatalf("openshell.policy.version=%v, want 42", policyVersion.AsRaw()) + } +} + +func TestMissingNativeTraceIDRemovesSourceClaim(t *testing.T) { + cfg := createDefaultConfig().(*Config) + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + traces := ptrace.NewTraces() + span := traces.ResourceSpans().AppendEmpty().ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.Attributes().PutStr("trace_id", "source-invented-trace") + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + attributes := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + if _, ok := attributes.Get("trace_id"); ok { + t.Fatal("source-supplied trace_id survived without a native OTLP trace identity") + } +} + +func TestMissingSandboxIsVisible(t *testing.T) { + cfg := createDefaultConfig().(*Config) + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + traces := ptrace.NewTraces() + span := traces.ResourceSpans().AppendEmpty().ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.Attributes().PutStr("session.id", "session-1") + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + attributes := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + assertString(t, attributes, "openshell.correlation.status", "partial") + assertString(t, attributes, "openshell.correlation.missing", "openshell.sandbox.id") +} + +func TestInvalidProtectedIdentifierValuesCannotBypassPrivacy(t *testing.T) { + cfg := createDefaultConfig().(*Config) + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resource := resourceSpans.Resource().Attributes() + resource.PutStr("openshell.sandbox.id", "sandbox secret=must-not-leave") + resource.PutStr("openshell.policy.version", "Bearer must-not-leave") + resource.PutStr("agent.session.id", "credential=must-not-leave") + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.Attributes().PutStr("openshell.sandbox.id", "forged-sandbox") + span.Attributes().PutStr("agent.session.id", "prompt content must-not-leave") + span.Attributes().PutStr("request_id", "response content must-not-leave") + span.Attributes().PutStr("tool_call_id", `{"arguments":"must-not-leave"}`) + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotResource := processed.ResourceSpans().At(0).Resource().Attributes() + for _, key := range []string{"openshell.sandbox.id", "openshell.policy.version", "agent.session.id"} { + if _, ok := gotResource.Get(key); ok { + t.Fatalf("invalid protected resource attribute %s survived", key) + } + } + attributes := processed.ResourceSpans().At(0).ScopeSpans().At(0).Spans().At(0).Attributes() + for _, key := range []string{"openshell.sandbox.id", "openshell.policy.version", "agent.session.id", "request_id", "tool_call_id"} { + if _, ok := attributes.Get(key); ok { + t.Fatalf("invalid protected span attribute %s survived", key) + } + } + assertString(t, attributes, "openshell.correlation.status", "partial") + assertString(t, attributes, "openshell.correlation.missing", "openshell.sandbox.id,agent.session.id") + assertString(t, attributes, "openshell.correlation.invalid", "agent.session.id,openshell.policy.version,openshell.sandbox.id,request_id,tool_call_id") + var marshaler ptrace.ProtoMarshaler + encoded, err := marshaler.MarshalTraces(processed) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("must-not-leave")) { + t.Fatal("invalid protected identifier content survived serialized telemetry") + } +} + +func TestPrivacyConfigurationValidation(t *testing.T) { + cfg := createDefaultConfig().(*Config) + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + cfg.Privacy.Mode = "unknown" + if err := cfg.Validate(); err == nil { + t.Fatal("expected invalid privacy mode to fail") + } + cfg = createDefaultConfig().(*Config) + cfg.GatewayID = "gateway secret" + if err := cfg.Validate(); err == nil { + t.Fatal("expected unsafe gateway identity to fail") + } + cfg = createDefaultConfig().(*Config) + cfg.Workspace = "workspace=secret" + if err := cfg.Validate(); err == nil { + t.Fatal("expected unsafe workspace identity to fail") + } +} + +func assertString(t *testing.T, attributes pcommon.Map, key, expected string) { + t.Helper() + value, ok := attributes.Get(key) + if !ok || value.Type() != pcommon.ValueTypeStr || value.Str() != expected { + t.Fatalf("%s=%v, want %q", key, value.AsRaw(), expected) + } +} + +func TestAllowListNormalizesUnboundedOTLPNamesAndScopeMetadata(t *testing.T) { + cfg := createDefaultConfig().(*Config) + processor, err := newProcessor(cfg, zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + traces := ptrace.NewTraces() + scopeSpans := traces.ResourceSpans().AppendEmpty().ScopeSpans().AppendEmpty() + scopeSpans.Scope().Attributes().PutStr("prompt.content", "must-not-leave") + span := scopeSpans.Spans().AppendEmpty() + span.SetName("private prompt must-not-leave") + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.TraceState().FromRaw("vendor=must-not-leave") + span.Attributes().PutStr("openinference.span.kind", "TOOL") + event := span.Events().AppendEmpty() + event.SetName("private tool result must-not-leave") + event.Attributes().PutStr("tool.result", "must-not-leave") + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + gotScope := processed.ResourceSpans().At(0).ScopeSpans().At(0) + if _, ok := gotScope.Scope().Attributes().Get("prompt.content"); ok { + t.Fatal("scope prompt attribute was not removed") + } + gotSpan := gotScope.Spans().At(0) + if gotSpan.Name() != "relay.tool" { + t.Fatalf("span name = %q, want relay.tool", gotSpan.Name()) + } + if gotSpan.TraceState().AsRaw() != "" { + t.Fatal("trace state was not removed") + } + if gotSpan.Events().At(0).Name() != "relay.event" { + t.Fatalf("event name = %q, want relay.event", gotSpan.Events().At(0).Name()) + } + var marshaler ptrace.ProtoMarshaler + encoded, err := marshaler.MarshalTraces(processed) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("must-not-leave")) { + t.Fatal("unbounded OTLP metadata survived serialized telemetry") + } +} + +func FuzzAllowListNeverSerializesUntrustedContent(f *testing.F) { + f.Add([]byte("private prompt and tool result")) + f.Add([]byte{0, 1, 2, 3, 255}) + f.Fuzz(func(t *testing.T, input []byte) { + if len(input) > 64*1024 { + t.Skip() + } + digest := sha256.Sum256(input) + key := "untrusted." + hex.EncodeToString(digest[:8]) + sentinel := "private-" + hex.EncodeToString(digest[:]) + + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resourceSpans.Resource().Attributes().PutStr(key, sentinel) + scopeSpans := resourceSpans.ScopeSpans().AppendEmpty() + scopeSpans.Scope().Attributes().PutStr(key, sentinel) + span := scopeSpans.Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr(key, sentinel) + span.Events().AppendEmpty().Attributes().PutStr(key, sentinel) + span.Links().AppendEmpty().Attributes().PutStr(key, sentinel) + + processed, err := processor.processTraces(context.Background(), traces) + if err != nil { + t.Fatal(err) + } + var marshaler ptrace.ProtoMarshaler + encoded, err := marshaler.MarshalTraces(processed) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte(sentinel)) { + t.Fatal("privacy allow list serialized untrusted content") + } + }) +} + +func BenchmarkRelayAllowListProcessing(b *testing.B) { + processor, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop(), nil) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + traces := ptrace.NewTraces() + resourceSpans := traces.ResourceSpans().AppendEmpty() + resourceSpans.Resource().Attributes().PutStr("openshell.sandbox.id", "sandbox-1") + span := resourceSpans.ScopeSpans().AppendEmpty().Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID([16]byte{1})) + span.SetSpanID(pcommon.SpanID([8]byte{1})) + span.Attributes().PutStr("nemo_relay.session.instance_id", "session-1") + span.Attributes().PutStr("input.value", "private prompt") + span.Attributes().PutStr("tool.arguments", `{"password":"secret"}`) + span.Attributes().PutInt("gen_ai.usage.input_tokens", 128) + if _, err := processor.processTraces(context.Background(), traces); err != nil { + b.Fatal(err) + } + } +} + +func TestAllowModeRejectsInputCorrelationMarkers(t *testing.T) { + for _, container := range []string{"resource", "scope", "event", "link"} { + for _, key := range []string{"openshell.correlation.status", "openshell.correlation.invalid", "openshell.correlation.missing"} { + for _, complete := range []bool{false, true} { + status := "partial" + if complete { + status = "complete" + } + t.Run(container+"/"+key+"/"+status, func(t *testing.T) { + const secret = "Bearer review-private-token" + traces := ptrace.NewTraces() + rs := traces.ResourceSpans().AppendEmpty() + rs.Resource().Attributes().PutStr("openshell.sandbox.id", "sandbox-1") + ss := rs.ScopeSpans().AppendEmpty() + span := ss.Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID{1}) + span.SetSpanID(pcommon.SpanID{1}) + // Span input markers must also be replaced by normalization. + for _, marker := range []string{"openshell.correlation.status", "openshell.correlation.invalid", "openshell.correlation.missing"} { + span.Attributes().PutStr(marker, secret) + } + if complete { + span.Attributes().PutStr("agent.session.id", "session-1") + } else { + span.Attributes().PutStr("agent.session.id", secret) + } + event := span.Events().AppendEmpty() + link := span.Links().AppendEmpty() + attrs := map[string]pcommon.Map{"resource": rs.Resource().Attributes(), "scope": ss.Scope().Attributes(), "event": event.Attributes(), "link": link.Attributes()}[container] + attrs.PutStr(key, secret) + attrs.PutStr("tool_call_id", "safe-call-1") + p, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + if _, err = p.processTraces(context.Background(), traces); err != nil { + t.Fatal(err) + } + encoded, err := (&ptrace.JSONMarshaler{}).MarshalTraces(traces) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("review-private-token")) { + t.Error("input correlation marker leaked in serialized telemetry") + } + if _, exists := attrs.Get(key); exists { + t.Error("input correlation marker retained outside span") + } + assertString(t, attrs, "tool_call_id", "safe-call-1") + assertString(t, span.Attributes(), "openshell.sandbox.id", "sandbox-1") + assertString(t, span.Attributes(), "openshell.correlation.status", status) + if complete { + assertString(t, span.Attributes(), "agent.session.id", "session-1") + for _, marker := range []string{"openshell.correlation.invalid", "openshell.correlation.missing"} { + if _, exists := span.Attributes().Get(marker); exists { + t.Errorf("complete span retained %s", marker) + } + } + } else { + assertString(t, span.Attributes(), "openshell.correlation.invalid", "agent.session.id") + assertString(t, span.Attributes(), "openshell.correlation.missing", "agent.session.id") + } + }) + } + } + } +} + +func TestPrivacyCoversEveryTraceContainer(t *testing.T) { + for _, container := range []string{"resource", "span", "scope", "event", "link"} { + t.Run(container, func(t *testing.T) { + traces := ptrace.NewTraces() + rs := traces.ResourceSpans().AppendEmpty() + ss := rs.ScopeSpans().AppendEmpty() + span := ss.Spans().AppendEmpty() + span.SetTraceID(pcommon.TraceID{1}) + span.SetSpanID(pcommon.SpanID{1}) + event := span.Events().AppendEmpty() + link := span.Links().AppendEmpty() + link.TraceState().FromRaw("vendor=trace-private-token") + attrs := map[string]pcommon.Map{"resource": rs.Resource().Attributes(), "span": span.Attributes(), "scope": ss.Scope().Attributes(), "event": event.Attributes(), "link": link.Attributes()}[container] + attrs.PutStr("request_id", "Bearer trace-private-token") + attrs.PutStr("tool_call_id", "safe-call-1") + p, err := newProcessor(createDefaultConfig().(*Config), zap.NewNop(), nil) + if err != nil { + t.Fatal(err) + } + if _, err = p.processTraces(context.Background(), traces); err != nil { + t.Fatal(err) + } + encoded, err := (&ptrace.JSONMarshaler{}).MarshalTraces(traces) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte("trace-private-token")) { + t.Error("protected metadata or trace state leaked") + } + if value, exists := attrs.Get("request_id"); exists && value.Str() == "Bearer trace-private-token" { + t.Error("unsafe protected identifier retained") + } + if link.TraceState().AsRaw() != "" { + t.Error("link trace state retained") + } + if value, ok := attrs.Get("tool_call_id"); !ok || value.Str() != "safe-call-1" { + t.Error("safe correlation lost") + } + }) + } +} diff --git a/projects/openshell-exporter/processor/relayprocessor/source_capability_test.go b/projects/openshell-exporter/processor/relayprocessor/source_capability_test.go new file mode 100644 index 00000000..3dca970a --- /dev/null +++ b/projects/openshell-exporter/processor/relayprocessor/source_capability_test.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package relayprocessor + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "go.opentelemetry.io/collector/pdata/pcommon" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestBoundedOTLPSource(t *testing.T) { + t.Parallel() + tests := []struct { + name string + telemetrySource string + resourceKey string + resourceValue string + want string + }{ + {name: "relay", telemetrySource: "nemo_relay", resourceKey: "service.name", resourceValue: "customer-controlled", want: "relay_otlp"}, + {name: "gateway", telemetrySource: "openshell_native", resourceKey: "service.name", resourceValue: "openshell-gateway", want: "gateway_otlp"}, + {name: "driver", telemetrySource: "openshell_native", resourceKey: "openshell.component", resourceValue: "compute-driver", want: "driver_otlp"}, + {name: "native unclassified", telemetrySource: "openshell_native", resourceKey: "service.name", resourceValue: "customer-controlled", want: "native_otlp_unclassified"}, + {name: "other processor", telemetrySource: "custom-customer-value", resourceKey: "service.name", resourceValue: "secret-url", want: "other_otlp"}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + resource := pcommon.NewMap() + resource.PutStr(test.resourceKey, test.resourceValue) + if got := boundedOTLPSource(test.telemetrySource, resource); got != test.want { + t.Fatalf("bounded source=%q, want %q", got, test.want) + } + }) + } +} + +func TestRelaySourceCapabilitiesAreBounded(t *testing.T) { + t.Parallel() + tests := []struct { + telemetrySource string + wantSources []string + }{ + {telemetrySource: "nemo_relay", wantSources: []string{"relay_otlp"}}, + {telemetrySource: "openshell_native", wantSources: []string{"gateway_otlp", "driver_otlp"}}, + {telemetrySource: "customer-specific-source", wantSources: []string{"other_otlp"}}, + } + for _, test := range tests { + capabilities := relaySourceCapabilities(test.telemetrySource) + if len(capabilities) != len(test.wantSources) { + t.Fatalf("source %q capabilities=%#v", test.telemetrySource, capabilities) + } + for index, capability := range capabilities { + if capability.Source != test.wantSources[index] { + t.Fatalf("source %q capability[%d]=%q, want %q", test.telemetrySource, index, capability.Source, test.wantSources[index]) + } + } + } +} + +func TestRelayStartupCapabilitySummaryAndMetrics(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.InfoLevel) + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + config := createDefaultConfig().(*Config) + config.GatewayID = "gateway-must-not-appear" + processor, err := newProcessor(config, zap.New(core), provider) + if err != nil { + t.Fatal(err) + } + if err := processor.start(context.Background(), nil); err != nil { + t.Fatal(err) + } + + entries := observed.AllUntimed() + if len(entries) != 1 || entries[0].Message != "OpenShell OTLP source capability summary" { + t.Fatalf("startup entries=%#v", entries) + } + encoded, err := json.Marshal(entries[0].ContextMap()) + if err != nil { + t.Fatal(err) + } + text := string(encoded) + if strings.Contains(text, config.GatewayID) || !strings.Contains(text, `"source":"relay_otlp"`) { + t.Fatalf("unexpected startup summary: %s", text) + } + + var resourceMetrics metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resourceMetrics); err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "openshell.exporter.source.enabled": false, + "openshell.exporter.source.configured": false, + "openshell.exporter.source.observed": false, + "openshell.exporter.source.health_class": false, + "openshell.exporter.source.capability_info": false, + } + for _, scope := range resourceMetrics.ScopeMetrics { + for _, metric := range scope.Metrics { + if _, ok := want[metric.Name]; ok { + want[metric.Name] = true + } + } + } + for name, found := range want { + if !found { + t.Fatalf("startup did not record metric %q", name) + } + } +} diff --git a/projects/openshell-exporter/project.yaml b/projects/openshell-exporter/project.yaml new file mode 100644 index 00000000..8b71f6be --- /dev/null +++ b/projects/openshell-exporter/project.yaml @@ -0,0 +1 @@ +kind: tool diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/client.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/client.go new file mode 100644 index 00000000..14723f1a --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/client.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/url" + "os" + "strings" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +type sandboxRef struct { + ID string + Name string +} + +type eventStream interface { + Recv() (*pb.SandboxStreamEvent, error) +} + +type gatewayClient interface { + List(context.Context) ([]sandboxRef, error) + Watch(context.Context, string) (eventStream, error) + GatewayVersion(context.Context) (string, error) + GetDraft(context.Context, string) (*pb.GetDraftPolicyResponse, error) + GetDraftHistory(context.Context, string) (*pb.GetDraftHistoryResponse, error) + GetPolicyStatus(context.Context, string) (*pb.GetSandboxPolicyStatusResponse, error) + ListPolicyRevisions(context.Context, string) ([]*pb.SandboxPolicyRevision, error) + Close() error +} + +type clientFactory func(*Config) (gatewayClient, error) + +type grpcGatewayClient struct { + api pb.OpenShellClient + conn *grpc.ClientConn + config *Config +} + +const policyRevisionPageSize = uint32(1000) + +const ( + policyRevisionLimitExceeded = "source_limit_exceeded" + policyRevisionPaginationDrift = "pagination_drift" + policyRevisionPaginationRead = "pagination_read_failed" +) + +type policyRevisionPaginationError struct { + Reason string + Retained int + ObservedAtLeast int + Limit uint32 + Cause error +} + +func (e *policyRevisionPaginationError) Error() string { + switch e.Reason { + case policyRevisionLimitExceeded: + return fmt.Sprintf("policy revisions exceed configured maximum: retained %d, observed at least %d, maximum %d", e.Retained, e.ObservedAtLeast, e.Limit) + case policyRevisionPaginationDrift: + return fmt.Sprintf("policy revision pagination order changed after %d retained records", e.Retained) + case policyRevisionPaginationRead: + return fmt.Sprintf("policy revision pagination failed after %d retained records: %v", e.Retained, e.Cause) + default: + return fmt.Sprintf("policy revision pagination incomplete after %d retained records", e.Retained) + } +} + +func (e *policyRevisionPaginationError) Unwrap() error { return e.Cause } + +func policyRevisionGapReason(err error) (string, bool) { + var paginationErr *policyRevisionPaginationError + if !errors.As(err, &paginationErr) { + return "", false + } + return paginationErr.Reason, true +} + +type policyRevisionAccumulator struct { + limit uint32 + revisions []*pb.SandboxPolicyRevision + lastVersion uint32 + haveLast bool +} + +func newPolicyRevisionAccumulator(limit uint32) *policyRevisionAccumulator { + return &policyRevisionAccumulator{ + limit: limit, + revisions: make([]*pb.SandboxPolicyRevision, 0, min(int(limit), int(policyRevisionPageSize))), + } +} + +func (a *policyRevisionAccumulator) add(page []*pb.SandboxPolicyRevision, requested uint32) error { + retainedBefore := len(a.revisions) + accepted := min(len(page), int(requested)) + for _, revision := range page[:accepted] { + if len(a.revisions) >= int(a.limit) { + return a.limitError(retainedBefore + len(page)) + } + version := revision.GetVersion() + if revision == nil || version == 0 || (a.haveLast && version >= a.lastVersion) { + return &policyRevisionPaginationError{ + Reason: policyRevisionPaginationDrift, + Retained: len(a.revisions), + Limit: a.limit, + } + } + a.revisions = append(a.revisions, revision) + a.lastVersion = version + a.haveLast = true + } + if len(page) > int(requested) { + return a.limitError(retainedBefore + len(page)) + } + return nil +} + +func (a *policyRevisionAccumulator) limitError(observedAtLeast int) error { + return &policyRevisionPaginationError{ + Reason: policyRevisionLimitExceeded, + Retained: len(a.revisions), + ObservedAtLeast: observedAtLeast, + Limit: a.limit, + } +} + +func newGRPCGatewayClient(config *Config) (gatewayClient, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate OpenShell gRPC client config: %w", err) + } + parsed, err := url.Parse(config.Endpoint) + if err != nil { + return nil, fmt.Errorf("parse endpoint: %w", err) + } + options := make([]grpc.DialOption, 0, 2) + if parsed.Scheme == "http" { + options = append(options, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + tlsConfig, err := loadTLSConfig(config, parsed.Hostname()) + if err != nil { + return nil, err + } + options = append(options, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + } + token, err := configuredBearerToken(config) + if err != nil { + return nil, err + } + if token != "" { + options = append(options, grpc.WithPerRPCCredentials(bearerCredentials{ + token: token, + })) + } + connection, err := grpc.NewClient(parsed.Host, options...) + if err != nil { + return nil, fmt.Errorf("create OpenShell gRPC client: %w", err) + } + return &grpcGatewayClient{ + api: pb.NewOpenShellClient(connection), + conn: connection, + config: config, + }, nil +} + +func configuredBearerToken(config *Config) (string, error) { + if config.TokenFile != "" { + encoded, err := os.ReadFile(config.TokenFile) + if err != nil { + return "", fmt.Errorf("read OpenShell token file: %w", err) + } + token := strings.TrimSpace(string(encoded)) + if token == "" { + return "", errors.New("OpenShell token file is empty") + } + return token, nil + } + if config.TokenEnv != "" { + token := strings.TrimSpace(os.Getenv(config.TokenEnv)) + if token == "" { + return "", errors.New("configured token_env is not set") + } + return token, nil + } + return "", nil +} + +func loadTLSConfig(config *Config, serverName string) (*tls.Config, error) { + if config.TLS.InsecureSkipVerify { + return nil, errors.New("tls.insecure_skip_verify is not supported; configure tls.ca_file instead") + } + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: serverName, + } + if config.TLS.CAFile != "" { + pem, err := os.ReadFile(config.TLS.CAFile) + if err != nil { + return nil, fmt.Errorf("read OpenShell CA: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(pem) { + return nil, errors.New("OpenShell CA file contains no certificates") + } + tlsConfig.RootCAs = roots + } + if config.TLS.CertFile != "" { + certificate, err := tls.LoadX509KeyPair(config.TLS.CertFile, config.TLS.KeyFile) + if err != nil { + return nil, fmt.Errorf("load OpenShell client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + return tlsConfig, nil +} + +func (c *grpcGatewayClient) List(ctx context.Context) ([]sandboxRef, error) { + const pageSize = uint32(1000) + result := make([]sandboxRef, 0) + for offset := uint32(0); ; offset += pageSize { + response, err := c.api.ListSandboxes(ctx, &pb.ListSandboxesRequest{ + Limit: pageSize, + Offset: offset, + LabelSelector: c.config.LabelSelector, + Workspace: c.config.Workspace, + }) + if err != nil { + return nil, err + } + for _, sandbox := range response.GetSandboxes() { + metadata := sandbox.GetMetadata() + if metadata == nil || metadata.GetId() == "" { + continue + } + result = append(result, sandboxRef{ + ID: metadata.GetId(), + Name: metadata.GetName(), + }) + } + if len(response.GetSandboxes()) < int(pageSize) { + return result, nil + } + } +} + +func (c *grpcGatewayClient) Watch( + ctx context.Context, + sandboxID string, +) (eventStream, error) { + return c.api.WatchSandbox(ctx, &pb.WatchSandboxRequest{ + Id: sandboxID, + FollowStatus: true, + FollowLogs: true, + FollowEvents: true, + LogTailLines: c.config.LogTailLines, + EventTail: c.config.EventTail, + StopOnTerminal: false, + LogSources: []string{"gateway", "sandbox"}, + }) +} + +func (c *grpcGatewayClient) GatewayVersion(ctx context.Context) (string, error) { + response, err := c.api.GetGatewayInfo(ctx, &pb.GetGatewayInfoRequest{}) + if err != nil { + return "", err + } + return response.GetGatewayVersion(), nil +} + +func (c *grpcGatewayClient) GetDraft(ctx context.Context, sandboxName string) (*pb.GetDraftPolicyResponse, error) { + return c.api.GetDraftPolicy(ctx, &pb.GetDraftPolicyRequest{ + Name: sandboxName, + Workspace: c.config.Workspace, + }) +} + +func (c *grpcGatewayClient) GetDraftHistory(ctx context.Context, sandboxName string) (*pb.GetDraftHistoryResponse, error) { + return c.api.GetDraftHistory(ctx, &pb.GetDraftHistoryRequest{ + Name: sandboxName, + Workspace: c.config.Workspace, + }) +} + +func (c *grpcGatewayClient) GetPolicyStatus(ctx context.Context, sandboxName string) (*pb.GetSandboxPolicyStatusResponse, error) { + return c.api.GetSandboxPolicyStatus(ctx, &pb.GetSandboxPolicyStatusRequest{ + Name: sandboxName, + Workspace: c.config.Workspace, + }) +} + +func (c *grpcGatewayClient) ListPolicyRevisions(ctx context.Context, sandboxName string) ([]*pb.SandboxPolicyRevision, error) { + accumulator := newPolicyRevisionAccumulator(c.config.PolicyReconciliation.MaxRevisions) + for { + if uint32(len(accumulator.revisions)) == accumulator.limit { + response, err := c.api.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ + Name: sandboxName, + Workspace: c.config.Workspace, + Limit: 1, + Offset: uint32(len(accumulator.revisions)), + }) + if err != nil { + return accumulator.revisions, &policyRevisionPaginationError{ + Reason: policyRevisionPaginationRead, + Retained: len(accumulator.revisions), + Limit: accumulator.limit, + Cause: err, + } + } + if len(response.GetRevisions()) == 0 { + return accumulator.revisions, nil + } + return accumulator.revisions, accumulator.limitError(len(accumulator.revisions) + len(response.GetRevisions())) + } + remaining := accumulator.limit - uint32(len(accumulator.revisions)) + requestLimit := min(policyRevisionPageSize, remaining) + response, err := c.api.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ + Name: sandboxName, + Workspace: c.config.Workspace, + Limit: requestLimit, + Offset: uint32(len(accumulator.revisions)), + }) + if err != nil { + if len(accumulator.revisions) == 0 { + return nil, err + } + return accumulator.revisions, &policyRevisionPaginationError{ + Reason: policyRevisionPaginationRead, + Retained: len(accumulator.revisions), + Limit: accumulator.limit, + Cause: err, + } + } + page := response.GetRevisions() + if err := accumulator.add(page, requestLimit); err != nil { + return accumulator.revisions, err + } + if len(page) < int(requestLimit) { + return accumulator.revisions, nil + } + } +} + +func (c *grpcGatewayClient) Close() error { + return c.conn.Close() +} + +type bearerCredentials struct { + token string +} + +func (c bearerCredentials) GetRequestMetadata( + context.Context, + ...string, +) (map[string]string, error) { + return map[string]string{"authorization": "Bearer " + c.token}, nil +} + +func (c bearerCredentials) RequireTransportSecurity() bool { + return true +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/client_security_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/client_security_test.go new file mode 100644 index 00000000..94f1ec78 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/client_security_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadTLSConfigUsesVerifiedTLSAndPrivateTrust(t *testing.T) { + caFile, certFile, keyFile := writeTestCertificate(t) + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.TLS.CAFile = caFile + config.TLS.CertFile = certFile + config.TLS.KeyFile = keyFile + + tlsConfig, err := loadTLSConfig(config, "gateway.internal") + if err != nil { + t.Fatal(err) + } + if tlsConfig.MinVersion != tls.VersionTLS12 { + t.Fatalf("minimum TLS version=%d, want TLS 1.2", tlsConfig.MinVersion) + } + if tlsConfig.ServerName != "gateway.internal" { + t.Fatalf("server name=%q", tlsConfig.ServerName) + } + if tlsConfig.InsecureSkipVerify { + t.Fatal("certificate verification must never be disabled") + } + if tlsConfig.RootCAs == nil { + t.Fatal("private CA pool was not loaded") + } + encodedCA, err := os.ReadFile(caFile) + if err != nil { + t.Fatal(err) + } + certificateBlock, _ := pem.Decode(encodedCA) + if certificateBlock == nil { + t.Fatal("test CA PEM could not be decoded") + } + caCertificate, err := x509.ParseCertificate(certificateBlock.Bytes) + if err != nil { + t.Fatal(err) + } + if _, err := caCertificate.Verify(x509.VerifyOptions{ + Roots: tlsConfig.RootCAs, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, + }); err != nil { + t.Fatalf("private CA did not verify against loaded trust: %v", err) + } + if len(tlsConfig.Certificates) != 1 { + t.Fatalf("client certificate count=%d, want 1", len(tlsConfig.Certificates)) + } +} + +func TestLoadTLSConfigFailsClosed(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + + config.TLS.InsecureSkipVerify = true + if _, err := loadTLSConfig(config, "gateway.internal"); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected insecure TLS rejection, got %v", err) + } + + config.TLS.InsecureSkipVerify = false + config.TLS.CAFile = filepath.Join(t.TempDir(), "missing-ca.pem") + if _, err := loadTLSConfig(config, "gateway.internal"); err == nil || !strings.Contains(err.Error(), "read OpenShell CA") { + t.Fatalf("expected missing CA error, got %v", err) + } + + config.TLS.CAFile = filepath.Join(t.TempDir(), "invalid-ca.pem") + if err := os.WriteFile(config.TLS.CAFile, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadTLSConfig(config, "gateway.internal"); err == nil || !strings.Contains(err.Error(), "contains no certificates") { + t.Fatalf("expected invalid CA error, got %v", err) + } +} + +func TestBearerCredentialsAlwaysRequireTransportSecurity(t *testing.T) { + credentials := bearerCredentials{token: "source-token"} + metadata, err := credentials.GetRequestMetadata(context.Background()) + if err != nil { + t.Fatal(err) + } + if metadata["authorization"] != "Bearer source-token" { + t.Fatalf("authorization metadata=%q", metadata["authorization"]) + } + if !credentials.RequireTransportSecurity() { + t.Fatal("bearer credentials must require transport security") + } +} + +func TestNewGRPCGatewayClientValidatesSecurityAtConstruction(t *testing.T) { + config := createDefaultConfig().(*Config) + config.Endpoint = "http://127.0.0.1:50051" + config.AllowInsecureHTTP = true + config.TokenEnv = "" + config.TokenFile = filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(config.TokenFile, []byte("source-token"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := newGRPCGatewayClient(config); err == nil || !strings.Contains(err.Error(), "require an HTTPS endpoint") { + t.Fatalf("constructor accepted plaintext credentials: %v", err) + } + + config.TokenFile = "" + config.Endpoint = "https://127.0.0.1:65535" + client, err := newGRPCGatewayClient(config) + if err != nil { + t.Fatalf("verified HTTPS client construction failed: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("close client: %v", err) + } +} + +func writeTestCertificate(t *testing.T) (string, string, string) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "watchsandbox-test-ca"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey) + if err != nil { + t.Fatal(err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + caFile := filepath.Join(directory, "ca.pem") + certFile := filepath.Join(directory, "client.pem") + keyFile := filepath.Join(directory, "client-key.pem") + certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}) + if err := os.WriteFile(caFile, certificatePEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(certFile, certificatePEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600); err != nil { + t.Fatal(err) + } + return caFile, certFile, keyFile +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/config.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/config.go new file mode 100644 index 00000000..fc887fe8 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/config.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "errors" + "net/url" + "time" + + "go.opentelemetry.io/collector/component" +) + +type Config struct { + Endpoint string `mapstructure:"endpoint"` + GatewayID string `mapstructure:"gateway_id"` + Workspace string `mapstructure:"workspace"` + SandboxNames []string `mapstructure:"sandbox_names"` + LabelSelector string `mapstructure:"label_selector"` + LogTailLines uint32 `mapstructure:"log_tail_lines"` + EventTail uint32 `mapstructure:"event_tail"` + ReconnectInitial time.Duration `mapstructure:"reconnect_initial"` + ReconnectMax time.Duration `mapstructure:"reconnect_max"` + DiscoveryInterval time.Duration `mapstructure:"discovery_interval"` + TokenEnv string `mapstructure:"token_env"` + TokenFile string `mapstructure:"token_file"` + AllowInsecureHTTP bool `mapstructure:"allow_insecure_http"` + TLS TLSConfig `mapstructure:"tls"` + PolicyReconciliation PolicyReconciliationConfig `mapstructure:"policy_reconciliation"` +} + +type PolicyReconciliationConfig struct { + Enabled bool `mapstructure:"enabled"` + Interval time.Duration `mapstructure:"interval"` + Timeout time.Duration `mapstructure:"timeout"` + Workers int `mapstructure:"workers"` + QueueSize int `mapstructure:"queue_size"` + MaxRevisions uint32 `mapstructure:"max_revisions"` + IncludeHistory bool `mapstructure:"include_history"` + IncludeEffectivePolicies bool `mapstructure:"include_effective_policies"` + StorageID *component.ID `mapstructure:"storage"` +} + +type TLSConfig struct { + CAFile string `mapstructure:"ca_file"` + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` +} + +func (cfg *Config) Validate() error { + parsed, err := url.Parse(cfg.Endpoint) + if err != nil || !parsed.IsAbs() || parsed.Host == "" { + return errors.New("endpoint must be an absolute HTTP(S) URL") + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return errors.New("endpoint must use HTTP or HTTPS") + } + if parsed.Scheme != "https" && !cfg.AllowInsecureHTTP { + return errors.New("endpoint must use HTTPS unless allow_insecure_http is true") + } + if parsed.User != nil { + return errors.New("endpoint must not contain user information") + } + if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("endpoint must not contain a path, query, or fragment") + } + if cfg.TLS.InsecureSkipVerify { + return errors.New("tls.insecure_skip_verify is not supported; configure tls.ca_file instead") + } + if parsed.Scheme != "https" && (cfg.TokenEnv != "" || cfg.TokenFile != "") { + return errors.New("OpenShell bearer credentials require an HTTPS endpoint") + } + if cfg.Workspace == "" { + return errors.New("workspace must not be empty") + } + if cfg.DiscoveryInterval <= 0 { + return errors.New("discovery_interval must be greater than zero") + } + if cfg.GatewayID == "" { + return errors.New("gateway_id must not be empty") + } + if cfg.LogTailLines == 0 || cfg.EventTail == 0 { + return errors.New("log_tail_lines and event_tail must be greater than zero") + } + if cfg.ReconnectInitial <= 0 || cfg.ReconnectMax < cfg.ReconnectInitial { + return errors.New("reconnect durations must be positive and reconnect_max must not be smaller than reconnect_initial") + } + if cfg.LogTailLines > 10000 || cfg.EventTail > 10000 { + return errors.New("tail sizes must not exceed 10000 records") + } + if (cfg.TLS.CertFile == "") != (cfg.TLS.KeyFile == "") { + return errors.New("tls.cert_file and tls.key_file must be configured together") + } + if cfg.TokenEnv != "" && cfg.TokenFile != "" { + return errors.New("token_env and token_file are mutually exclusive") + } + if cfg.PolicyReconciliation.Enabled { + if cfg.PolicyReconciliation.Interval <= 0 { + return errors.New("policy_reconciliation.interval must be greater than zero") + } + if cfg.PolicyReconciliation.Timeout <= 0 { + return errors.New("policy_reconciliation.timeout must be greater than zero") + } + if cfg.PolicyReconciliation.Workers <= 0 || cfg.PolicyReconciliation.Workers > 16 { + return errors.New("policy_reconciliation.workers must be between 1 and 16") + } + if cfg.PolicyReconciliation.QueueSize <= 0 || cfg.PolicyReconciliation.QueueSize > 10000 { + return errors.New("policy_reconciliation.queue_size must be between 1 and 10000") + } + if cfg.PolicyReconciliation.MaxRevisions == 0 || cfg.PolicyReconciliation.MaxRevisions > 100000 { + return errors.New("policy_reconciliation.max_revisions must be between 1 and 100000") + } + if cfg.PolicyReconciliation.StorageID == nil { + return errors.New("policy_reconciliation.storage is required when reconciliation is enabled") + } + } + if _, err := configuredBearerToken(cfg); err != nil { + return err + } + return nil +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/config_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/config_test.go new file mode 100644 index 00000000..0700499e --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/config_test.go @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.opentelemetry.io/collector/component" +) + +func TestConfigRejectsInsecureHTTPByDefault(t *testing.T) { + config := createDefaultConfig().(*Config) + config.Endpoint = "http://127.0.0.1:50051" + config.TokenEnv = "" + if err := config.Validate(); err == nil { + t.Fatal("HTTP endpoint should require explicit local-development opt-in") + } + config.AllowInsecureHTTP = true + if err := config.Validate(); err != nil { + t.Fatalf("explicit insecure HTTP should validate: %v", err) + } +} + +func TestConfigRejectsCredentialsOverInsecureHTTP(t *testing.T) { + config := createDefaultConfig().(*Config) + config.Endpoint = "http://127.0.0.1:50051" + config.AllowInsecureHTTP = true + config.TokenEnv = "" + config.TokenFile = filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(config.TokenFile, []byte("must-not-cross-http\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "require an HTTPS endpoint") { + t.Fatalf("expected HTTPS credential error, got %v", err) + } +} + +func TestConfigRejectsCertificateVerificationBypass(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.TLS.InsecureSkipVerify = true + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected certificate verification error, got %v", err) + } +} + +func TestConfigRejectsAmbiguousEndpointComponents(t *testing.T) { + for _, endpoint := range []string{ + "https://user:password@gateway.example:8080", + "https://gateway.example:8080/grpc", + "https://gateway.example:8080?workspace=default", + "https://gateway.example:8080#fragment", + } { + t.Run(endpoint, func(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.Endpoint = endpoint + if err := config.Validate(); err == nil { + t.Fatalf("endpoint %q should be rejected", endpoint) + } + }) + } + + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.Endpoint = "https://gateway.example:8080/" + if err := config.Validate(); err != nil { + t.Fatalf("root endpoint should validate: %v", err) + } +} + +func TestConfigAcceptsBearerTokenFile(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.TokenFile = filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(config.TokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := config.Validate(); err != nil { + t.Fatalf("token file should validate: %v", err) + } + token, err := configuredBearerToken(config) + if err != nil { + t.Fatal(err) + } + if token != "secret-token" { + t.Fatalf("unexpected token %q", token) + } +} + +func TestConfigRejectsAmbiguousBearerTokenSources(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenFile = filepath.Join(t.TempDir(), "token") + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually exclusive error, got %v", err) + } +} + +func TestConfigRejectsEmptyBearerTokenFile(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.TokenFile = filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(config.TokenFile, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty token error, got %v", err) + } +} + +func TestPolicyReconciliationRequiresPersistentStorageAndBoundedSettings(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.PolicyReconciliation.Enabled = true + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "storage is required") { + t.Fatalf("expected policy storage error, got %v", err) + } + + storageID := component.NewID(component.MustNewType("file_storage")) + config.PolicyReconciliation.StorageID = &storageID + if err := config.Validate(); err != nil { + t.Fatalf("valid policy reconciliation config was rejected: %v", err) + } + + config.PolicyReconciliation.Workers = 17 + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "workers") { + t.Fatalf("expected bounded worker error, got %v", err) + } + + config.PolicyReconciliation.Workers = 2 + config.PolicyReconciliation.MaxRevisions = 0 + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "max_revisions") { + t.Fatalf("expected zero revision limit error, got %v", err) + } + config.PolicyReconciliation.MaxRevisions = 100001 + if err := config.Validate(); err == nil || !strings.Contains(err.Error(), "max_revisions") { + t.Fatalf("expected excessive revision limit error, got %v", err) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/convert.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/convert.go new file mode 100644 index 00000000..a17d88be --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/convert.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func convertStreamEvent( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + event *pb.SandboxStreamEvent, +) (plog.Logs, error) { + if event == nil { + return plog.Logs{}, errors.New("nil SandboxStreamEvent") + } + switch payload := event.GetPayload().(type) { + case *pb.SandboxStreamEvent_Sandbox: + if payload.Sandbox == nil { + return plog.Logs{}, errors.New("nil sandbox status payload") + } + return lifecycleLogs(config, gatewayVersion, payload.Sandbox) + case *pb.SandboxStreamEvent_Log: + if payload.Log == nil { + return plog.Logs{}, errors.New("nil sandbox log payload") + } + return logLineLogs(config, sandbox, gatewayVersion, payload.Log) + case *pb.SandboxStreamEvent_Event: + if payload.Event == nil { + return plog.Logs{}, errors.New("nil platform event payload") + } + return platformEventLogs(config, sandbox, gatewayVersion, payload.Event) + case *pb.SandboxStreamEvent_DraftPolicyUpdate: + if payload.DraftPolicyUpdate == nil { + return plog.Logs{}, errors.New("nil draft policy update payload") + } + return draftUpdateLogs( + config, + sandbox, + gatewayVersion, + payload.DraftPolicyUpdate, + ) + case *pb.SandboxStreamEvent_Warning: + if payload.Warning == nil { + return plog.Logs{}, errors.New("nil stream warning payload") + } + return warningLogs( + config, + sandbox, + gatewayVersion, + "gateway_stream_warning", + payload.Warning.GetMessage(), + payload.Warning, + ) + default: + return plog.Logs{}, fmt.Errorf( + "unsupported SandboxStreamEvent payload %T", + event.GetPayload(), + ) + } +} + +func lifecycleLogs( + config *Config, + gatewayVersion string, + sandbox *pb.Sandbox, +) (plog.Logs, error) { + metadata := sandbox.GetMetadata() + if metadata == nil || metadata.GetId() == "" { + return plog.Logs{}, errors.New("sandbox payload has no stable ID") + } + eventType := "MODIFIED" + if metadata.GetDeletionTimestampMs() > 0 { + eventType = "DELETED" + } + status := sandbox.GetStatus() + sandboxBody := map[string]any{ + "id": metadata.GetId(), + "name": metadata.GetName(), + "workspace": metadata.GetWorkspace(), + "labels": stringMapToAny(metadata.GetLabels()), + "resource_version": metadata.GetResourceVersion(), + "phase": status.GetPhase().String(), + } + policyVersion := status.GetCurrentPolicyVersion() + if policyVersion > 0 { + sandboxBody["policy_version"] = policyVersion + } + body := map[string]any{ + "event_type": eventType, + "sandbox": sandboxBody, + } + body, payloadType, err := withPublicPayload(body, sandbox) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := sourceLogs( + config, + sandboxRef{ID: metadata.GetId(), Name: metadata.GetName()}, + gatewayVersion, + "sandbox.lifecycle", + body, + payloadType, + ) + if err != nil { + return plog.Logs{}, err + } + record.Attributes().PutInt( + "openshell.resource.version", + int64(metadata.GetResourceVersion()), + ) + if policyVersion > 0 { + record.Attributes().PutInt( + "openshell.policy.version", + int64(policyVersion), + ) + } + return logs, nil +} + +func logLineLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + line *pb.SandboxLogLine, +) (plog.Logs, error) { + source := line.GetSource() + if source == "" { + source = "gateway" + } + kind := "gateway.log" + if source == "sandbox" { + kind = "sandbox.log" + } + body := map[string]any{ + "sandbox_id": line.GetSandboxId(), + "timestamp_ms": line.GetTimestampMs(), + "level": line.GetLevel(), + "target": line.GetTarget(), + "message": line.GetMessage(), + "source": source, + "fields": stringMapToAny(line.GetFields()), + } + body, payloadType, err := withPublicPayload(body, line) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := sourceLogs( + config, + sandbox, + gatewayVersion, + kind, + body, + payloadType, + ) + if err != nil { + return plog.Logs{}, err + } + if line.GetTimestampMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", line.GetTimestampMs()) + } + return logs, nil +} + +func platformEventLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + event *pb.PlatformEvent, +) (plog.Logs, error) { + body := map[string]any{ + "sandbox_id": sandbox.ID, + "timestamp_ms": event.GetTimestampMs(), + "source": event.GetSource(), + "type": event.GetType(), + "reason": event.GetReason(), + "message": event.GetMessage(), + "metadata": stringMapToAny(event.GetMetadata()), + } + body, payloadType, err := withPublicPayload(body, event) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := sourceLogs( + config, + sandbox, + gatewayVersion, + "platform.event", + body, + payloadType, + ) + if err != nil { + return plog.Logs{}, err + } + if event.GetTimestampMs() > 0 { + record.Attributes().PutInt( + "openshell.event.time_unix_ms", + event.GetTimestampMs(), + ) + } + return logs, nil +} + +func draftUpdateLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + update *pb.DraftPolicyUpdate, +) (plog.Logs, error) { + body := map[string]any{ + "sandbox_id": sandbox.ID, + "draft_version": update.GetDraftVersion(), + "new_chunks": update.GetNewChunks(), + "total_pending": update.GetTotalPending(), + "summary": update.GetSummary(), + } + body, payloadType, err := withPublicPayload(body, update) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := sourceLogs( + config, + sandbox, + gatewayVersion, + "policy.draft_updated", + body, + payloadType, + ) + if err != nil { + return plog.Logs{}, err + } + record.Attributes().PutInt( + "openshell.policy.revision", + int64(update.GetDraftVersion()), + ) + return logs, nil +} + +func warningLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + code string, + message string, + payload ...proto.Message, +) (plog.Logs, error) { + body := map[string]any{ + "sandbox_id": sandbox.ID, + "code": code, + "message": message, + } + payloadType := "openshell.exporter.watchsandbox.Diagnostic" + if len(payload) > 0 { + var err error + body, payloadType, err = withPublicPayload(body, payload[0]) + if err != nil { + return plog.Logs{}, err + } + } + returnLogs, _, err := sourceLogs( + config, + sandbox, + gatewayVersion, + "stream.warning", + body, + payloadType, + ) + return returnLogs, err +} + +func withPublicPayload( + body map[string]any, + message proto.Message, +) (map[string]any, string, error) { + if message == nil || !message.ProtoReflect().IsValid() { + return nil, "", errors.New("nil public OpenShell payload") + } + encoded, err := (protojson.MarshalOptions{UseProtoNames: true}).Marshal(message) + if err != nil { + return nil, "", fmt.Errorf("marshal public OpenShell payload: %w", err) + } + payload := map[string]any{} + if err := json.Unmarshal(encoded, &payload); err != nil { + return nil, "", fmt.Errorf("decode public OpenShell payload: %w", err) + } + body["source_payload"] = payload + return body, string(message.ProtoReflect().Descriptor().FullName()), nil +} + +func sourceLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + kind string, + body any, + payloadType string, +) (plog.Logs, plog.LogRecord, error) { + logs := plog.NewLogs() + resourceLogs := logs.ResourceLogs().AppendEmpty() + resourceLogs.Resource().Attributes().PutStr("service.name", "openshell") + resourceLogs.Resource().Attributes().PutStr( + "openshell.gateway.id", + config.GatewayID, + ) + scopeLogs := resourceLogs.ScopeLogs().AppendEmpty() + scopeLogs.Scope().SetName("openshell.watchsandbox") + record := scopeLogs.LogRecords().AppendEmpty() + record.Attributes().PutStr( + "openshell.acquisition.source_instance", + "watchsandbox", + ) + record.SetObservedTimestamp(pcommon.NewTimestampFromTime(time.Now().UTC())) + record.Attributes().PutStr("openshell.acquisition.kind", kind) + record.Attributes().PutStr("openshell.acquisition.transport", "grpc") + record.Attributes().PutStr( + "openshell.acquisition.api_operation", + "openshell.v1.OpenShell/WatchSandbox", + ) + record.Attributes().PutStr("openshell.acquisition.payload_type", payloadType) + record.Attributes().PutBool("openshell.acquisition.stream_resumable", false) + record.Attributes().PutBool("openshell.acquisition.follow_status", true) + record.Attributes().PutBool("openshell.acquisition.follow_logs", true) + record.Attributes().PutBool("openshell.acquisition.follow_events", true) + record.Attributes().PutBool("openshell.acquisition.stop_on_terminal", false) + record.Attributes().PutInt("openshell.acquisition.log_since_ms", 0) + record.Attributes().PutStr("openshell.acquisition.log_min_level", "") + logSources := record.Attributes().PutEmptySlice("openshell.acquisition.log_sources") + logSources.AppendEmpty().SetStr("gateway") + logSources.AppendEmpty().SetStr("sandbox") + record.Attributes().PutInt( + "openshell.acquisition.log_tail_lines", + int64(config.LogTailLines), + ) + record.Attributes().PutInt( + "openshell.acquisition.event_tail", + int64(config.EventTail), + ) + record.Attributes().PutStr("openshell.event.kind", kind) + record.Attributes().PutStr("openshell.workspace", config.Workspace) + record.Attributes().PutStr("openshell.sandbox.id", sandbox.ID) + record.Attributes().PutStr("openshell.sandbox.name", sandbox.Name) + if gatewayVersion != "" { + record.Attributes().PutStr("openshell.gateway.version", gatewayVersion) + } + if err := record.Body().FromRaw(body); err != nil { + return plog.Logs{}, plog.LogRecord{}, fmt.Errorf( + "construct %s event: %w", + kind, + err, + ) + } + return logs, record, nil +} + +func stringMapToAny(values map[string]string) map[string]any { + result := make(map[string]any, len(values)) + for key, value := range values { + result[key] = value + } + return result +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/discovery_cleanup_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/discovery_cleanup_test.go new file mode 100644 index 00000000..a0a26922 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/discovery_cleanup_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "testing" + "time" + + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/plog" +) + +type changingDiscoveryGateway struct { + *lifecycleGateway + current []sandboxRef + listErr error +} + +func (g *changingDiscoveryGateway) List(context.Context) ([]sandboxRef, error) { + return g.current, g.listErr +} + +func TestDiscoveryRetiresDeletedSandboxesButNotTransientFailures(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + gateway := &changingDiscoveryGateway{lifecycleGateway: &lifecycleGateway{}} + // Use the fixture's blocking stream so cancellation is observable. + gateway.watchCalls.Store(1) + next, err := consumer.NewLogs(func(context.Context, plog.Logs) error { return nil }) + if err != nil { + t.Fatal(err) + } + r := newWatchReceiver(createDefaultConfig().(*Config), lifecycleSettings(), next) + r.client = gateway + for range 10 { + gateway.current = []sandboxRef{{ID: "sandbox", Name: "sandbox"}} + r.discover(ctx) + gateway.current = nil + gateway.listErr = errors.New("temporary discovery failure") + r.discover(ctx) + r.mu.Lock() + retained := len(r.sandboxes) == 1 && len(r.watching) == 1 + r.mu.Unlock() + if !retained { + t.Fatal("failed discovery retired a live watch") + } + gateway.listErr = nil + r.discover(ctx) + done := make(chan struct{}) + go func() { r.watchWG.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("deleted sandbox watch did not stop") + } + r.mu.Lock() + empty := len(r.sandboxes) == 0 && len(r.watching) == 0 + r.mu.Unlock() + if !empty { + t.Fatal("deleted sandbox retained discovery or watch state") + } + } +} + +func TestReconciliationSkipsQueuedDeletedSandbox(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := newWatchReceiver(createDefaultConfig().(*Config), lifecycleSettings(), nil) + r.reconcileCh = make(chan reconciliationRequest, 1) + r.reconcilePending = map[string]struct{}{"deleted": {}} + r.reconcileCh <- reconciliationRequest{Sandbox: sandboxRef{ID: "deleted"}} + // No gateway or storage: stale work must be discarded before either is used. + r.reconcileWG.Add(1) + go r.policyReconciliationWorker(ctx) + deadline := time.After(2 * time.Second) + for { + r.reconcileMu.Lock() + pending := len(r.reconcilePending) + r.reconcileMu.Unlock() + if pending == 0 { + break + } + select { + case <-deadline: + t.Fatal("stale reconciliation work was not cleared") + case <-time.After(time.Millisecond): + } + } + cancel() + r.reconcileWG.Wait() +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/factory.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/factory.go new file mode 100644 index 00000000..aa2619db --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/factory.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/receiver" +) + +var componentType = component.MustNewType("watchsandbox") + +func NewFactory() receiver.Factory { + return receiver.NewFactory( + componentType, + createDefaultConfig, + receiver.WithLogs(createLogsReceiver, component.StabilityLevelAlpha), + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + Endpoint: "https://127.0.0.1:50051", + Workspace: "default", + GatewayID: "local-gateway", + DiscoveryInterval: 30 * time.Second, + TokenEnv: "OPENSHELL_TOKEN", + LogTailLines: 200, + EventTail: 200, + ReconnectInitial: time.Second, + ReconnectMax: 30 * time.Second, + PolicyReconciliation: PolicyReconciliationConfig{ + Enabled: false, + Interval: 5 * time.Minute, + Timeout: 15 * time.Second, + Workers: 2, + QueueSize: 128, + MaxRevisions: 10000, + IncludeHistory: true, + IncludeEffectivePolicies: false, + }, + } +} +func createLogsReceiver( + _ context.Context, + settings receiver.Settings, + cfg component.Config, + next consumer.Logs, +) (receiver.Logs, error) { + receiverConfig := cfg.(*Config) + if err := receiverConfig.Validate(); err != nil { + return nil, err + } + return newWatchReceiver(receiverConfig, settings, next), nil +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics.go new file mode 100644 index 00000000..d1533bc8 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics.go @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +const receiverMeterName = "github.com/NVIDIA-dev/OpenShell-exporter/watchsandbox" + +const watchMetricProducer = "watchsandboxreceiver" + +const ( + policyConfiguredMetricName = "openshell.exporter.policy.reconciliation.configured" + policyLastSuccessMetricName = "openshell.exporter.policy.reconciliation.last_success_unixtime" + policyAgeMetricName = "openshell.exporter.policy.reconciliation.age_seconds" + policyQueueUtilizationMetricName = "openshell.exporter.policy.reconciliation.queue_utilization" + policyConsistencyGapsMetricName = "openshell.exporter.policy.reconciliation.consistency_gaps" +) + +type watchMetrics struct { + discoveryFailures metric.Int64Counter + reconnects metric.Int64Counter + gapWarnings metric.Int64Counter + backpressure metric.Int64Counter + sourceEnabled metric.Int64Gauge + lastSuccess metric.Int64Gauge + policyConfigured metric.Int64Gauge + policyLastSuccess metric.Int64Gauge + policyAge metric.Int64Gauge + policyQueueUtilization metric.Float64Gauge + policyReconciliations metric.Int64Counter + policyFailures metric.Int64Counter + policyGapWarnings metric.Int64Counter + policyConsistencyGaps metric.Int64Counter + policyConfiguredAt atomic.Int64 + policyLastSuccessAt atomic.Int64 + now func() time.Time +} + +func newWatchMetrics(provider metric.MeterProvider) *watchMetrics { + if provider == nil { + provider = noop.NewMeterProvider() + } + meter := provider.Meter(receiverMeterName) + discoveryFailures, _ := meter.Int64Counter("openshell.exporter.discovery.failures", metric.WithUnit("{failure}")) + reconnects, _ := meter.Int64Counter("openshell.exporter.stream.reconnects", metric.WithUnit("{reconnect}")) + gapWarnings, _ := meter.Int64Counter("openshell.exporter.stream.gap_warnings", metric.WithUnit("{warning}")) + backpressure, _ := meter.Int64Counter("openshell.exporter.stream.backpressure_retries", metric.WithUnit("{retry}")) + sourceEnabled, _ := meter.Int64Gauge("openshell.exporter.source.enabled", metric.WithDescription("Configured source profile capability; one means enabled."), metric.WithUnit("1")) + lastSuccess, _ := meter.Int64Gauge("openshell.exporter.source.last_success_unixtime", metric.WithDescription("Unix time of the last normalized record by bounded source kind."), metric.WithUnit("s")) + policyConfigured, _ := meter.Int64Gauge(policyConfiguredMetricName, metric.WithDescription("One when read-only policy reconciliation is configured."), metric.WithUnit("1")) + policyLastSuccess, _ := meter.Int64Gauge(policyLastSuccessMetricName, metric.WithDescription("Unix time of the last successful complete policy reconciliation."), metric.WithUnit("s")) + policyAge, _ := meter.Int64Gauge(policyAgeMetricName, metric.WithDescription("Seconds since policy reconciliation was configured or last succeeded."), metric.WithUnit("s")) + policyQueueUtilization, _ := meter.Float64Gauge(policyQueueUtilizationMetricName, metric.WithDescription("Current bounded policy reconciliation work-queue utilization from zero to one."), metric.WithUnit("1")) + policyReconciliations, _ := meter.Int64Counter("openshell.exporter.policy.reconciliations", metric.WithUnit("{reconciliation}")) + policyFailures, _ := meter.Int64Counter("openshell.exporter.policy.reconciliation_failures", metric.WithUnit("{failure}")) + policyGapWarnings, _ := meter.Int64Counter("openshell.exporter.policy.gap_warnings", metric.WithUnit("{warning}")) + policyConsistencyGaps, _ := meter.Int64Counter(policyConsistencyGapsMetricName, metric.WithDescription("Policy notification and gateway snapshot version differences by bounded state."), metric.WithUnit("{gap}")) + return &watchMetrics{ + discoveryFailures: discoveryFailures, + reconnects: reconnects, + gapWarnings: gapWarnings, + backpressure: backpressure, + sourceEnabled: sourceEnabled, + lastSuccess: lastSuccess, + policyConfigured: policyConfigured, + policyLastSuccess: policyLastSuccess, + policyAge: policyAge, + policyQueueUtilization: policyQueueUtilization, + policyReconciliations: policyReconciliations, + policyFailures: policyFailures, + policyGapWarnings: policyGapWarnings, + policyConsistencyGaps: policyConsistencyGaps, + now: time.Now, + } +} + +func (m *watchMetrics) enabled(ctx context.Context) { + if m.sourceEnabled != nil { + m.sourceEnabled.Record(ctx, 1, metric.WithAttributes( + attribute.String("source", "watchsandbox"), + attribute.String("durability", "non_resumable"), + attribute.String("producer", watchMetricProducer), + )) + } +} + +func (m *watchMetrics) discoveryFailed(ctx context.Context) { + if m.discoveryFailures != nil { + m.discoveryFailures.Add(ctx, 1) + } +} + +func (m *watchMetrics) reconnect(ctx context.Context, reason string) { + if m.reconnects != nil { + m.reconnects.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) + } +} + +func (m *watchMetrics) gap(ctx context.Context, reason string) { + if m.gapWarnings != nil { + m.gapWarnings.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) + } +} + +func (m *watchMetrics) backpressured(ctx context.Context) { + if m.backpressure != nil { + m.backpressure.Add(ctx, 1) + } +} + +func (m *watchMetrics) succeeded(ctx context.Context) { + if m.lastSuccess != nil { + m.lastSuccess.Record(ctx, time.Now().Unix(), metric.WithAttributes( + attribute.String("source", "watchsandbox"), + attribute.String("producer", watchMetricProducer), + )) + } +} + +func (m *watchMetrics) policyEnabled(ctx context.Context) { + if m.sourceEnabled != nil { + m.sourceEnabled.Record(ctx, 1, metric.WithAttributes( + attribute.String("source", "policy_reconciliation"), + attribute.String("durability", "checkpointed_api_snapshot"), + attribute.String("producer", watchMetricProducer), + )) + } + now := m.now().Unix() + m.policyConfiguredAt.Store(now) + if m.policyConfigured != nil { + m.policyConfigured.Record(ctx, 1) + } + m.reconciliationState(ctx, 0, 1) +} + +func (m *watchMetrics) policyDisabled(ctx context.Context) { + if m.policyConfigured != nil { + m.policyConfigured.Record(ctx, 0) + } +} + +func (m *watchMetrics) reconciliationSucceeded(ctx context.Context) { + if m.policyReconciliations != nil { + m.policyReconciliations.Add(ctx, 1) + } + now := m.now().Unix() + m.policyLastSuccessAt.Store(now) + if m.lastSuccess != nil { + m.lastSuccess.Record(ctx, now, metric.WithAttributes( + attribute.String("source", "policy_reconciliation"), + attribute.String("producer", watchMetricProducer), + )) + } + if m.policyLastSuccess != nil { + m.policyLastSuccess.Record(ctx, now) + } + m.recordReconciliationAge(ctx, now) +} + +func (m *watchMetrics) reconciliationFailed(ctx context.Context, reason string) { + if m.policyFailures != nil { + m.policyFailures.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", boundedPolicyFailureReason(reason)))) + } + m.recordReconciliationAge(ctx, m.now().Unix()) +} + +func (m *watchMetrics) reconciliationGap(ctx context.Context, reason string) { + if m.policyGapWarnings != nil { + m.policyGapWarnings.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", boundedPolicyGapReason(reason)))) + } +} + +func (m *watchMetrics) reconciliationConsistencyGap(ctx context.Context, state string) { + if m.policyConsistencyGaps != nil { + m.policyConsistencyGaps.Add(ctx, 1, metric.WithAttributes(attribute.String("state", boundedPolicyConsistencyState(state)))) + } +} + +func (m *watchMetrics) reconciliationState(ctx context.Context, queueLength, queueCapacity int) { + if queueCapacity > 0 && m.policyQueueUtilization != nil { + m.policyQueueUtilization.Record(ctx, float64(queueLength)/float64(queueCapacity)) + } + m.recordReconciliationAge(ctx, m.now().Unix()) +} + +func (m *watchMetrics) recordReconciliationAge(ctx context.Context, now int64) { + base := m.policyLastSuccessAt.Load() + if base == 0 { + base = m.policyConfiguredAt.Load() + } + if base == 0 || m.policyAge == nil { + return + } + m.policyAge.Record(ctx, max(now-base, 0)) +} + +func boundedPolicyFailureReason(reason string) string { + switch reason { + case "state_read", "state_write", "conversion_failed", "delivery_failed", + policyRevisionLimitExceeded, policyRevisionPaginationDrift, policyRevisionPaginationRead, + "Canceled", "Unknown", "InvalidArgument", "DeadlineExceeded", "NotFound", + "AlreadyExists", "PermissionDenied", "ResourceExhausted", "FailedPrecondition", + "Aborted", "OutOfRange", "Unimplemented", "Internal", "Unavailable", + "DataLoss", "Unauthenticated": + return reason + default: + return "other" + } +} + +func boundedPolicyGapReason(reason string) string { + switch reason { + case "queue_full", "snapshot_reset", policyRevisionLimitExceeded, + policyRevisionPaginationDrift, policyRevisionPaginationRead: + return reason + default: + return "other" + } +} + +func boundedPolicyConsistencyState(state string) string { + switch state { + case "gateway_ahead", "notification_ahead": + return state + default: + return "other" + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics_test.go new file mode 100644 index 00000000..21ce6f0d --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/metrics_test.go @@ -0,0 +1,201 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/receiver" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestPolicyReconciliationMetricsAreBoundedAndOperational(t *testing.T) { + t.Parallel() + ctx := context.Background() + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + t.Cleanup(func() { _ = provider.Shutdown(ctx) }) + metrics := newWatchMetrics(provider) + now := time.Unix(1_000, 0) + metrics.now = func() time.Time { return now } + + metrics.policyEnabled(ctx) + now = time.Unix(1_010, 0) + metrics.reconciliationState(ctx, 3, 4) + metrics.reconciliationFailed(ctx, "Bearer must-not-become-a-label") + metrics.reconciliationGap(ctx, "https://customer.example/private") + metrics.reconciliationConsistencyGap(ctx, "gateway_ahead") + now = time.Unix(1_020, 0) + metrics.reconciliationSucceeded(ctx) + now = time.Unix(1_050, 0) + metrics.reconciliationState(ctx, 3, 4) + + collected := collectMetrics(t, reader) + assertIntGauge(t, collected, policyConfiguredMetricName, 1) + assertIntGauge(t, collected, policyLastSuccessMetricName, 1_020) + assertIntGauge(t, collected, policyAgeMetricName, 30) + assertFloatGauge(t, collected, policyQueueUtilizationMetricName, 0.75) + assertIntSum(t, collected, policyConsistencyGapsMetricName, 1, "state", "gateway_ahead") + assertIntSum(t, collected, "openshell.exporter.policy.reconciliation_failures", 1, "reason", "other") + assertIntSum(t, collected, "openshell.exporter.policy.gap_warnings", 1, "reason", "other") + assertIntGaugeLabel(t, collected, "openshell.exporter.source.last_success_unixtime", "producer", watchMetricProducer) + + forbidden := map[string]struct{}{ + "gateway": {}, "workspace": {}, "sandbox": {}, "draft": {}, "chunk": {}, + "request": {}, "trace": {}, "url": {}, "customer": {}, + } + for _, current := range collected { + if !strings.HasPrefix(current.Name, "openshell.exporter.policy") { + continue + } + for _, attrs := range metricAttributes(current) { + for _, item := range attrs.ToSlice() { + if _, blocked := forbidden[string(item.Key)]; blocked { + t.Fatalf("metric %q contains forbidden label %q", current.Name, item.Key) + } + if strings.Contains(item.Value.String(), "must-not") || strings.Contains(item.Value.String(), "customer.example") { + t.Fatalf("metric %q contains unbounded or secret label value %q", current.Name, item.Value.String()) + } + } + } + } +} + +func TestPolicyReconciliationRecoveryLogsExcludeCredentials(t *testing.T) { + t.Parallel() + core, observed := observer.New(zapcore.DebugLevel) + r := newWatchReceiver(createDefaultConfig().(*Config), receiver.Settings{ + TelemetrySettings: component.TelemetrySettings{Logger: zap.New(core)}, + }, nil) + r.recordReconciliationFailure(context.Background(), "Bearer must-never-appear") + r.recordReconciliationFailure(context.Background(), "https://customer.example/private") + r.recordReconciliationSuccess(context.Background()) + entries := observed.AllUntimed() + if len(entries) != 2 || entries[0].Message != "policy reconciliation degraded" || entries[1].Message != "policy reconciliation recovered" { + t.Fatalf("unexpected recovery log transitions: %#v", entries) + } + encoded, err := json.Marshal(entries) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "must-never") || strings.Contains(string(encoded), "customer.example") { + t.Fatalf("credential or URL reached structured logs: %s", encoded) + } + if entries[0].ContextMap()["reason"] != "other" { + t.Fatalf("degraded reason=%#v, want bounded other", entries[0].ContextMap()["reason"]) + } +} + +func TestPolicyPaginationMetricReasonsAreBoundedAndDistinct(t *testing.T) { + t.Parallel() + for _, reason := range []string{ + policyRevisionLimitExceeded, + policyRevisionPaginationDrift, + policyRevisionPaginationRead, + } { + if got := boundedPolicyFailureReason(reason); got != reason { + t.Fatalf("failure reason %q became %q", reason, got) + } + if got := boundedPolicyGapReason(reason); got != reason { + t.Fatalf("gap reason %q became %q", reason, got) + } + } + if got := boundedPolicyGapReason("sandbox-secret"); got != "other" { + t.Fatalf("unbounded gap reason became %q", got) + } +} + +func collectMetrics(t *testing.T, reader *sdkmetric.ManualReader) []metricdata.Metrics { + t.Helper() + var resource metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &resource); err != nil { + t.Fatal(err) + } + var result []metricdata.Metrics + for _, scope := range resource.ScopeMetrics { + result = append(result, scope.Metrics...) + } + return result +} + +func findMetric(t *testing.T, metrics []metricdata.Metrics, name string) metricdata.Metrics { + t.Helper() + for _, current := range metrics { + if current.Name == name { + return current + } + } + t.Fatalf("metric %q was not collected", name) + return metricdata.Metrics{} +} + +func assertIntGauge(t *testing.T, metrics []metricdata.Metrics, name string, want int64) { + t.Helper() + gauge, ok := findMetric(t, metrics, name).Data.(metricdata.Gauge[int64]) + if !ok || len(gauge.DataPoints) != 1 || gauge.DataPoints[0].Value != want { + t.Fatalf("gauge %q=%#v, want %d", name, gauge.DataPoints, want) + } +} + +func assertIntGaugeLabel(t *testing.T, metrics []metricdata.Metrics, name, key, want string) { + t.Helper() + gauge, ok := findMetric(t, metrics, name).Data.(metricdata.Gauge[int64]) + if !ok || len(gauge.DataPoints) != 1 { + t.Fatalf("gauge %q=%#v, want one point", name, gauge.DataPoints) + } + got, exists := gauge.DataPoints[0].Attributes.Value(attribute.Key(key)) + if !exists || got.AsString() != want { + t.Fatalf("gauge %q label %s=%v, want %q", name, key, got, want) + } +} + +func assertFloatGauge(t *testing.T, metrics []metricdata.Metrics, name string, want float64) { + t.Helper() + gauge, ok := findMetric(t, metrics, name).Data.(metricdata.Gauge[float64]) + if !ok || len(gauge.DataPoints) != 1 || gauge.DataPoints[0].Value != want { + t.Fatalf("gauge %q=%#v, want %f", name, gauge.DataPoints, want) + } +} + +func assertIntSum(t *testing.T, metrics []metricdata.Metrics, name string, want int64, key, value string) { + t.Helper() + sum, ok := findMetric(t, metrics, name).Data.(metricdata.Sum[int64]) + if !ok || len(sum.DataPoints) != 1 || sum.DataPoints[0].Value != want { + t.Fatalf("sum %q=%#v, want %d", name, sum.DataPoints, want) + } + if got, exists := sum.DataPoints[0].Attributes.Value(attribute.Key(key)); !exists || got.AsString() != value { + t.Fatalf("sum %q label %s=%v, want %q", name, key, got, value) + } +} + +func metricAttributes(current metricdata.Metrics) []attribute.Set { + switch data := current.Data.(type) { + case metricdata.Gauge[int64]: + return pointAttributes(data.DataPoints) + case metricdata.Gauge[float64]: + return pointAttributes(data.DataPoints) + case metricdata.Sum[int64]: + return pointAttributes(data.DataPoints) + default: + return nil + } +} + +func pointAttributes[N int64 | float64](points []metricdata.DataPoint[N]) []attribute.Set { + result := make([]attribute.Set, 0, len(points)) + for _, point := range points { + result = append(result, point.Attributes) + } + return result +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/normalizer_delivery_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/normalizer_delivery_test.go new file mode 100644 index 00000000..675c632b --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/normalizer_delivery_test.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/NVIDIA/OpenShell-Research/projects/openshell-exporter/processor/openshellprocessor" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/processor/processortest" +) + +func withNormalizer(t *testing.T, consume func(context.Context, plog.Logs) error) consumer.Logs { + t.Helper() + sink, err := consumer.NewLogs(consume) + if err != nil { + t.Fatal(err) + } + factory := openshellprocessor.NewFactory() + cfg := factory.CreateDefaultConfig().(*openshellprocessor.Config) + cfg.SourceProfiles = []string{"watchsandbox", "policy.reconciliation"} + normalizer, err := factory.CreateLogs(context.Background(), processortest.NewNopSettings(factory.Type()), cfg, sink) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := normalizer.Shutdown(context.Background()); err != nil { + t.Error(err) + } + }) + return normalizer +} + +func TestReceiverRetryPreservesNormalizedBodyAndID(t *testing.T) { + cfg := createDefaultConfig().(*Config) + var bodies []any + var ids []string + next := withNormalizer(t, func(_ context.Context, logs plog.Logs) error { + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + bodies = append(bodies, record.Body().AsRaw()) + id, _ := record.Attributes().Get("cloudevents.id") + ids = append(ids, id.Str()) + if len(bodies) == 1 { + return errors.New("temporary downstream failure") + } + return nil + }) + r := newWatchReceiver(cfg, lifecycleSettings(), next) + logs, err := draftSnapshotLogs(cfg, testReconciliationRequest().Sandbox, "test", testReconciliationGateway().draft, reconciliationMetadata{}) + if err != nil { + t.Fatal(err) + } + before, err := (&plog.JSONMarshaler{}).MarshalLogs(logs) + if err != nil { + t.Fatal(err) + } + if err := r.consumeWithBackpressure(context.Background(), logs); err != nil { + t.Fatal(err) + } + after, err := (&plog.JSONMarshaler{}).MarshalLogs(logs) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Error("receiver-owned evidence mutated") + } + if len(bodies) != 2 || !reflect.DeepEqual(bodies[0], bodies[1]) || ids[0] == "" || ids[0] != ids[1] { + t.Error("retry changed normalized evidence or identity") + } +} + +func TestReconciliationWithNormalizerPersistsOnlyAcceptedSnapshots(t *testing.T) { + for _, rejectFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "accepted", true: "rejected"}[rejectFirst], func(t *testing.T) { + cfg := createDefaultConfig().(*Config) + cfg.PolicyReconciliation.Enabled = true + cfg.PolicyReconciliation.IncludeHistory = true + store := newMemoryPolicyStorage() + gateway := testReconciliationGateway() + calls := 0 + reject := rejectFirst + next := withNormalizer(t, func(context.Context, plog.Logs) error { + calls++ + if reject { + return consumererror.NewPermanent(errors.New("rejected")) + } + return nil + }) + makeReceiver := func() *watchReceiver { + r := newWatchReceiver(cfg, lifecycleSettings(), next) + r.client = gateway + r.gatewayVersion = "test" + r.policyStorage = store + return r + } + r := makeReceiver() + request := testReconciliationRequest() + r.reconcileSandbox(context.Background(), request) + if calls == 0 { + t.Fatal("no evidence delivered") + } + if reject { + state, err := r.loadPolicyState(context.Background(), request.Sandbox.ID) + if err != nil { + t.Fatal(err) + } + if state.DraftHash != "" || state.StatusHash != "" { + t.Fatal("rejected snapshots checkpointed") + } + reject = false + before := calls + r.reconcileSandbox(context.Background(), request) + if calls <= before { + t.Fatal("rejected snapshots not retried") + } + } + before := calls + r.reconcileSandbox(context.Background(), request) + makeReceiver().reconcileSandbox(context.Background(), request) + if calls != before { + t.Fatalf("unchanged reconciliation emitted %d extra records", calls-before) + } + }) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/pagination_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/pagination_test.go new file mode 100644 index 00000000..a60c6c19 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/pagination_test.go @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type policyRevisionAPI struct { + pb.OpenShellClient + mu sync.Mutex + requests []*pb.ListSandboxPoliciesRequest + respond func(*pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) +} + +func (a *policyRevisionAPI) ListSandboxPolicies( + _ context.Context, + request *pb.ListSandboxPoliciesRequest, + _ ...grpc.CallOption, +) (*pb.ListSandboxPoliciesResponse, error) { + a.mu.Lock() + a.requests = append(a.requests, request) + a.mu.Unlock() + return a.respond(request) +} + +func (a *policyRevisionAPI) snapshotRequests() []*pb.ListSandboxPoliciesRequest { + a.mu.Lock() + defer a.mu.Unlock() + return append([]*pb.ListSandboxPoliciesRequest(nil), a.requests...) +} + +func TestPolicyRevisionPaginationUsesOneRecordBoundaryProbe(t *testing.T) { + t.Parallel() + api := &policyRevisionAPI{} + api.respond = func(request *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + switch request.GetOffset() { + case 0: + return revisionResponse(3, 2, 1), nil + case 3: + return revisionResponse(), nil + default: + return nil, fmt.Errorf("unexpected offset %d", request.GetOffset()) + } + } + client := testPolicyRevisionClient(api, 3) + + revisions, err := client.ListPolicyRevisions(context.Background(), "sandbox") + if err != nil { + t.Fatal(err) + } + assertRevisionVersions(t, revisions, 3, 2, 1) + requests := api.snapshotRequests() + if len(requests) != 2 || requests[0].GetLimit() != 3 || requests[0].GetOffset() != 0 || + requests[1].GetLimit() != 1 || requests[1].GetOffset() != 3 { + t.Fatalf("pagination requests=%#v, want bounded page and one-record probe", requests) + } +} + +func TestPolicyRevisionPaginationReportsOverflowAndRetainsBoundedNewestSet(t *testing.T) { + t.Parallel() + api := &policyRevisionAPI{} + api.respond = func(request *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + if request.GetOffset() == 0 { + return revisionResponse(5, 4, 3), nil + } + return revisionResponse(2), nil + } + client := testPolicyRevisionClient(api, 3) + + revisions, err := client.ListPolicyRevisions(context.Background(), "sandbox") + assertPaginationError(t, err, policyRevisionLimitExceeded, 3) + assertRevisionVersions(t, revisions, 5, 4, 3) +} + +func TestPolicyRevisionPaginationRejectsServerPageAboveRequestedLimit(t *testing.T) { + t.Parallel() + api := &policyRevisionAPI{respond: func(*pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + return revisionResponse(8, 7, 6, 5, 4), nil + }} + client := testPolicyRevisionClient(api, 3) + + revisions, err := client.ListPolicyRevisions(context.Background(), "sandbox") + assertPaginationError(t, err, policyRevisionLimitExceeded, 3) + assertRevisionVersions(t, revisions, 8, 7, 6) +} + +func TestPolicyRevisionPaginationReturnsPartialEvidenceAfterLaterPageFailure(t *testing.T) { + t.Parallel() + firstPage := make([]uint32, policyRevisionPageSize) + for index := range firstPage { + firstPage[index] = policyRevisionPageSize + 1 - uint32(index) + } + api := &policyRevisionAPI{} + api.respond = func(request *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + if request.GetOffset() == 0 { + return revisionResponse(firstPage...), nil + } + return nil, status.Error(codes.Unavailable, "injected later-page failure") + } + client := testPolicyRevisionClient(api, policyRevisionPageSize+10) + + revisions, err := client.ListPolicyRevisions(context.Background(), "sandbox") + assertPaginationError(t, err, policyRevisionPaginationRead, int(policyRevisionPageSize)) + if !errors.Is(err, status.Error(codes.Unavailable, "injected later-page failure")) && status.Code(err) != codes.Unavailable { + t.Fatalf("pagination error lost gRPC status: %v", err) + } + if len(revisions) != int(policyRevisionPageSize) { + t.Fatalf("partial revisions=%d, want %d", len(revisions), policyRevisionPageSize) + } +} + +func TestPolicyRevisionPaginationDetectsDuplicateAndAscendingVersions(t *testing.T) { + t.Parallel() + for name, versions := range map[string][]uint32{ + "duplicate": {5, 4, 4, 3}, + "ascending": {5, 4, 6, 3}, + "zero": {5, 4, 0, 3}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + api := &policyRevisionAPI{respond: func(*pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + return revisionResponse(versions...), nil + }} + client := testPolicyRevisionClient(api, 10) + revisions, err := client.ListPolicyRevisions(context.Background(), "sandbox") + assertPaginationError(t, err, policyRevisionPaginationDrift, 2) + assertRevisionVersions(t, revisions, 5, 4) + }) + } +} + +func TestPolicyRevisionAccumulatorNeverRetainsPastConfiguredLimit(t *testing.T) { + t.Parallel() + page := make([]*pb.SandboxPolicyRevision, 100000) + for index := range page { + page[index] = &pb.SandboxPolicyRevision{Version: uint32(len(page) - index)} + } + accumulator := newPolicyRevisionAccumulator(10) + err := accumulator.add(page, 10) + assertPaginationError(t, err, policyRevisionLimitExceeded, 10) + if len(accumulator.revisions) != 10 || cap(accumulator.revisions) > int(policyRevisionPageSize) { + t.Fatalf("bounded accumulator len=%d cap=%d", len(accumulator.revisions), cap(accumulator.revisions)) + } +} + +func FuzzPolicyRevisionAccumulator(f *testing.F) { + f.Add([]byte{5, 4, 3}, uint8(3), uint8(3)) + f.Add([]byte{5, 4, 4}, uint8(10), uint8(10)) + f.Add([]byte{5, 4, 3, 2}, uint8(2), uint8(2)) + f.Fuzz(func(t *testing.T, encoded []byte, limitByte, requestedByte uint8) { + limit := uint32(limitByte%64) + 1 + requested := uint32(requestedByte%64) + 1 + page := make([]*pb.SandboxPolicyRevision, len(encoded)) + for index, version := range encoded { + page[index] = &pb.SandboxPolicyRevision{Version: uint32(version)} + } + accumulator := newPolicyRevisionAccumulator(limit) + err := accumulator.add(page, requested) + if len(accumulator.revisions) > int(limit) || len(accumulator.revisions) > int(requested) { + t.Fatalf("retained %d records for limit=%d requested=%d", len(accumulator.revisions), limit, requested) + } + for index := 1; index < len(accumulator.revisions); index++ { + if accumulator.revisions[index].GetVersion() >= accumulator.revisions[index-1].GetVersion() { + t.Fatalf("accepted non-descending revisions with err=%v", err) + } + } + }) +} + +func BenchmarkPolicyRevisionAccumulator(b *testing.B) { + page := make([]*pb.SandboxPolicyRevision, policyRevisionPageSize) + for index := range page { + page[index] = &pb.SandboxPolicyRevision{Version: policyRevisionPageSize - uint32(index)} + } + b.ReportAllocs() + for range b.N { + accumulator := newPolicyRevisionAccumulator(policyRevisionPageSize) + if err := accumulator.add(page, policyRevisionPageSize); err != nil { + b.Fatal(err) + } + } +} + +func testPolicyRevisionClient(api pb.OpenShellClient, maxRevisions uint32) *grpcGatewayClient { + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.MaxRevisions = maxRevisions + return &grpcGatewayClient{api: api, config: config} +} + +func revisionResponse(versions ...uint32) *pb.ListSandboxPoliciesResponse { + revisions := make([]*pb.SandboxPolicyRevision, 0, len(versions)) + for _, version := range versions { + revisions = append(revisions, &pb.SandboxPolicyRevision{Version: version, PolicyHash: fmt.Sprintf("sha256:%d", version)}) + } + return &pb.ListSandboxPoliciesResponse{Revisions: revisions} +} + +func assertRevisionVersions(t *testing.T, revisions []*pb.SandboxPolicyRevision, want ...uint32) { + t.Helper() + if len(revisions) != len(want) { + t.Fatalf("revision count=%d, want %d", len(revisions), len(want)) + } + for index, revision := range revisions { + if revision.GetVersion() != want[index] { + t.Fatalf("revision[%d]=%d, want %d", index, revision.GetVersion(), want[index]) + } + } +} + +func assertPaginationError(t *testing.T, err error, reason string, retained int) { + t.Helper() + var paginationErr *policyRevisionPaginationError + if !errors.As(err, &paginationErr) || paginationErr.Reason != reason || paginationErr.Retained != retained { + t.Fatalf("pagination error=%#v, want reason=%q retained=%d", err, reason, retained) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert.go new file mode 100644 index 00000000..1e9eaf7b --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert.go @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "errors" + "fmt" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "google.golang.org/protobuf/proto" +) + +type reconciliationMetadata struct { + Trigger string + NotificationDraftVersion uint64 + Consistency string +} + +func draftSnapshotLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + response *pb.GetDraftPolicyResponse, + metadata reconciliationMetadata, +) (plog.Logs, error) { + if response == nil { + return plog.Logs{}, errors.New("nil GetDraftPolicy response") + } + sanitized, removed := sanitizeDraft(response, config.PolicyReconciliation.IncludeEffectivePolicies) + body := map[string]any{ + "sandbox_id": sandbox.ID, + "draft_version": sanitized.GetDraftVersion(), + "rolling_summary": sanitized.GetRollingSummary(), + "last_analyzed_at_ms": sanitized.GetLastAnalyzedAtMs(), + "chunk_count": len(sanitized.GetChunks()), + "review_tokens_removed": removed, + "effective_policies_included": config.PolicyReconciliation.IncludeEffectivePolicies, + } + body, payloadType, err := withPublicPayload(body, sanitized) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.draft.snapshot", "openshell.v1.OpenShell/GetDraftPolicy", payloadType, metadata, body) + if err != nil { + return plog.Logs{}, err + } + record.Attributes().PutInt("openshell.policy.revision", int64(sanitized.GetDraftVersion())) + if sanitized.GetLastAnalyzedAtMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", sanitized.GetLastAnalyzedAtMs()) + } + return logs, nil +} + +func draftChunkLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + draftVersion uint64, + chunk *pb.PolicyChunk, + metadata reconciliationMetadata, +) (plog.Logs, error) { + if chunk == nil { + return plog.Logs{}, errors.New("nil policy chunk") + } + sanitized, removed := sanitizePolicyChunk(chunk, config.PolicyReconciliation.IncludeEffectivePolicies) + body := map[string]any{ + "sandbox_id": sandbox.ID, + "draft_version": draftVersion, + "policy_chunk_id": sanitized.GetId(), + "status": sanitized.GetStatus(), + "stage": sanitized.GetStage(), + "review_token_present": removed > 0, + "effective_policies_included": config.PolicyReconciliation.IncludeEffectivePolicies, + } + body, payloadType, err := withPublicPayload(body, sanitized) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.draft.chunk", "openshell.v1.OpenShell/GetDraftPolicy", payloadType, metadata, body) + if err != nil { + return plog.Logs{}, err + } + record.Attributes().PutInt("openshell.policy.revision", int64(draftVersion)) + if sanitized.GetId() != "" { + record.Attributes().PutStr("openshell.policy.chunk.id", sanitized.GetId()) + } + if sanitized.GetCreatedAtMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", sanitized.GetCreatedAtMs()) + } + return logs, nil +} + +func policyStatusLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + response *pb.GetSandboxPolicyStatusResponse, + metadata reconciliationMetadata, +) (plog.Logs, error) { + if response == nil { + return plog.Logs{}, errors.New("nil GetSandboxPolicyStatus response") + } + sanitized := proto.Clone(response).(*pb.GetSandboxPolicyStatusResponse) + if !config.PolicyReconciliation.IncludeEffectivePolicies && sanitized.GetRevision() != nil { + sanitized.Revision.Policy = nil + } + body := map[string]any{ + "sandbox_id": sandbox.ID, + "active_version": sanitized.GetActiveVersion(), + } + body, payloadType, err := withPublicPayload(body, sanitized) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.status", "openshell.v1.OpenShell/GetSandboxPolicyStatus", payloadType, metadata, body) + if err != nil { + return plog.Logs{}, err + } + if sanitized.GetActiveVersion() > 0 { + record.Attributes().PutInt("openshell.policy.version", int64(sanitized.GetActiveVersion())) + } + if revision := sanitized.GetRevision(); revision != nil { + record.Attributes().PutInt("openshell.policy.revision", int64(revision.GetVersion())) + if revision.GetCreatedAtMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", revision.GetCreatedAtMs()) + } + } + return logs, nil +} + +func policyRevisionLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + revision *pb.SandboxPolicyRevision, + metadata reconciliationMetadata, +) (plog.Logs, error) { + if revision == nil { + return plog.Logs{}, errors.New("nil policy revision") + } + sanitized := proto.Clone(revision).(*pb.SandboxPolicyRevision) + if !config.PolicyReconciliation.IncludeEffectivePolicies { + sanitized.Policy = nil + } + body := map[string]any{ + "sandbox_id": sandbox.ID, + "version": sanitized.GetVersion(), + "policy_hash": sanitized.GetPolicyHash(), + "status": sanitized.GetStatus().String(), + } + body, payloadType, err := withPublicPayload(body, sanitized) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.revision", "openshell.v1.OpenShell/ListSandboxPolicies", payloadType, metadata, body) + if err != nil { + return plog.Logs{}, err + } + record.Attributes().PutInt("openshell.policy.version", int64(sanitized.GetVersion())) + record.Attributes().PutInt("openshell.policy.revision", int64(sanitized.GetVersion())) + if sanitized.GetCreatedAtMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", sanitized.GetCreatedAtMs()) + } + return logs, nil +} + +func draftHistoryLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + entry *pb.DraftHistoryEntry, + metadata reconciliationMetadata, +) (plog.Logs, error) { + if entry == nil { + return plog.Logs{}, errors.New("nil draft history entry") + } + body := map[string]any{ + "sandbox_id": sandbox.ID, + "event_type": entry.GetEventType(), + "policy_chunk_id": entry.GetChunkId(), + "timestamp_ms": entry.GetTimestampMs(), + } + body, payloadType, err := withPublicPayload(body, entry) + if err != nil { + return plog.Logs{}, err + } + logs, record, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.draft.history", "openshell.v1.OpenShell/GetDraftHistory", payloadType, metadata, body) + if err != nil { + return plog.Logs{}, err + } + if entry.GetChunkId() != "" { + record.Attributes().PutStr("openshell.policy.chunk.id", entry.GetChunkId()) + } + if entry.GetTimestampMs() > 0 { + record.Attributes().PutInt("openshell.event.time_unix_ms", entry.GetTimestampMs()) + } + return logs, nil +} + +func reconciliationWarningLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + operation string, + code string, + message string, + metadata reconciliationMetadata, +) (plog.Logs, error) { + body := map[string]any{ + "sandbox_id": sandbox.ID, + "code": code, + "message": message, + "api_operation": operation, + } + returnLogs, _, err := policyReadLogs(config, sandbox, gatewayVersion, "policy.reconciliation.warning", operation, "openshell.exporter.policy.Diagnostic", metadata, body) + return returnLogs, err +} + +func policyReadLogs( + config *Config, + sandbox sandboxRef, + gatewayVersion string, + kind string, + operation string, + payloadType string, + metadata reconciliationMetadata, + body any, +) (plog.Logs, plog.LogRecord, error) { + logs := plog.NewLogs() + resourceLogs := logs.ResourceLogs().AppendEmpty() + resourceLogs.Resource().Attributes().PutStr("service.name", "openshell") + resourceLogs.Resource().Attributes().PutStr("openshell.gateway.id", config.GatewayID) + scopeLogs := resourceLogs.ScopeLogs().AppendEmpty() + scopeLogs.Scope().SetName("openshell.policy.reconciliation") + record := scopeLogs.LogRecords().AppendEmpty() + record.SetObservedTimestamp(pcommon.NewTimestampFromTime(time.Now().UTC())) + attributes := record.Attributes() + attributes.PutStr("openshell.acquisition.source_instance", "policy-reconciliation") + attributes.PutStr("openshell.acquisition.kind", kind) + attributes.PutStr("openshell.acquisition.transport", "grpc") + attributes.PutStr("openshell.acquisition.api_operation", operation) + attributes.PutStr("openshell.acquisition.payload_type", payloadType) + attributes.PutStr("openshell.acquisition.reconciliation_trigger", metadata.Trigger) + attributes.PutStr("openshell.acquisition.consistency", metadata.Consistency) + if metadata.NotificationDraftVersion > 0 { + attributes.PutInt("openshell.acquisition.notification_draft_version", int64(metadata.NotificationDraftVersion)) + } + attributes.PutStr("openshell.event.kind", kind) + attributes.PutStr("openshell.workspace", config.Workspace) + attributes.PutStr("openshell.sandbox.id", sandbox.ID) + attributes.PutStr("openshell.sandbox.name", sandbox.Name) + if gatewayVersion != "" { + attributes.PutStr("openshell.gateway.version", gatewayVersion) + } + if err := record.Body().FromRaw(body); err != nil { + return plog.Logs{}, plog.LogRecord{}, fmt.Errorf("construct %s event: %w", kind, err) + } + return logs, record, nil +} + +func sanitizeDraft(response *pb.GetDraftPolicyResponse, includeEffectivePolicies bool) (*pb.GetDraftPolicyResponse, int) { + sanitized := proto.Clone(response).(*pb.GetDraftPolicyResponse) + removed := 0 + for index, chunk := range sanitized.GetChunks() { + clean, count := sanitizePolicyChunk(chunk, includeEffectivePolicies) + sanitized.Chunks[index] = clean + removed += count + } + return sanitized, removed +} + +func sanitizePolicyChunk(chunk *pb.PolicyChunk, includeEffectivePolicies bool) (*pb.PolicyChunk, int) { + sanitized := proto.Clone(chunk).(*pb.PolicyChunk) + removed := 0 + if sanitized.GetReviewToken() != "" { + removed = 1 + sanitized.ReviewToken = "" + } + if !includeEffectivePolicies { + sanitized.CurrentEffectivePolicy = nil + sanitized.CandidateEffectivePolicy = nil + } + return sanitized, removed +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert_test.go new file mode 100644 index 00000000..9cbe8ba8 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/policy_convert_test.go @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "encoding/json" + "strings" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +func TestPolicyChunkHardExcludesReviewToken(t *testing.T) { + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + chunk := &pb.PolicyChunk{ + Id: "chunk-1", + Status: "pending", + RuleName: "allow-example", + ReviewToken: "opaque-review-authorization-secret", + CurrentEffectivePolicyHash: "sha256:current", + CandidateEffectivePolicyHash: "sha256:candidate", + CurrentEffectivePolicy: &sandboxv1.SandboxPolicy{}, + CandidateEffectivePolicy: &sandboxv1.SandboxPolicy{}, + } + logs, err := draftChunkLogs(config, sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, "0.0.113", 7, chunk, reconciliationMetadata{Trigger: "draft_notification", NotificationDraftVersion: 7, Consistency: "matched"}) + if err != nil { + t.Fatal(err) + } + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + encoded, err := json.Marshal(record.Body().AsRaw()) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if strings.Contains(body, chunk.ReviewToken) || strings.Contains(body, "review_token\"") { + t.Fatalf("review token leaked into policy evidence: %s", body) + } + if !strings.Contains(body, `"review_token_present":true`) { + t.Fatalf("review token presence diagnostic is missing: %s", body) + } + if strings.Contains(body, "current_effective_policy\"") || strings.Contains(body, "candidate_effective_policy\"") { + t.Fatalf("effective policy bodies were emitted without opt-in: %s", body) + } + for _, hash := range []string{"sha256:current", "sha256:candidate"} { + if !strings.Contains(body, hash) { + t.Fatalf("policy hash %q was lost: %s", hash, body) + } + } +} + +func TestPolicyChunkEffectivePoliciesRequireExplicitOptIn(t *testing.T) { + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeEffectivePolicies = true + chunk := &pb.PolicyChunk{ + Id: "chunk-1", + ReviewToken: "never-export-me", + CurrentEffectivePolicy: &sandboxv1.SandboxPolicy{}, + CandidateEffectivePolicy: &sandboxv1.SandboxPolicy{}, + } + logs, err := draftChunkLogs(config, sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, "0.0.113", 3, chunk, reconciliationMetadata{Trigger: "periodic", Consistency: "not_compared"}) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0).Body().AsRaw()) + body := string(encoded) + if !strings.Contains(body, "current_effective_policy") || !strings.Contains(body, "candidate_effective_policy") { + t.Fatalf("opted-in policy bodies are missing: %s", body) + } + if strings.Contains(body, chunk.ReviewToken) { + t.Fatalf("review token leaked with full policy opt-in: %s", body) + } +} + +func TestPolicyEventHashIgnoresReconciliationTrigger(t *testing.T) { + config := createDefaultConfig().(*Config) + response := &pb.GetDraftPolicyResponse{DraftVersion: 8, RollingSummary: "same snapshot"} + first, err := draftSnapshotLogs(config, sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, "0.0.113", response, reconciliationMetadata{Trigger: "discovery", Consistency: "not_compared"}) + if err != nil { + t.Fatal(err) + } + second, err := draftSnapshotLogs(config, sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, "0.0.113", response, reconciliationMetadata{Trigger: "periodic", Consistency: "not_compared"}) + if err != nil { + t.Fatal(err) + } + if policyEventHash(first) != policyEventHash(second) { + t.Fatal("poll trigger changed stable policy snapshot identity") + } +} + +func TestDraftConsistency(t *testing.T) { + for _, test := range []struct { + notification, draft uint64 + want string + }{ + {0, 5, "not_compared"}, {5, 5, "matched"}, {4, 5, "gateway_ahead"}, {6, 5, "notification_ahead"}, + } { + got := draftConsistency(test.notification, &pb.GetDraftPolicyResponse{DraftVersion: test.draft}) + if got != test.want { + t.Fatalf("draftConsistency(%d,%d)=%q want=%q", test.notification, test.draft, got, test.want) + } + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver.go new file mode 100644 index 00000000..ec5e8046 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver.go @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "slices" + "sync" + "sync/atomic" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/extension/xextension/storage" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/receiver" + "go.uber.org/zap" +) + +type watchReceiver struct { + config *Config + settings receiver.Settings + next consumer.Logs + newClient clientFactory + + client gatewayClient + gatewayVersion string + cancel context.CancelFunc + + discoveryWG sync.WaitGroup + watchWG sync.WaitGroup + + mu sync.Mutex + watching map[string]context.CancelFunc + sandboxes map[string]sandboxRef + + policyStorage storage.Client + reconcileCh chan reconciliationRequest + reconcileWG sync.WaitGroup + reconcileMu sync.Mutex + reconcilePending map[string]struct{} + policyUnhealthy atomic.Bool + + shutdownOnce sync.Once + shutdownDone chan struct{} + shutdownErr error + metrics *watchMetrics +} + +func newWatchReceiver( + config *Config, + settings receiver.Settings, + next consumer.Logs, +) *watchReceiver { + return &watchReceiver{ + config: config, + settings: settings, + next: next, + newClient: newGRPCGatewayClient, + watching: make(map[string]context.CancelFunc), + sandboxes: make(map[string]sandboxRef), + shutdownDone: make(chan struct{}), + metrics: newWatchMetrics(settings.MeterProvider), + } +} + +func (r *watchReceiver) Start(ctx context.Context, host component.Host) error { + client, err := r.newClient(r.config) + if err != nil { + return err + } + r.client = client + runContext, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + if err := r.startPolicyReconciliation(runContext, host); err != nil { + cancel() + _ = client.Close() + return err + } + r.metrics.enabled(ctx) + r.settings.Logger.Info( + "OpenShell source capability enabled", + zap.String("source", "watchsandbox"), + zap.String("durability", "non_resumable"), + zap.Bool("status", true), + zap.Bool("logs", true), + zap.Bool("events", true), + zap.Bool("watch_events_rpc", false), + ) + + infoContext, cancelInfo := context.WithTimeout(context.Background(), 5*time.Second) + r.gatewayVersion, err = client.GatewayVersion(infoContext) + cancelInfo() + if err != nil { + r.settings.Logger.Warn( + "gateway version enrichment unavailable", + zap.Error(err), + ) + } + + r.discoveryWG.Add(1) + go r.discoveryLoop(runContext) + return nil +} + +func (r *watchReceiver) Shutdown(ctx context.Context) error { + r.shutdownOnce.Do(func() { + if r.cancel != nil { + r.cancel() + } + go r.finishShutdown() + }) + select { + case <-r.shutdownDone: + return r.shutdownErr + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *watchReceiver) finishShutdown() { + r.discoveryWG.Wait() + r.watchWG.Wait() + r.reconcileWG.Wait() + var shutdownErrors []error + if r.policyStorage != nil { + shutdownErrors = append(shutdownErrors, r.policyStorage.Close(context.Background())) + } + if r.client != nil { + shutdownErrors = append(shutdownErrors, r.client.Close()) + } + r.shutdownErr = errors.Join(shutdownErrors...) + close(r.shutdownDone) +} + +func (r *watchReceiver) discoveryLoop(ctx context.Context) { + defer r.discoveryWG.Done() + ticker := time.NewTicker(r.config.DiscoveryInterval) + defer ticker.Stop() + for { + r.discover(ctx) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (r *watchReceiver) discover(ctx context.Context) { + sandboxes, err := r.client.List(ctx) + if err != nil { + r.metrics.discoveryFailed(ctx) + if !errors.Is(err, context.Canceled) { + r.settings.Logger.Warn("sandbox discovery failed", zap.Error(err)) + } + return + } + present := make(map[string]struct{}, len(sandboxes)) + for _, sandbox := range sandboxes { + if len(r.config.SandboxNames) > 0 && + !slices.Contains(r.config.SandboxNames, sandbox.Name) { + continue + } + present[sandbox.ID] = struct{}{} + watchContext, cancel := context.WithCancel(ctx) + r.mu.Lock() + _, exists := r.watching[sandbox.ID] + r.sandboxes[sandbox.ID] = sandbox + if !exists { + r.watching[sandbox.ID] = cancel + } + r.mu.Unlock() + if exists { + cancel() + continue + } + r.enqueuePolicyReconciliation(ctx, sandbox, "discovery", 0) + r.watchWG.Add(1) + go func() { + defer cancel() + r.watch(watchContext, sandbox) + }() + } + // Only a successful complete discovery can retire a sandbox. Transient list + // failures leave existing watches and checkpoints intact. + r.mu.Lock() + for id := range r.sandboxes { + if _, exists := present[id]; !exists { + delete(r.sandboxes, id) + if cancel, watching := r.watching[id]; watching { + cancel() + } + } + } + r.mu.Unlock() +} + +func (r *watchReceiver) watch(ctx context.Context, sandbox sandboxRef) { + defer r.watchWG.Done() + defer func() { + r.mu.Lock() + if cancel, exists := r.watching[sandbox.ID]; exists { + cancel() + } + delete(r.watching, sandbox.ID) + r.mu.Unlock() + }() + + backoff := r.config.ReconnectInitial + for { + if ctx.Err() != nil { + return + } + stream, err := r.client.Watch(ctx, sandbox.ID) + if err != nil { + r.metrics.reconnect(ctx, "stream_open_failed") + r.emitWarning( + ctx, + sandbox, + "stream_open_failed", + fmt.Sprintf("WatchSandbox open failed: %v", err), + ) + if !r.waitReconnect(ctx, backoff) { + return + } + backoff = min(backoff*2, r.config.ReconnectMax) + continue + } + + r.enqueuePolicyReconciliation(ctx, sandbox, "stream_connected", 0) + received := false + for { + event, recvErr := stream.Recv() + if recvErr != nil { + if errors.Is(recvErr, context.Canceled) || ctx.Err() != nil { + return + } + code := "stream_receive_failed" + if errors.Is(recvErr, io.EOF) { + code = "stream_closed" + } + r.metrics.reconnect(ctx, code) + r.emitWarning( + ctx, + sandbox, + code, + fmt.Sprintf("WatchSandbox interrupted: %v", recvErr), + ) + break + } + logs, convertErr := convertStreamEvent( + r.config, + sandbox, + r.gatewayVersion, + event, + ) + if convertErr != nil { + r.emitWarning( + ctx, + sandbox, + "conversion_failed", + convertErr.Error(), + ) + continue + } + received = true + r.metrics.succeeded(ctx) + deliveryErr := r.consumeWithBackpressure(ctx, logs) + if deliveryErr != nil { + if errors.Is(deliveryErr, context.Canceled) { + return + } + r.settings.Logger.Warn( + "sandbox event delivery failed", + zap.Error(deliveryErr), + ) + } + if deliveryErr == nil { + if payload, ok := event.GetPayload().(*pb.SandboxStreamEvent_DraftPolicyUpdate); ok && payload.DraftPolicyUpdate != nil { + r.enqueuePolicyReconciliation(ctx, sandbox, "draft_notification", payload.DraftPolicyUpdate.GetDraftVersion()) + } + } + } + if received { + backoff = r.config.ReconnectInitial + } + if !r.waitReconnect(ctx, backoff) { + return + } + backoff = min(backoff*2, r.config.ReconnectMax) + } +} + +func (r *watchReceiver) emitWarning( + ctx context.Context, + sandbox sandboxRef, + code string, + message string, +) { + r.metrics.gap(ctx, code) + logs, err := warningLogs( + r.config, + sandbox, + r.gatewayVersion, + code, + message, + ) + if err != nil { + r.settings.Logger.Error( + "construct stream warning evidence", + zap.Error(err), + ) + return + } + if err := r.consumeWithBackpressure(ctx, logs); err != nil && + !errors.Is(err, context.Canceled) { + r.settings.Logger.Warn( + "stream warning delivery failed", + zap.Error(err), + ) + } +} + +func (r *watchReceiver) waitReconnect(ctx context.Context, delay time.Duration) bool { + jitter := time.Duration(rand.Int64N(int64(delay/2) + 1)) + timer := time.NewTimer(min(delay+jitter, r.config.ReconnectMax)) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func (r *watchReceiver) consumeWithBackpressure( + ctx context.Context, + logs plog.Logs, +) error { + delay := time.Second + for { + // Downstream processors may mutate their input even when delivery fails. + // Retain the receiver-owned evidence for retries and checkpoint hashes. + attempt := plog.NewLogs() + logs.CopyTo(attempt) + if err := r.next.ConsumeLogs(ctx, attempt); err == nil { + return nil + } else if consumererror.IsPermanent(err) { + return err + } + r.metrics.backpressured(ctx) + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + delay = min(delay*2, 30*time.Second) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_lifecycle_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_lifecycle_test.go new file mode 100644 index 00000000..a9c6ba92 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_lifecycle_test.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/receiver" + "go.uber.org/zap" +) + +type lifecycleHost struct{} + +func (lifecycleHost) GetExtensions() map[component.ID]component.Component { + return map[component.ID]component.Component{} +} + +func lifecycleSettings() receiver.Settings { + return receiver.Settings{ + ID: component.NewID(componentType), + TelemetrySettings: component.TelemetrySettings{ + Logger: zap.NewNop(), + }, + } +} + +type lifecycleGateway struct { + sandboxes []sandboxRef + watchMu sync.Mutex + watchedIDs []string + watchCalls atomic.Int32 + listCalls atomic.Int32 + closeCalls atomic.Int32 + versionError error +} + +func (g *lifecycleGateway) List(context.Context) ([]sandboxRef, error) { + g.listCalls.Add(1) + return g.sandboxes, nil +} + +func (g *lifecycleGateway) Watch(ctx context.Context, sandboxID string) (eventStream, error) { + g.watchMu.Lock() + g.watchedIDs = append(g.watchedIDs, sandboxID) + g.watchMu.Unlock() + if g.watchCalls.Add(1) == 1 { + return &scriptedEventStream{events: []*pb.SandboxStreamEvent{{ + Payload: &pb.SandboxStreamEvent_Sandbox{Sandbox: &pb.Sandbox{ + Metadata: &datamodelv1.ObjectMeta{ + Id: sandboxID, + Name: "allowed", + Workspace: "default", + ResourceVersion: 1, + }, + }}, + }}}, nil + } + return blockingEventStream{ctx: ctx}, nil +} + +func (g *lifecycleGateway) GatewayVersion(context.Context) (string, error) { + return "0.0.test", g.versionError +} + +func (g *lifecycleGateway) GetDraft(context.Context, string) (*pb.GetDraftPolicyResponse, error) { + return nil, nil +} + +func (g *lifecycleGateway) GetDraftHistory(context.Context, string) (*pb.GetDraftHistoryResponse, error) { + return nil, nil +} + +func (g *lifecycleGateway) GetPolicyStatus(context.Context, string) (*pb.GetSandboxPolicyStatusResponse, error) { + return nil, nil +} + +func (g *lifecycleGateway) ListPolicyRevisions(context.Context, string) ([]*pb.SandboxPolicyRevision, error) { + return nil, nil +} + +func (g *lifecycleGateway) Close() error { + g.closeCalls.Add(1) + return nil +} + +type scriptedEventStream struct { + events []*pb.SandboxStreamEvent + next int +} + +func (s *scriptedEventStream) Recv() (*pb.SandboxStreamEvent, error) { + if s.next >= len(s.events) { + return nil, io.EOF + } + event := s.events[s.next] + s.next++ + return event, nil +} + +type blockingEventStream struct { + ctx context.Context +} + +func (s blockingEventStream) Recv() (*pb.SandboxStreamEvent, error) { + <-s.ctx.Done() + return nil, s.ctx.Err() +} + +func TestReceiverLifecycleFiltersDiscoveryAndSurfacesStreamGap(t *testing.T) { + gateway := &lifecycleGateway{sandboxes: []sandboxRef{ + {ID: "sandbox-allowed", Name: "allowed"}, + {ID: "sandbox-denied", Name: "denied"}, + }} + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + config.SandboxNames = []string{"allowed"} + config.DiscoveryInterval = time.Hour + config.ReconnectInitial = time.Millisecond + config.ReconnectMax = 2 * time.Millisecond + + kinds := make(chan string, 4) + next, err := consumer.NewLogs(func(_ context.Context, logs plog.Logs) error { + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + kind, _ := record.Attributes().Get("openshell.acquisition.kind") + kinds <- kind.Str() + return nil + }) + if err != nil { + t.Fatal(err) + } + receiver := newWatchReceiver(config, lifecycleSettings(), next) + receiver.newClient = func(*Config) (gatewayClient, error) { return gateway, nil } + if err := receiver.Start(context.Background(), lifecycleHost{}); err != nil { + t.Fatal(err) + } + + wantKinds := map[string]bool{"sandbox.lifecycle": false, "stream.warning": false} + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + for !wantKinds["sandbox.lifecycle"] || !wantKinds["stream.warning"] { + select { + case kind := <-kinds: + if _, ok := wantKinds[kind]; ok { + wantKinds[kind] = true + } + case <-deadline.C: + t.Fatalf("timed out waiting for lifecycle and gap evidence: %#v", wantKinds) + } + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := receiver.Shutdown(shutdownContext); err != nil { + t.Fatal(err) + } + if gateway.listCalls.Load() == 0 { + t.Fatal("sandbox discovery was not attempted") + } + if gateway.closeCalls.Load() != 1 { + t.Fatalf("gateway close calls=%d, want 1", gateway.closeCalls.Load()) + } + gateway.watchMu.Lock() + defer gateway.watchMu.Unlock() + for _, sandboxID := range gateway.watchedIDs { + if sandboxID != "sandbox-allowed" { + t.Fatalf("unauthorized sandbox was watched: %q", sandboxID) + } + } +} + +func TestReceiverStartFailsClosedAndReleasesClient(t *testing.T) { + config := createDefaultConfig().(*Config) + config.TokenEnv = "" + receiver := newWatchReceiver(config, lifecycleSettings(), nil) + receiver.newClient = func(*Config) (gatewayClient, error) { + return nil, errors.New("client construction failed") + } + if err := receiver.Start(context.Background(), lifecycleHost{}); err == nil { + t.Fatal("receiver start accepted a failed client") + } + + storageID := component.NewID(component.MustNewType("file_storage")) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.StorageID = &storageID + gateway := &lifecycleGateway{} + receiver = newWatchReceiver(config, lifecycleSettings(), nil) + receiver.newClient = func(*Config) (gatewayClient, error) { return gateway, nil } + if err := receiver.Start(context.Background(), lifecycleHost{}); err == nil { + t.Fatal("receiver start accepted missing policy storage") + } + if gateway.closeCalls.Load() != 1 { + t.Fatalf("failed start close calls=%d, want 1", gateway.closeCalls.Load()) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_test.go new file mode 100644 index 00000000..99f63a3f --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/receiver_test.go @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/receiver" +) + +func TestEveryPublicWatchSandboxVariantConverts(t *testing.T) { + config := createDefaultConfig().(*Config) + ref := sandboxRef{ID: "sandbox-id", Name: "sandbox-name"} + tests := map[string]struct { + event *pb.SandboxStreamEvent + kind string + }{ + "lifecycle": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_Sandbox{ + Sandbox: &pb.Sandbox{ + Metadata: &datamodelv1.ObjectMeta{ + Id: "sandbox-id", + Name: "sandbox-name", + Workspace: "default", + ResourceVersion: 7, + }, + Status: &pb.SandboxStatus{CurrentPolicyVersion: 3}, + }, + }, + }, + kind: "sandbox.lifecycle", + }, + "gateway log": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_Log{ + Log: &pb.SandboxLogLine{ + SandboxId: "sandbox-id", + TimestampMs: 1_700_000_000_000, + Source: "gateway", + Message: "allowed", + }, + }, + }, + kind: "gateway.log", + }, + "sandbox log": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_Log{ + Log: &pb.SandboxLogLine{ + SandboxId: "sandbox-id", + Source: "sandbox", + Message: "supervisor ready", + }, + }, + }, + kind: "sandbox.log", + }, + "platform event": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_Event{ + Event: &pb.PlatformEvent{ + TimestampMs: 1_700_000_000_001, + Source: "kubernetes", + Type: "Warning", + Reason: "BackOff", + }, + }, + }, + kind: "platform.event", + }, + "draft update": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_DraftPolicyUpdate{ + DraftPolicyUpdate: &pb.DraftPolicyUpdate{ + DraftVersion: 8, + NewChunks: 1, + TotalPending: 2, + Summary: "network evidence", + }, + }, + }, + kind: "policy.draft_updated", + }, + "warning": { + event: &pb.SandboxStreamEvent{ + Payload: &pb.SandboxStreamEvent_Warning{ + Warning: &pb.SandboxStreamWarning{Message: "lagged"}, + }, + }, + kind: "stream.warning", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + logs, err := convertStreamEvent(config, ref, "v1.2.3", test.event) + if err != nil { + t.Fatal(err) + } + if logs.LogRecordCount() != 1 { + t.Fatalf("record count=%d", logs.LogRecordCount()) + } + record := logs.ResourceLogs(). + At(0). + ScopeLogs(). + At(0). + LogRecords(). + At(0) + kind, _ := record.Attributes().Get("openshell.acquisition.kind") + if kind.Str() != test.kind { + t.Fatalf("kind=%q want=%q", kind.Str(), test.kind) + } + if record.ObservedTimestamp() == 0 { + t.Fatal("observed time missing") + } + for key, expected := range map[string]any{ + "openshell.acquisition.transport": "grpc", + "openshell.acquisition.api_operation": "openshell.v1.OpenShell/WatchSandbox", + "openshell.acquisition.stream_resumable": false, + "openshell.acquisition.follow_status": true, + "openshell.acquisition.follow_logs": true, + "openshell.acquisition.follow_events": true, + "openshell.acquisition.stop_on_terminal": false, + "openshell.acquisition.log_since_ms": int64(0), + "openshell.acquisition.log_min_level": "", + "openshell.acquisition.log_tail_lines": int64(config.LogTailLines), + "openshell.acquisition.event_tail": int64(config.EventTail), + } { + value, ok := record.Attributes().Get(key) + if !ok || value.AsRaw() != expected { + t.Fatalf("%s=%#v, want %#v", key, value.AsRaw(), expected) + } + } + logSources, ok := record.Attributes().Get("openshell.acquisition.log_sources") + if !ok || logSources.Slice().Len() != 2 || + logSources.Slice().At(0).Str() != "gateway" || + logSources.Slice().At(1).Str() != "sandbox" { + t.Fatalf("log sources=%#v", logSources.AsRaw()) + } + payloadType, ok := record.Attributes().Get("openshell.acquisition.payload_type") + if !ok || !strings.HasPrefix(payloadType.Str(), "openshell.v1.") { + t.Fatalf("payload type=%q", payloadType.Str()) + } + sourcePayload, ok := record.Body().Map().Get("source_payload") + if !ok || sourcePayload.Type() != pcommon.ValueTypeMap { + t.Fatal("full public source payload is missing") + } + }) + } +} + +func TestLifecyclePreservesCompletePublicPayload(t *testing.T) { + config := createDefaultConfig().(*Config) + logs, err := lifecycleLogs(config, "v0.0.113", &pb.Sandbox{ + Metadata: &datamodelv1.ObjectMeta{ + Id: "sandbox-id", + Name: "sandbox-name", + CreatedAtMs: 1_700_000_000_000, + Labels: map[string]string{"team": "security"}, + ResourceVersion: 19, + Annotations: map[string]string{"trace.id": "trace-from-annotation"}, + Workspace: "default", + DeletionTimestampMs: 1_700_000_100_000, + }, + Spec: &pb.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"authorization": "Bearer source-secret"}, + Template: &pb.SandboxTemplate{ + Image: "example.invalid/agent@sha256:abc", + AgentSocket: "/run/openshell/agent.sock", + Annotations: map[string]string{"future.context": "preserved"}, + }, + Providers: []string{"nvidia"}, + }, + Status: &pb.SandboxStatus{ + SandboxName: "driver-sandbox-id", + AgentPod: "agent-pod-1", + AgentFd: "agent.sock", + SandboxFd: "sandbox.sock", + Conditions: []*pb.SandboxCondition{{ + Type: "Ready", + Status: "True", + Reason: "Provisioned", + Message: "sandbox is ready", + LastTransitionTime: "2026-08-21T00:00:00Z", + }}, + Phase: pb.SandboxPhase_SANDBOX_PHASE_READY, + CurrentPolicyVersion: 7, + }, + }) + if err != nil { + t.Fatal(err) + } + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + sourcePayload, ok := record.Body().Map().Get("source_payload") + if !ok || sourcePayload.Type() != pcommon.ValueTypeMap { + t.Fatal("source_payload is missing") + } + metadata, ok := sourcePayload.Map().Get("metadata") + if !ok || metadata.Map().AsRaw()["annotations"].(map[string]any)["trace.id"] != "trace-from-annotation" { + t.Fatalf("metadata=%#v", metadata.AsRaw()) + } + spec, ok := sourcePayload.Map().Get("spec") + if !ok { + t.Fatal("spec is missing") + } + if spec.Map().AsRaw()["environment"].(map[string]any)["authorization"] != "Bearer source-secret" { + t.Fatalf("spec=%#v", spec.AsRaw()) + } + template := spec.Map().AsRaw()["template"].(map[string]any) + if template["future.context"] != nil { + t.Fatalf("unexpected flattened template=%#v", template) + } + if template["annotations"].(map[string]any)["future.context"] != "preserved" { + t.Fatalf("template annotations=%#v", template["annotations"]) + } + status, ok := sourcePayload.Map().Get("status") + if !ok { + t.Fatal("status is missing") + } + conditions := status.Map().AsRaw()["conditions"].([]any) + if len(conditions) != 1 || conditions[0].(map[string]any)["reason"] != "Provisioned" { + t.Fatalf("conditions=%#v", conditions) + } +} + +func TestLifecyclePolicyVersionIsOnlyEmittedWhenReported(t *testing.T) { + config := createDefaultConfig().(*Config) + for _, test := range []struct { + name string + policyVersion uint32 + expectPolicyField bool + }{ + {name: "unavailable", policyVersion: 0, expectPolicyField: false}, + {name: "reported", policyVersion: 3, expectPolicyField: true}, + } { + t.Run(test.name, func(t *testing.T) { + logs, err := lifecycleLogs(config, "", &pb.Sandbox{ + Metadata: &datamodelv1.ObjectMeta{ + Id: "sandbox-id", + Name: "sandbox-name", + Workspace: "default", + ResourceVersion: 7, + }, + Status: &pb.SandboxStatus{CurrentPolicyVersion: test.policyVersion}, + }) + if err != nil { + t.Fatal(err) + } + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + attribute, attributeExists := record.Attributes().Get("openshell.policy.version") + sandboxValue, sandboxExists := record.Body().Map().Get("sandbox") + if !sandboxExists || sandboxValue.Type() != pcommon.ValueTypeMap { + t.Fatal("lifecycle body has no sandbox object") + } + bodyVersion, bodyExists := sandboxValue.Map().Get("policy_version") + if attributeExists != test.expectPolicyField || bodyExists != test.expectPolicyField { + t.Fatalf( + "policy presence attribute=%t body=%t, want %t", + attributeExists, + bodyExists, + test.expectPolicyField, + ) + } + if test.expectPolicyField { + if attribute.Type() != pcommon.ValueTypeInt || attribute.Int() != int64(test.policyVersion) { + t.Fatalf("policy attribute=%v, want %d", attribute.AsRaw(), test.policyVersion) + } + if bodyVersion.Type() != pcommon.ValueTypeInt || bodyVersion.Int() != int64(test.policyVersion) { + t.Fatalf("policy body=%v, want %d", bodyVersion.AsRaw(), test.policyVersion) + } + } + }) + } +} + +func TestNilAndUnsupportedPayloadBecomeConversionErrors(t *testing.T) { + config := createDefaultConfig().(*Config) + ref := sandboxRef{ID: "sandbox-id"} + for _, event := range []*pb.SandboxStreamEvent{nil, {}} { + if _, err := convertStreamEvent(config, ref, "", event); err == nil { + t.Fatal("expected conversion error") + } + } +} + +func TestShutdownHonorsContextDeadline(t *testing.T) { + watch := newWatchReceiver( + createDefaultConfig().(*Config), + receiver.Settings{}, + nil, + ) + watch.discoveryWG.Add(1) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := watch.Shutdown(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Shutdown() error = %v, want context cancellation", err) + } + watch.discoveryWG.Done() + if err := watch.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown() after workers exit: %v", err) + } +} + +func TestConsumeWithBackpressureDoesNotRetryPermanentError(t *testing.T) { + next, err := consumer.NewLogs(func(context.Context, plog.Logs) error { + return consumererror.NewPermanent(errors.New("invalid downstream request")) + }) + if err != nil { + t.Fatal(err) + } + watch := newWatchReceiver( + createDefaultConfig().(*Config), + receiver.Settings{}, + next, + ) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err = watch.consumeWithBackpressure(ctx, plog.NewLogs()) + if !consumererror.IsPermanent(err) { + t.Fatalf("permanent error was retried or lost: %v", err) + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler.go new file mode 100644 index 00000000..be1845c8 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler.go @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension/xextension/storage" + "go.opentelemetry.io/collector/pdata/plog" + "go.uber.org/zap" + "google.golang.org/grpc/status" +) + +type reconciliationRequest struct { + Sandbox sandboxRef + Trigger string + NotificationDraftVersion uint64 +} + +type reconciliationAttemptKey struct{} + +type reconciliationAttempt struct { + failed bool +} + +func (r *watchReceiver) startPolicyReconciliation(ctx context.Context, host component.Host) error { + if !r.config.PolicyReconciliation.Enabled { + r.metrics.policyDisabled(ctx) + r.settings.Logger.Info("OpenShell source capability disabled", zapString("source", "policy_reconciliation")) + return nil + } + storageID := r.config.PolicyReconciliation.StorageID + extension, ok := host.GetExtensions()[*storageID] + if !ok { + return fmt.Errorf("policy reconciliation storage extension %q not found", storageID.String()) + } + storageExtension, ok := extension.(storage.Extension) + if !ok { + return fmt.Errorf("policy reconciliation extension %q is not storage", storageID.String()) + } + client, err := storageExtension.GetClient(ctx, component.KindReceiver, r.settings.ID, "policy-reconciliation") + if err != nil { + return fmt.Errorf("create policy reconciliation storage client: %w", err) + } + r.policyStorage = client + r.reconcileCh = make(chan reconciliationRequest, r.config.PolicyReconciliation.QueueSize) + r.metrics.policyEnabled(ctx) + r.recordPolicyQueueState(ctx) + r.reconcilePending = make(map[string]struct{}) + for range r.config.PolicyReconciliation.Workers { + r.reconcileWG.Add(1) + go r.policyReconciliationWorker(ctx) + } + r.reconcileWG.Add(1) + go r.policyReconciliationTicker(ctx) + r.settings.Logger.Info( + "OpenShell source capability enabled", + zapString("source", "policy_reconciliation"), + zapString("durability", "checkpointed_api_snapshot"), + zapBool("draft_snapshots", true), + zap.Duration("interval", r.config.PolicyReconciliation.Interval), + zap.Duration("timeout", r.config.PolicyReconciliation.Timeout), + zap.Int("workers", r.config.PolicyReconciliation.Workers), + zap.Int("queue_capacity", r.config.PolicyReconciliation.QueueSize), + zap.Uint32("max_revisions", r.config.PolicyReconciliation.MaxRevisions), + zapBool("draft_chunks", true), + zapBool("draft_history", r.config.PolicyReconciliation.IncludeHistory), + zapBool("policy_status", true), + zapBool("policy_revisions", true), + zapBool("effective_policy_bodies", r.config.PolicyReconciliation.IncludeEffectivePolicies), + zapBool("review_tokens_exported", false), + ) + return nil +} + +// Tiny wrappers keep reconciler tests independent of zap field construction. +func zapString(key, value string) zap.Field { return zap.String(key, value) } +func zapBool(key string, value bool) zap.Field { return zap.Bool(key, value) } + +func (r *watchReceiver) policyReconciliationTicker(ctx context.Context) { + defer r.reconcileWG.Done() + ticker := time.NewTicker(r.config.PolicyReconciliation.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.recordPolicyQueueState(ctx) + r.mu.Lock() + sandboxes := make([]sandboxRef, 0, len(r.sandboxes)) + for _, sandbox := range r.sandboxes { + sandboxes = append(sandboxes, sandbox) + } + r.mu.Unlock() + for _, sandbox := range sandboxes { + r.enqueuePolicyReconciliation(ctx, sandbox, "periodic", 0) + } + } + } +} + +func (r *watchReceiver) enqueuePolicyReconciliation(ctx context.Context, sandbox sandboxRef, trigger string, notificationVersion uint64) { + if !r.config.PolicyReconciliation.Enabled || r.reconcileCh == nil { + return + } + r.reconcileMu.Lock() + if _, exists := r.reconcilePending[sandbox.ID]; exists { + r.reconcileMu.Unlock() + return + } + r.reconcilePending[sandbox.ID] = struct{}{} + r.reconcileMu.Unlock() + request := reconciliationRequest{Sandbox: sandbox, Trigger: trigger, NotificationDraftVersion: notificationVersion} + select { + case r.reconcileCh <- request: + case <-ctx.Done(): + r.clearReconciliationPending(sandbox.ID) + default: + r.clearReconciliationPending(sandbox.ID) + r.metrics.reconciliationGap(ctx, "queue_full") + r.emitReconciliationWarning(ctx, request, "openshell.exporter/policy-reconciliation", "queue_full", "policy reconciliation queue is full; the periodic read will retry") + } + r.recordPolicyQueueState(ctx) +} + +func (r *watchReceiver) clearReconciliationPending(sandboxID string) { + r.reconcileMu.Lock() + delete(r.reconcilePending, sandboxID) + r.reconcileMu.Unlock() +} + +func (r *watchReceiver) recordPolicyQueueState(ctx context.Context) { + if r.reconcileCh == nil { + return + } + r.metrics.reconciliationState(ctx, len(r.reconcileCh), cap(r.reconcileCh)) +} + +func (r *watchReceiver) policyReconciliationWorker(ctx context.Context) { + defer r.reconcileWG.Done() + for { + select { + case <-ctx.Done(): + return + case request := <-r.reconcileCh: + r.recordPolicyQueueState(ctx) + r.mu.Lock() + _, exists := r.sandboxes[request.Sandbox.ID] + r.mu.Unlock() + if exists { + r.reconcileSandbox(ctx, request) + } + // Keep the pending marker through completion: one sandbox can have + // only one queued or active read, without retaining per-sandbox locks. + r.clearReconciliationPending(request.Sandbox.ID) + } + } +} + +func (r *watchReceiver) reconcileSandbox(parent context.Context, request reconciliationRequest) { + ctx, cancel := context.WithTimeout(parent, r.config.PolicyReconciliation.Timeout) + defer cancel() + attempt := &reconciliationAttempt{} + ctx = context.WithValue(ctx, reconciliationAttemptKey{}, attempt) + state, err := r.loadPolicyState(ctx, request.Sandbox.ID) + if err != nil { + r.recordReconciliationFailure(ctx, "state_read") + r.emitReconciliationWarning(ctx, request, "file_storage/Get", "state_read_failed", err.Error()) + return + } + changed := state.migrated + if state.migrated { + if !r.emitReconciliationWarning(ctx, request, "file_storage/Get", "state_migrated", "policy reconciliation checkpoint upgraded; mutable snapshots will replay once") { + r.recordReconciliationFailure(ctx, "delivery_failed") + return + } + } + + draft, draftErr := r.client.GetDraft(ctx, request.Sandbox.Name) + metadata := reconciliationMetadata{Trigger: request.Trigger, NotificationDraftVersion: request.NotificationDraftVersion, Consistency: draftConsistency(request.NotificationDraftVersion, draft)} + if metadata.Consistency == "gateway_ahead" || metadata.Consistency == "notification_ahead" { + r.metrics.reconciliationConsistencyGap(ctx, metadata.Consistency) + } + if draftErr != nil { + r.recordPolicyReadFailure(ctx, request, "openshell.v1.OpenShell/GetDraftPolicy", draftErr) + } else { + logs, convertErr := draftSnapshotLogs(r.config, request.Sandbox, r.gatewayVersion, draft, metadata) + if convertErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetDraftPolicy", convertErr) + } else { + hash := policyEventHash(logs) + if hash != state.DraftHash && r.deliverPolicyLogs(ctx, logs) { + state.DraftHash = hash + changed = true + } + } + chunkEvents := make([]policySequenceEvent, 0, len(draft.GetChunks())) + chunksComplete := true + for _, chunk := range draft.GetChunks() { + logs, convertErr := draftChunkLogs(r.config, request.Sandbox, r.gatewayVersion, draft.GetDraftVersion(), chunk, metadata) + if convertErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetDraftPolicy", convertErr) + chunksComplete = false + continue + } + event, eventErr := newPolicySequenceEvent(logs, 0, chunk.GetId()) + if eventErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetDraftPolicy", eventErr) + chunksComplete = false + continue + } + chunkEvents = append(chunkEvents, event) + } + canonicalizePolicySequence(chunkEvents) + if r.reconcilePolicySequence(ctx, request, &state.Chunks, chunkEvents, chunksComplete, false, "openshell.v1.OpenShell/GetDraftPolicy", "draft chunks") { + changed = true + } + } + + policyStatus, statusErr := r.client.GetPolicyStatus(ctx, request.Sandbox.Name) + if statusErr != nil { + r.recordPolicyReadFailure(ctx, request, "openshell.v1.OpenShell/GetSandboxPolicyStatus", statusErr) + } else { + logs, convertErr := policyStatusLogs(r.config, request.Sandbox, r.gatewayVersion, policyStatus, metadata) + if convertErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetSandboxPolicyStatus", convertErr) + } else { + hash := policyEventHash(logs) + if hash != state.StatusHash && r.deliverPolicyLogs(ctx, logs) { + state.StatusHash = hash + changed = true + } + } + } + + revisions, revisionsErr := r.client.ListPolicyRevisions(ctx, request.Sandbox.Name) + revisionsComplete := revisionsErr == nil + if revisionsErr != nil { + r.recordPolicyReadFailure(ctx, request, "openshell.v1.OpenShell/ListSandboxPolicies", revisionsErr) + } + if len(revisions) > 0 || revisionsComplete { + revisionEvents := make([]policySequenceEvent, 0, len(revisions)) + for _, revision := range revisions { + logs, convertErr := policyRevisionLogs(r.config, request.Sandbox, r.gatewayVersion, revision, metadata) + if convertErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/ListSandboxPolicies", convertErr) + revisionsComplete = false + continue + } + event, eventErr := newPolicySequenceEvent(logs, int64(revision.GetVersion()), revision.GetPolicyHash()) + if eventErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/ListSandboxPolicies", eventErr) + revisionsComplete = false + continue + } + revisionEvents = append(revisionEvents, event) + } + canonicalizePolicySequence(revisionEvents) + if r.reconcilePolicySequence(ctx, request, &state.Revisions, revisionEvents, revisionsComplete, true, "openshell.v1.OpenShell/ListSandboxPolicies", "policy revisions") { + changed = true + } + } + + if r.config.PolicyReconciliation.IncludeHistory { + history, historyErr := r.client.GetDraftHistory(ctx, request.Sandbox.Name) + if historyErr != nil { + r.recordPolicyReadFailure(ctx, request, "openshell.v1.OpenShell/GetDraftHistory", historyErr) + } else { + historyEvents := make([]policySequenceEvent, 0, len(history.GetEntries())) + historyComplete := true + for _, entry := range history.GetEntries() { + logs, convertErr := draftHistoryLogs(r.config, request.Sandbox, r.gatewayVersion, entry, metadata) + if convertErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetDraftHistory", convertErr) + historyComplete = false + continue + } + event, eventErr := newPolicySequenceEvent(logs, entry.GetTimestampMs(), entry.GetChunkId()+"\x00"+entry.GetEventType()) + if eventErr != nil { + r.recordPolicyConversionFailure(ctx, request, "openshell.v1.OpenShell/GetDraftHistory", eventErr) + historyComplete = false + continue + } + historyEvents = append(historyEvents, event) + } + canonicalizePolicySequence(historyEvents) + if r.reconcilePolicySequence(ctx, request, &state.History, historyEvents, historyComplete, true, "openshell.v1.OpenShell/GetDraftHistory", "draft history") { + changed = true + } + } + } + + if changed { + if err := r.savePolicyState(ctx, request.Sandbox.ID, state); err != nil { + r.recordReconciliationFailure(ctx, "state_write") + r.emitReconciliationWarning(ctx, request, "file_storage/Set", "state_write_failed", err.Error()) + return + } + } + if !attempt.failed { + r.recordReconciliationSuccess(ctx) + } +} + +func (r *watchReceiver) recordPolicyReadFailure(ctx context.Context, request reconciliationRequest, operation string, err error) { + if errors.Is(err, context.Canceled) || (errors.Is(err, context.DeadlineExceeded) && ctx.Err() != nil) { + if attempt, ok := ctx.Value(reconciliationAttemptKey{}).(*reconciliationAttempt); ok { + attempt.failed = true + } + return + } + code := status.Code(err).String() + if reason, incomplete := policyRevisionGapReason(err); incomplete { + code = reason + r.metrics.reconciliationGap(ctx, reason) + } + r.recordReconciliationFailure(ctx, code) + r.emitReconciliationWarning(ctx, request, operation, code, err.Error()) +} + +func (r *watchReceiver) recordPolicyConversionFailure(ctx context.Context, request reconciliationRequest, operation string, err error) { + r.recordReconciliationFailure(ctx, "conversion_failed") + r.emitReconciliationWarning(ctx, request, operation, "conversion_failed", err.Error()) +} + +func (r *watchReceiver) recordReconciliationFailure(ctx context.Context, reason string) { + reason = boundedPolicyFailureReason(reason) + if attempt, ok := ctx.Value(reconciliationAttemptKey{}).(*reconciliationAttempt); ok { + attempt.failed = true + } + r.metrics.reconciliationFailed(ctx, reason) + if r.policyUnhealthy.CompareAndSwap(false, true) && r.settings.Logger != nil { + r.settings.Logger.Warn("policy reconciliation degraded", zap.String("reason", reason)) + } +} + +func (r *watchReceiver) recordReconciliationSuccess(ctx context.Context) { + r.metrics.reconciliationSucceeded(ctx) + if r.policyUnhealthy.Swap(false) && r.settings.Logger != nil { + r.settings.Logger.Info("policy reconciliation recovered") + } +} + +func (r *watchReceiver) emitReconciliationWarning(ctx context.Context, request reconciliationRequest, operation, code, message string) bool { + metadata := reconciliationMetadata{Trigger: request.Trigger, NotificationDraftVersion: request.NotificationDraftVersion, Consistency: "unavailable"} + logs, err := reconciliationWarningLogs(r.config, request.Sandbox, r.gatewayVersion, operation, code, message, metadata) + if err != nil { + if r.settings.Logger != nil { + r.settings.Logger.Error("construct policy reconciliation warning", zap.Error(err)) + } + return false + } + if err := r.consumeWithBackpressure(ctx, logs); err != nil && !errors.Is(err, context.Canceled) { + if r.settings.Logger != nil { + r.settings.Logger.Warn("policy reconciliation warning delivery failed", zap.Error(err)) + } + return false + } + return ctx.Err() == nil +} + +func (r *watchReceiver) reconcilePolicySequence( + ctx context.Context, + request reconciliationRequest, + checkpoint *policySequenceCheckpoint, + events []policySequenceEvent, + complete bool, + appendOnly bool, + operation string, + lane string, +) bool { + if !complete { + for _, event := range events { + if !r.deliverPolicyLogs(ctx, event.logs) { + return false + } + } + return false + } + start := 0 + var next policySequenceCheckpoint + reset := false + if appendOnly { + start, next, reset = policySequencePlan(*checkpoint, events) + } else { + start, next = policySnapshotPlan(*checkpoint, events) + } + if reset { + r.metrics.reconciliationGap(ctx, "snapshot_reset") + message := lane + " no longer matches the persisted prefix; replaying the complete current snapshot" + if !r.emitReconciliationWarning(ctx, request, operation, "snapshot_reset", message) { + r.recordReconciliationFailure(ctx, "delivery_failed") + return false + } + } + for _, event := range events[start:] { + if !r.deliverPolicyLogs(ctx, event.logs) { + return false + } + } + if *checkpoint == next { + return false + } + *checkpoint = next + return true +} + +func (r *watchReceiver) deliverPolicyLogs(ctx context.Context, logs plog.Logs) bool { + if err := r.consumeWithBackpressure(ctx, logs); err != nil { + if !errors.Is(err, context.Canceled) { + r.recordReconciliationFailure(ctx, "delivery_failed") + if r.settings.Logger != nil { + r.settings.Logger.Warn("policy evidence delivery failed", zap.Error(err)) + } + } + return false + } + return true +} + +func (r *watchReceiver) loadPolicyState(ctx context.Context, sandboxID string) (*policyReconciliationState, error) { + encoded, err := r.policyStorage.Get(ctx, policyStateKey(sandboxID)) + if err != nil { + return nil, err + } + return decodePolicyState(encoded) +} + +func (r *watchReceiver) savePolicyState(ctx context.Context, sandboxID string, state *policyReconciliationState) error { + encoded, err := encodePolicyState(state) + if err != nil { + return err + } + return r.policyStorage.Set(ctx, policyStateKey(sandboxID), encoded) +} + +func policyStateKey(sandboxID string) string { + sum := sha256.Sum256([]byte(sandboxID)) + return "sandbox/" + hex.EncodeToString(sum[:]) +} + +func policyEventHash(logs plog.Logs) string { + if logs.LogRecordCount() == 0 { + return "" + } + record := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + encoded, _ := json.Marshal(map[string]any{ + "kind": attributeRaw(record, "openshell.acquisition.kind"), + "body": record.Body().AsRaw(), + }) + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]) +} + +func attributeRaw(record plog.LogRecord, key string) any { + value, ok := record.Attributes().Get(key) + if !ok { + return nil + } + return value.AsRaw() +} + +func draftConsistency(notification uint64, draft interface{ GetDraftVersion() uint64 }) string { + if draft == nil { + return "unavailable" + } + if notification == 0 { + return "not_compared" + } + retrieved := draft.GetDraftVersion() + switch { + case retrieved == notification: + return "matched" + case retrieved > notification: + return "gateway_ahead" + default: + return "notification_ahead" + } +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler_test.go new file mode 100644 index 00000000..ad69b4fd --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciler_test.go @@ -0,0 +1,580 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/consumer/consumererror" + "go.opentelemetry.io/collector/extension/xextension/storage" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/receiver" +) + +type reconciliationGateway struct { + mu sync.Mutex + draft *pb.GetDraftPolicyResponse + draftErr error + history *pb.GetDraftHistoryResponse + status *pb.GetSandboxPolicyStatusResponse + revisions []*pb.SandboxPolicyRevision + revisionsErr error + draftCalls int +} + +func (g *reconciliationGateway) List(context.Context) ([]sandboxRef, error) { return nil, nil } +func (g *reconciliationGateway) Watch(context.Context, string) (eventStream, error) { return nil, nil } +func (g *reconciliationGateway) GatewayVersion(context.Context) (string, error) { + return "0.0.113", nil +} +func (g *reconciliationGateway) GetDraft(context.Context, string) (*pb.GetDraftPolicyResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + g.draftCalls++ + return g.draft, g.draftErr +} +func (g *reconciliationGateway) GetDraftHistory(context.Context, string) (*pb.GetDraftHistoryResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.history != nil { + return g.history, nil + } + return &pb.GetDraftHistoryResponse{Entries: []*pb.DraftHistoryEntry{{TimestampMs: 1700000000000, EventType: "analysis_cycle", ChunkId: "chunk-1"}}}, nil +} +func (g *reconciliationGateway) GetPolicyStatus(context.Context, string) (*pb.GetSandboxPolicyStatusResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.status != nil { + return g.status, nil + } + return &pb.GetSandboxPolicyStatusResponse{ActiveVersion: 3, Revision: &pb.SandboxPolicyRevision{Version: 3, PolicyHash: "sha256:active"}}, nil +} +func (g *reconciliationGateway) ListPolicyRevisions(context.Context, string) ([]*pb.SandboxPolicyRevision, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.revisions != nil { + return append([]*pb.SandboxPolicyRevision(nil), g.revisions...), g.revisionsErr + } + return []*pb.SandboxPolicyRevision{{Version: 3, PolicyHash: "sha256:active"}}, nil +} + +func (g *reconciliationGateway) setPolicyRevisionResult(revisions []*pb.SandboxPolicyRevision, err error) { + g.mu.Lock() + defer g.mu.Unlock() + g.revisions = append([]*pb.SandboxPolicyRevision(nil), revisions...) + g.revisionsErr = err +} +func (g *reconciliationGateway) Close() error { return nil } + +func (g *reconciliationGateway) setDraft(response *pb.GetDraftPolicyResponse, err error) { + g.mu.Lock() + defer g.mu.Unlock() + g.draft = response + g.draftErr = err +} + +func (g *reconciliationGateway) getDraftCalls() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.draftCalls +} + +func (g *reconciliationGateway) setPolicySnapshots(history []*pb.DraftHistoryEntry, revisions []*pb.SandboxPolicyRevision) { + g.mu.Lock() + defer g.mu.Unlock() + g.history = &pb.GetDraftHistoryResponse{Entries: append([]*pb.DraftHistoryEntry(nil), history...)} + g.revisions = append([]*pb.SandboxPolicyRevision(nil), revisions...) +} + +type memoryPolicyStorage struct { + mu sync.Mutex + values map[string][]byte + getErr error + setErr error + setCalls int +} + +func newMemoryPolicyStorage() *memoryPolicyStorage { + return &memoryPolicyStorage{values: map[string][]byte{}} +} +func (s *memoryPolicyStorage) Get(_ context.Context, key string) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + return append([]byte(nil), s.values[key]...), nil +} +func (s *memoryPolicyStorage) Set(_ context.Context, key string, value []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.setCalls++ + if s.setErr != nil { + return s.setErr + } + s.values[key] = append([]byte(nil), value...) + return nil +} +func (s *memoryPolicyStorage) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.values, key) + return nil +} +func (s *memoryPolicyStorage) Batch(context.Context, ...*storage.Operation) error { return nil } +func (s *memoryPolicyStorage) Close(context.Context) error { return nil } + +func (s *memoryPolicyStorage) setFailures(getErr, setErr error) { + s.mu.Lock() + defer s.mu.Unlock() + s.getErr = getErr + s.setErr = setErr +} + +func (s *memoryPolicyStorage) putRaw(key string, value []byte) { + s.mu.Lock() + defer s.mu.Unlock() + s.values[key] = append([]byte(nil), value...) +} + +func (s *memoryPolicyStorage) snapshot(key string) ([]byte, int) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]byte(nil), s.values[key]...), s.setCalls +} + +type reconciliationRecord struct { + kind string + body map[string]any +} + +type reconciliationRecorder struct { + mu sync.Mutex + records []reconciliationRecord + calls int + failAt int +} + +func (r *reconciliationRecorder) consume(_ context.Context, logs plog.Logs) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls++ + if r.failAt > 0 && r.calls == r.failAt { + return consumererror.NewPermanent(errors.New("injected downstream rejection")) + } + for i := 0; i < logs.ResourceLogs().Len(); i++ { + for j := 0; j < logs.ResourceLogs().At(i).ScopeLogs().Len(); j++ { + records := logs.ResourceLogs().At(i).ScopeLogs().At(j).LogRecords() + for k := 0; k < records.Len(); k++ { + record := records.At(k) + kind, _ := record.Attributes().Get("openshell.acquisition.kind") + body, _ := record.Body().AsRaw().(map[string]any) + r.records = append(r.records, reconciliationRecord{kind: kind.Str(), body: body}) + } + } + } + return nil +} + +func (r *reconciliationRecorder) failAfterAdditionalCalls(additional int) { + r.mu.Lock() + defer r.mu.Unlock() + r.failAt = r.calls + additional +} + +func (r *reconciliationRecorder) clearFailure() { + r.mu.Lock() + defer r.mu.Unlock() + r.failAt = 0 +} + +func (r *reconciliationRecorder) snapshot() []reconciliationRecord { + r.mu.Lock() + defer r.mu.Unlock() + return append([]reconciliationRecord(nil), r.records...) +} + +func (r *reconciliationRecorder) countKind(kind string) int { + count := 0 + for _, record := range r.snapshot() { + if record.kind == kind { + count++ + } + } + return count +} + +func testReconciliationGateway() *reconciliationGateway { + return &reconciliationGateway{draft: &pb.GetDraftPolicyResponse{ + DraftVersion: 7, + RollingSummary: "one proposed endpoint", + Chunks: []*pb.PolicyChunk{{ + Id: "chunk-1", Status: "pending", ReviewToken: "must-never-leak", + CurrentEffectivePolicyHash: "sha256:current", CandidateEffectivePolicyHash: "sha256:candidate", + }}, + }} +} + +func newReconciliationTestReceiver(t *testing.T, config *Config, gateway gatewayClient, store storage.Client, recorder *reconciliationRecorder) *watchReceiver { + t.Helper() + next, err := consumer.NewLogs(recorder.consume) + if err != nil { + t.Fatal(err) + } + r := newWatchReceiver(config, receiver.Settings{}, next) + r.client = gateway + r.gatewayVersion = "0.0.113" + r.policyStorage = store + return r +} + +func testReconciliationRequest() reconciliationRequest { + return reconciliationRequest{ + Sandbox: sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, + Trigger: "test", + NotificationDraftVersion: 7, + } +} + +func TestReconciliationPersistsDeduplicationAcrossReceiverRestart(t *testing.T) { + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + config.PolicyReconciliation.IncludeEffectivePolicies = false + gateway := &reconciliationGateway{draft: &pb.GetDraftPolicyResponse{ + DraftVersion: 7, + RollingSummary: "one proposed endpoint", + Chunks: []*pb.PolicyChunk{{Id: "chunk-1", Status: "pending", ReviewToken: "must-never-leak", CurrentEffectivePolicyHash: "sha256:current", CandidateEffectivePolicyHash: "sha256:candidate"}}, + }} + store := newMemoryPolicyStorage() + var mu sync.Mutex + bodies := make([]any, 0) + next, err := consumer.NewLogs(func(_ context.Context, logs plog.Logs) error { + mu.Lock() + defer mu.Unlock() + for i := 0; i < logs.ResourceLogs().Len(); i++ { + for j := 0; j < logs.ResourceLogs().At(i).ScopeLogs().Len(); j++ { + records := logs.ResourceLogs().At(i).ScopeLogs().At(j).LogRecords() + for k := 0; k < records.Len(); k++ { + bodies = append(bodies, records.At(k).Body().AsRaw()) + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + newReceiver := func() *watchReceiver { + r := newWatchReceiver(config, receiver.Settings{}, next) + r.client = gateway + r.gatewayVersion = "0.0.113" + r.policyStorage = store + return r + } + request := reconciliationRequest{Sandbox: sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, Trigger: "test", NotificationDraftVersion: 7} + newReceiver().reconcileSandbox(context.Background(), request) + if len(bodies) != 5 { + t.Fatalf("first reconciliation emitted %d records, want 5", len(bodies)) + } + for _, body := range bodies { + encoded, _ := json.Marshal(body) + if string(encoded) == "" || contains(string(encoded), "must-never-leak") { + t.Fatalf("review token leaked: %s", encoded) + } + } + newReceiver().reconcileSandbox(context.Background(), request) + if len(bodies) != 5 { + t.Fatalf("restart replay emitted unchanged records: total=%d want=5", len(bodies)) + } +} + +func TestPartialReconciliationDoesNotAdvanceSuccessfulState(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + store := newMemoryPolicyStorage() + gateway := &reconciliationGateway{draftErr: errors.New("draft read unavailable")} + next, err := consumer.NewLogs(func(context.Context, plog.Logs) error { return nil }) + if err != nil { + t.Fatal(err) + } + r := newWatchReceiver(config, receiver.Settings{}, next) + r.client = gateway + r.gatewayVersion = "0.0.113" + r.policyStorage = store + request := reconciliationRequest{ + Sandbox: sandboxRef{ID: "sandbox-id", Name: "sandbox-name"}, + Trigger: "test", + NotificationDraftVersion: 7, + } + + r.reconcileSandbox(context.Background(), request) + if !r.policyUnhealthy.Load() { + t.Fatal("partial reconciliation was incorrectly recorded as successful") + } + + gateway.setDraft(&pb.GetDraftPolicyResponse{ + DraftVersion: 7, + Chunks: []*pb.PolicyChunk{{Id: "chunk-1", Status: "pending"}}, + }, nil) + r.reconcileSandbox(context.Background(), request) + if r.policyUnhealthy.Load() { + t.Fatal("complete reconciliation did not record recovery") + } +} + +func TestPolicyStateWriteFailureReplaysEvidenceAndRecovers(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + store := newMemoryPolicyStorage() + store.setFailures(nil, errors.New("injected checkpoint write failure")) + gateway := testReconciliationGateway() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + request := testReconciliationRequest() + + r.reconcileSandbox(context.Background(), request) + if !r.policyUnhealthy.Load() { + t.Fatal("checkpoint write failure was incorrectly recorded as healthy") + } + if got := recorder.countKind("policy.reconciliation.warning"); got != 1 { + t.Fatalf("checkpoint write failure warnings=%d, want 1", got) + } + if encoded, _ := store.snapshot(policyStateKey(request.Sandbox.ID)); len(encoded) != 0 { + t.Fatalf("failed checkpoint write persisted %d bytes", len(encoded)) + } + + store.setFailures(nil, nil) + r.reconcileSandbox(context.Background(), request) + if r.policyUnhealthy.Load() { + t.Fatal("successful checkpoint retry did not record recovery") + } + encoded, setCalls := store.snapshot(policyStateKey(request.Sandbox.ID)) + if len(encoded) == 0 || setCalls != 2 { + t.Fatalf("checkpoint after recovery bytes=%d set calls=%d, want persisted state after one failed and one successful write", len(encoded), setCalls) + } + records := recorder.snapshot() + if got := len(records); got != 11 { + t.Fatalf("records after replay=%d, want five evidence records, one warning, and five identity-stable replays", got) + } + if records[5].kind != "policy.reconciliation.warning" || records[5].body["code"] != "state_write_failed" { + t.Fatalf("checkpoint failure diagnostic=%#v", records[5]) + } + if !reflect.DeepEqual(records[:5], records[6:11]) { + t.Fatalf("checkpoint retry changed replay evidence: first=%#v replay=%#v", records[:5], records[6:11]) + } + + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 11 { + t.Fatalf("successful checkpoint did not deduplicate subsequent reconciliation: records=%d", got) + } + for _, record := range recorder.snapshot() { + encoded, err := json.Marshal(record.body) + if err != nil { + t.Fatal(err) + } + if contains(string(encoded), "must-never-leak") { + t.Fatalf("review token leaked during failure or replay: %s", encoded) + } + } +} + +func TestIncompletePolicyRevisionPaginationExportsPartialEvidenceWithoutAdvancingCheckpoint(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + gateway := testReconciliationGateway() + partial := testPolicyRevisions(2) + gateway.setPolicyRevisionResult(partial, &policyRevisionPaginationError{ + Reason: policyRevisionLimitExceeded, + Retained: 2, + ObservedAtLeast: 3, + Limit: 2, + }) + store := newMemoryPolicyStorage() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + request := testReconciliationRequest() + + r.reconcileSandbox(context.Background(), request) + if !r.policyUnhealthy.Load() { + t.Fatal("incomplete revision pagination was incorrectly recorded as healthy") + } + if got := recorder.countKind("policy.revision"); got != 2 { + t.Fatalf("partial revision records=%d, want 2", got) + } + if !hasReconciliationWarningCode(recorder.snapshot(), policyRevisionLimitExceeded) { + t.Fatalf("missing %q warning in %#v", policyRevisionLimitExceeded, recorder.snapshot()) + } + encoded, _ := store.snapshot(policyStateKey(request.Sandbox.ID)) + state, err := decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.Revisions.Count != 0 || state.Revisions.Digest != "" { + t.Fatalf("incomplete pagination advanced checkpoint: %#v", state.Revisions) + } + first := recordsOfKind(recorder.snapshot(), "policy.revision") + + r.reconcileSandbox(context.Background(), request) + second := recordsOfKind(recorder.snapshot(), "policy.revision") + if len(second) != 4 || !reflect.DeepEqual(first, second[2:]) { + t.Fatalf("partial replay identity changed: first=%#v all=%#v", first, second) + } +} + +func recordsOfKind(records []reconciliationRecord, kind string) []reconciliationRecord { + result := make([]reconciliationRecord, 0) + for _, record := range records { + if record.kind == kind { + result = append(result, record) + } + } + return result +} + +func hasReconciliationWarningCode(records []reconciliationRecord, code string) bool { + for _, record := range records { + if record.kind == "policy.reconciliation.warning" && record.body["code"] == code { + return true + } + } + return false +} + +func TestPolicyStateReadFailureAndCorruptionRequireRepairThenRecover(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + store := newMemoryPolicyStorage() + store.setFailures(errors.New("injected checkpoint read failure"), nil) + gateway := testReconciliationGateway() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + request := testReconciliationRequest() + stateKey := policyStateKey(request.Sandbox.ID) + + r.reconcileSandbox(context.Background(), request) + if gateway.getDraftCalls() != 0 || !r.policyUnhealthy.Load() { + t.Fatalf("state read failure advanced source reads=%d unhealthy=%v", gateway.getDraftCalls(), r.policyUnhealthy.Load()) + } + store.setFailures(nil, nil) + store.putRaw(stateKey, []byte(`{"version":1,"chunk_hashes":`)) + r.reconcileSandbox(context.Background(), request) + if gateway.getDraftCalls() != 0 { + t.Fatalf("corrupt state advanced source reads=%d", gateway.getDraftCalls()) + } + if got := recorder.countKind("policy.reconciliation.warning"); got != 2 { + t.Fatalf("state failure warnings=%d, want one read-failure and one corruption warning", got) + } + + if err := store.Delete(context.Background(), stateKey); err != nil { + t.Fatal(err) + } + r.reconcileSandbox(context.Background(), request) + if gateway.getDraftCalls() != 1 || r.policyUnhealthy.Load() { + t.Fatalf("repaired state recovery calls=%d unhealthy=%v", gateway.getDraftCalls(), r.policyUnhealthy.Load()) + } + if encoded, _ := store.snapshot(stateKey); len(encoded) == 0 { + t.Fatal("recovered reconciliation did not persist a fresh checkpoint") + } + if got := len(recorder.snapshot()); got != 7 { + t.Fatalf("records after repair=%d, want two warnings and five evidence records", got) + } +} + +func TestPolicyReconciliationQueueFullEmitsGapAndRetriesAfterCapacityReturns(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + recorder := &reconciliationRecorder{} + next, err := consumer.NewLogs(recorder.consume) + if err != nil { + t.Fatal(err) + } + r := newWatchReceiver(config, receiver.Settings{}, next) + r.gatewayVersion = "0.0.113" + r.reconcileCh = make(chan reconciliationRequest, 1) + r.reconcilePending = make(map[string]struct{}) + queued := sandboxRef{ID: "queued-sandbox", Name: "queued"} + rejected := sandboxRef{ID: "rejected-sandbox", Name: "rejected"} + + r.enqueuePolicyReconciliation(context.Background(), queued, "periodic", 0) + r.enqueuePolicyReconciliation(context.Background(), rejected, "periodic", 0) + if len(r.reconcileCh) != 1 { + t.Fatalf("queue length=%d, want bounded capacity 1", len(r.reconcileCh)) + } + r.reconcileMu.Lock() + _, queuedPending := r.reconcilePending[queued.ID] + _, rejectedPending := r.reconcilePending[rejected.ID] + r.reconcileMu.Unlock() + if !queuedPending || rejectedPending { + t.Fatalf("pending state queued=%v rejected=%v, rejected work must be retryable", queuedPending, rejectedPending) + } + records := recorder.snapshot() + if len(records) != 1 || records[0].kind != "policy.reconciliation.warning" || records[0].body["code"] != "queue_full" { + t.Fatalf("queue saturation evidence=%#v", records) + } + + dequeued := <-r.reconcileCh + r.clearReconciliationPending(dequeued.Sandbox.ID) + r.enqueuePolicyReconciliation(context.Background(), rejected, "periodic", 0) + if len(r.reconcileCh) != 1 || recorder.countKind("policy.reconciliation.warning") != 1 { + t.Fatalf("retry after capacity returned queue=%d warnings=%d", len(r.reconcileCh), recorder.countKind("policy.reconciliation.warning")) + } + if retried := <-r.reconcileCh; retried.Sandbox.ID != rejected.ID { + t.Fatalf("retried sandbox=%q, want %q", retried.Sandbox.ID, rejected.ID) + } +} + +func TestCanceledPolicyReconciliationEnqueueClearsPendingWithoutGap(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + recorder := &reconciliationRecorder{} + next, err := consumer.NewLogs(recorder.consume) + if err != nil { + t.Fatal(err) + } + r := newWatchReceiver(config, receiver.Settings{}, next) + r.reconcileCh = make(chan reconciliationRequest) + r.reconcilePending = make(map[string]struct{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sandbox := sandboxRef{ID: "canceled-sandbox", Name: "canceled"} + + r.enqueuePolicyReconciliation(ctx, sandbox, "periodic", 0) + r.reconcileMu.Lock() + _, pending := r.reconcilePending[sandbox.ID] + r.reconcileMu.Unlock() + if pending { + t.Fatal("canceled enqueue left a permanently pending sandbox") + } + if len(recorder.snapshot()) != 0 { + t.Fatal("normal cancellation was misrepresented as a source gap") + } +} + +func contains(value, fragment string) bool { + for i := 0; i+len(fragment) <= len(value); i++ { + if value[i:i+len(fragment)] == fragment { + return true + } + } + return false +} + +var _ gatewayClient = (*reconciliationGateway)(nil) +var _ storage.Client = (*memoryPolicyStorage)(nil) diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state.go new file mode 100644 index 00000000..1f556478 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state.go @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + + "go.opentelemetry.io/collector/pdata/plog" +) + +const ( + legacyPolicyStateVersion = 1 + policyStateVersion = 2 + maxPolicyStateReadBytes = 1 << 20 + maxPolicyStateEncodedBytes = 4 << 10 +) + +type policySequenceCheckpoint struct { + Count uint64 `json:"count,omitempty"` + Digest string `json:"digest,omitempty"` +} + +type policyReconciliationState struct { + Version int `json:"version"` + DraftHash string `json:"draft_hash,omitempty"` + StatusHash string `json:"status_hash,omitempty"` + Chunks policySequenceCheckpoint `json:"chunks,omitempty"` + History policySequenceCheckpoint `json:"history,omitempty"` + Revisions policySequenceCheckpoint `json:"revisions,omitempty"` + + migrated bool +} + +type legacyPolicyReconciliationState struct { + Version int `json:"version"` + DraftHash string `json:"draft_hash,omitempty"` + StatusHash string `json:"status_hash,omitempty"` + ChunkHashes map[string]string `json:"chunk_hashes,omitempty"` + HistoryHashes map[string]string `json:"history_hashes,omitempty"` + RevisionHashes map[string]string `json:"revision_hashes,omitempty"` +} + +type policySequenceEvent struct { + logs plog.Logs + hash string + order int64 + tie string +} + +func newPolicySequenceEvent(logs plog.Logs, order int64, tie string) (policySequenceEvent, error) { + hash := policyEventHash(logs) + if hash == "" { + return policySequenceEvent{}, errors.New("policy conversion produced no event") + } + return policySequenceEvent{logs: logs, hash: hash, order: order, tie: tie}, nil +} + +func canonicalizePolicySequence(events []policySequenceEvent) { + sort.SliceStable(events, func(i, j int) bool { + if events[i].order != events[j].order { + return events[i].order < events[j].order + } + if events[i].tie != events[j].tie { + return events[i].tie < events[j].tie + } + return events[i].hash < events[j].hash + }) +} + +func policySequenceDigest(events []policySequenceEvent) string { + hasher := sha256.New() + var length [8]byte + for _, event := range events { + binary.BigEndian.PutUint64(length[:], uint64(len(event.hash))) + _, _ = hasher.Write(length[:]) + _, _ = hasher.Write([]byte(event.hash)) + } + return hex.EncodeToString(hasher.Sum(nil)) +} + +func policySequenceState(events []policySequenceEvent) policySequenceCheckpoint { + return policySequenceCheckpoint{ + Count: uint64(len(events)), + Digest: policySequenceDigest(events), + } +} + +// policySequencePlan returns the first event that must be delivered. When a +// previously observed canonical prefix still matches, only its suffix is new. +// A mismatch is never guessed around: the complete current snapshot is replayed. +func policySequencePlan(previous policySequenceCheckpoint, events []policySequenceEvent) (int, policySequenceCheckpoint, bool) { + next := policySequenceState(events) + if previous.Count == 0 && previous.Digest == "" { + return 0, next, false + } + if previous.Count > uint64(len(events)) { + return 0, next, true + } + prefixLength := int(previous.Count) + if policySequenceDigest(events[:prefixLength]) != previous.Digest { + return 0, next, true + } + return prefixLength, next, false +} + +// policySnapshotPlan deduplicates a complete mutable snapshot. A changed +// snapshot is replayed in full; unlike append-only history, it has no suffix +// continuity claim. +func policySnapshotPlan(previous policySequenceCheckpoint, events []policySequenceEvent) (int, policySequenceCheckpoint) { + next := policySequenceState(events) + if previous == next { + return len(events), next + } + return 0, next +} + +func decodePolicyState(encoded []byte) (*policyReconciliationState, error) { + if len(encoded) == 0 { + return newPolicyState(), nil + } + if len(encoded) > maxPolicyStateReadBytes { + return nil, fmt.Errorf("policy reconciliation state is %d bytes; maximum accepted size is %d", len(encoded), maxPolicyStateReadBytes) + } + var header struct { + Version int `json:"version"` + } + if err := json.Unmarshal(encoded, &header); err != nil { + return nil, fmt.Errorf("decode policy reconciliation state: %w", err) + } + switch header.Version { + case policyStateVersion: + var state policyReconciliationState + if err := json.Unmarshal(encoded, &state); err != nil { + return nil, fmt.Errorf("decode policy reconciliation state: %w", err) + } + if err := state.validate(); err != nil { + return nil, err + } + return &state, nil + case legacyPolicyStateVersion: + var legacy legacyPolicyReconciliationState + if err := json.Unmarshal(encoded, &legacy); err != nil { + return nil, fmt.Errorf("decode legacy policy reconciliation state: %w", err) + } + if err := legacy.validate(); err != nil { + return nil, err + } + return &policyReconciliationState{ + Version: policyStateVersion, + DraftHash: legacy.DraftHash, + StatusHash: legacy.StatusHash, + migrated: true, + }, nil + default: + return nil, fmt.Errorf("unsupported policy reconciliation state version %d", header.Version) + } +} + +func encodePolicyState(state *policyReconciliationState) ([]byte, error) { + if state == nil { + return nil, errors.New("policy reconciliation state is nil") + } + state.Version = policyStateVersion + if err := state.validate(); err != nil { + return nil, err + } + encoded, err := json.Marshal(state) + if err != nil { + return nil, err + } + if len(encoded) > maxPolicyStateEncodedBytes { + return nil, fmt.Errorf("encoded policy reconciliation state is %d bytes; maximum is %d", len(encoded), maxPolicyStateEncodedBytes) + } + return encoded, nil +} + +func newPolicyState() *policyReconciliationState { + return &policyReconciliationState{Version: policyStateVersion} +} + +func (s *policyReconciliationState) validate() error { + if s.Version != policyStateVersion { + return fmt.Errorf("unsupported policy reconciliation state version %d", s.Version) + } + for name, value := range map[string]string{ + "draft_hash": s.DraftHash, + "status_hash": s.StatusHash, + "chunks.digest": s.Chunks.Digest, + "history.digest": s.History.Digest, + "revisions.digest": s.Revisions.Digest, + } { + if value != "" && !isPolicyHash(value) { + return fmt.Errorf("invalid policy reconciliation state %s", name) + } + } + for name, sequence := range map[string]policySequenceCheckpoint{ + "chunks": s.Chunks, + "history": s.History, + "revisions": s.Revisions, + } { + if sequence.Count > 0 && sequence.Digest == "" { + return fmt.Errorf("invalid policy reconciliation state %s: count requires digest", name) + } + } + return nil +} + +func (s *legacyPolicyReconciliationState) validate() error { + for name, value := range map[string]string{ + "draft_hash": s.DraftHash, + "status_hash": s.StatusHash, + } { + if value != "" && !isPolicyHash(value) { + return fmt.Errorf("invalid legacy policy reconciliation state %s", name) + } + } + for name, hashes := range map[string]map[string]string{ + "chunk_hashes": s.ChunkHashes, + "history_hashes": s.HistoryHashes, + "revision_hashes": s.RevisionHashes, + } { + for _, value := range hashes { + if !isPolicyHash(value) { + return fmt.Errorf("invalid legacy policy reconciliation state %s", name) + } + } + } + return nil +} + +func isPolicyHash(value string) bool { + if len(value) != sha256.Size*2 { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == sha256.Size && value == hex.EncodeToString(decoded) +} diff --git a/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state_test.go b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state_test.go new file mode 100644 index 00000000..05935316 --- /dev/null +++ b/projects/openshell-exporter/receiver/watchsandboxreceiver/reconciliation_state_test.go @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. +// SPDX-License-Identifier: Apache-2.0 + +package watchsandboxreceiver + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "strings" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +func TestPolicySequenceAppendsOnlySuffixAndSurvivesRestart(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + gateway := testReconciliationGateway() + gateway.setPolicySnapshots(testPolicyHistory(2), testPolicyRevisions(2)) + store := newMemoryPolicyStorage() + recorder := &reconciliationRecorder{} + request := testReconciliationRequest() + + newReceiver := func() *watchReceiver { + return newReconciliationTestReceiver(t, config, gateway, store, recorder) + } + newReceiver().reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 7 { + t.Fatalf("initial records=%d, want draft, chunk, status, two revisions, and two history entries", got) + } + + gateway.setPolicySnapshots(testPolicyHistory(3), testPolicyRevisions(3)) + newReceiver().reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 9 { + t.Fatalf("append records=%d, want only one new revision and one new history entry", got) + } + if recorder.countKind("policy.reconciliation.warning") != 0 { + t.Fatal("append-only growth was incorrectly reported as a continuity reset") + } + + newReceiver().reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 9 { + t.Fatalf("restart replayed unchanged policy evidence: records=%d", got) + } + encoded, _ := store.snapshot(policyStateKey(request.Sandbox.ID)) + state, err := decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.History.Count != 3 || state.Revisions.Count != 3 || len(encoded) > 512 { + t.Fatalf("bounded checkpoint history=%d revisions=%d bytes=%d", state.History.Count, state.Revisions.Count, len(encoded)) + } +} + +func TestPolicySequenceMutationAndTruncationReplayWithDiagnostics(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + gateway := testReconciliationGateway() + history := testPolicyHistory(3) + revisions := testPolicyRevisions(3) + gateway.setPolicySnapshots(history, revisions) + store := newMemoryPolicyStorage() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + request := testReconciliationRequest() + + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 9 { + t.Fatalf("initial records=%d, want 9", got) + } + + history[1] = &pb.DraftHistoryEntry{ + TimestampMs: history[1].GetTimestampMs(), + EventType: history[1].GetEventType(), + Description: "corrected source description", + ChunkId: history[1].GetChunkId(), + } + revisions[1] = &pb.SandboxPolicyRevision{ + Version: revisions[1].GetVersion(), + PolicyHash: "sha256:corrected", + CreatedAtMs: revisions[1].GetCreatedAtMs(), + } + gateway.setPolicySnapshots(history, revisions) + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 17 { + t.Fatalf("mutation records=%d, want two diagnostics plus complete three-entry replays", got) + } + if got := recorder.countKind("policy.reconciliation.warning"); got != 2 { + t.Fatalf("mutation diagnostics=%d, want 2", got) + } + + gateway.setPolicySnapshots(history[1:], revisions) + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 20 { + t.Fatalf("truncation records=%d, want one diagnostic plus two current history entries", got) + } + if got := recorder.countKind("policy.reconciliation.warning"); got != 3 { + t.Fatalf("truncation diagnostics=%d, want 3 total", got) + } + encoded, _ := store.snapshot(policyStateKey(request.Sandbox.ID)) + state, err := decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.History.Count != 2 || state.Revisions.Count != 3 { + t.Fatalf("checkpoint after truncation history=%d revisions=%d", state.History.Count, state.Revisions.Count) + } + + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 20 { + t.Fatalf("unchanged rebased snapshot replayed records: %d", got) + } +} + +func TestPolicyStateV1MigrationReplaysOnceAndShrinksCheckpoint(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + store := newMemoryPolicyStorage() + legacyHash := strings.Repeat("a", 64) + legacy := legacyPolicyReconciliationState{ + Version: legacyPolicyStateVersion, + ChunkHashes: make(map[string]string, 1000), + HistoryHashes: make(map[string]string, 1000), + RevisionHashes: make(map[string]string, 1000), + } + for index := range 1000 { + key := fmt.Sprintf("legacy-%04d", index) + legacy.ChunkHashes[key] = legacyHash + legacy.HistoryHashes[key] = legacyHash + legacy.RevisionHashes[key] = legacyHash + } + legacyEncoded, err := json.Marshal(legacy) + if err != nil { + t.Fatal(err) + } + if len(legacyEncoded) < 100000 || len(legacyEncoded) > maxPolicyStateReadBytes { + t.Fatalf("legacy fixture bytes=%d, want a large accepted v1 checkpoint", len(legacyEncoded)) + } + request := testReconciliationRequest() + stateKey := policyStateKey(request.Sandbox.ID) + store.putRaw(stateKey, legacyEncoded) + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, testReconciliationGateway(), store, recorder) + + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 6 { + t.Fatalf("migration records=%d, want one diagnostic and five replayed records", got) + } + if records := recorder.snapshot(); records[0].kind != "policy.reconciliation.warning" || records[0].body["code"] != "state_migrated" { + t.Fatalf("migration diagnostic=%#v", records[0]) + } + encoded, _ := store.snapshot(stateKey) + if len(encoded) > 512 || bytes.Contains(encoded, []byte("_hashes")) { + t.Fatalf("v2 checkpoint remained unbounded: bytes=%d state=%s", len(encoded), encoded) + } + state, err := decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.Version != policyStateVersion || state.migrated { + t.Fatalf("persisted migration state=%#v", state) + } + + r.reconcileSandbox(context.Background(), request) + if got := len(recorder.snapshot()); got != 6 { + t.Fatalf("completed migration replayed again: records=%d", got) + } + for _, record := range recorder.snapshot() { + encoded, err := json.Marshal(record.body) + if err != nil { + t.Fatal(err) + } + if contains(string(encoded), "must-never-leak") { + t.Fatalf("review token leaked during migration: %s", encoded) + } + } +} + +func TestOversizedPolicyStateFailsClosedBeforeGatewayRead(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + store := newMemoryPolicyStorage() + request := testReconciliationRequest() + oversized := bytes.Repeat([]byte("x"), maxPolicyStateReadBytes+1) + store.putRaw(policyStateKey(request.Sandbox.ID), oversized) + gateway := testReconciliationGateway() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + + r.reconcileSandbox(context.Background(), request) + if gateway.getDraftCalls() != 0 || !r.policyUnhealthy.Load() { + t.Fatalf("oversized checkpoint advanced gateway reads=%d unhealthy=%v", gateway.getDraftCalls(), r.policyUnhealthy.Load()) + } + records := recorder.snapshot() + if len(records) != 1 || records[0].kind != "policy.reconciliation.warning" || records[0].body["code"] != "state_read_failed" { + t.Fatalf("oversized checkpoint diagnostic=%#v", records) + } + stored, _ := store.snapshot(policyStateKey(request.Sandbox.ID)) + if !bytes.Equal(stored, oversized) { + t.Fatal("failed-closed read changed the operator-owned checkpoint") + } +} + +func TestPartialSequenceDeliveryDoesNotAdvancePastRejectedEvidence(t *testing.T) { + t.Parallel() + config := createDefaultConfig().(*Config) + config.PolicyReconciliation.Enabled = true + config.PolicyReconciliation.IncludeHistory = true + gateway := testReconciliationGateway() + gateway.setPolicySnapshots(testPolicyHistory(2), testPolicyRevisions(2)) + store := newMemoryPolicyStorage() + recorder := &reconciliationRecorder{} + r := newReconciliationTestReceiver(t, config, gateway, store, recorder) + request := testReconciliationRequest() + + r.reconcileSandbox(context.Background(), request) + gateway.setPolicySnapshots(testPolicyHistory(2), testPolicyRevisions(4)) + recorder.failAfterAdditionalCalls(2) + r.reconcileSandbox(context.Background(), request) + if !r.policyUnhealthy.Load() { + t.Fatal("rejected policy evidence was incorrectly recorded as healthy") + } + encoded, _ := store.snapshot(policyStateKey(request.Sandbox.ID)) + state, err := decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.Revisions.Count != 2 { + t.Fatalf("partial delivery advanced revision checkpoint to %d", state.Revisions.Count) + } + + recorder.clearFailure() + r.reconcileSandbox(context.Background(), request) + if r.policyUnhealthy.Load() { + t.Fatal("successful retry did not record recovery") + } + if got := recorder.countKind("policy.revision"); got != 5 { + t.Fatalf("revision records=%d, want two initial, one accepted partial, and two replayed suffix records", got) + } + encoded, _ = store.snapshot(policyStateKey(request.Sandbox.ID)) + state, err = decodePolicyState(encoded) + if err != nil { + t.Fatal(err) + } + if state.Revisions.Count != 4 { + t.Fatalf("successful retry checkpoint count=%d, want 4", state.Revisions.Count) + } +} + +func TestPolicyCheckpointEncodingRemainsBoundedAcrossSourceCounts(t *testing.T) { + t.Parallel() + digest := strings.Repeat("b", 64) + for _, count := range []uint64{0, 1, 1000, 1000000, math.MaxUint64} { + state := &policyReconciliationState{ + Version: policyStateVersion, + DraftHash: digest, + StatusHash: digest, + Chunks: policySequenceCheckpoint{Count: count, Digest: digest}, + History: policySequenceCheckpoint{Count: count, Digest: digest}, + Revisions: policySequenceCheckpoint{Count: count, Digest: digest}, + } + encoded, err := encodePolicyState(state) + if err != nil { + t.Fatalf("count %d: %v", count, err) + } + if len(encoded) > 640 { + t.Fatalf("count %d encoded checkpoint grew to %d bytes", count, len(encoded)) + } + } +} + +func FuzzPolicyStateDecoder(f *testing.F) { + valid, err := encodePolicyState(&policyReconciliationState{Version: policyStateVersion}) + if err != nil { + f.Fatal(err) + } + f.Add([]byte{}) + f.Add(valid) + f.Add([]byte(`{"version":1,"chunk_hashes":{}}`)) + f.Add([]byte(`{"version":2,"history":{"count":1}}`)) + f.Add([]byte(`{"version":999}`)) + f.Add([]byte(`{"version":`)) + f.Fuzz(func(t *testing.T, encoded []byte) { + state, decodeErr := decodePolicyState(encoded) + if decodeErr != nil { + return + } + roundTrip, encodeErr := encodePolicyState(state) + if encodeErr != nil { + t.Fatalf("accepted state could not be encoded: %v", encodeErr) + } + if len(roundTrip) > maxPolicyStateEncodedBytes { + t.Fatalf("accepted state encoded to %d bytes", len(roundTrip)) + } + }) +} + +func BenchmarkPolicyCheckpointEncoding(b *testing.B) { + digest := strings.Repeat("c", 64) + state := &policyReconciliationState{ + Version: policyStateVersion, + DraftHash: digest, + StatusHash: digest, + Chunks: policySequenceCheckpoint{Count: 1000000, Digest: digest}, + History: policySequenceCheckpoint{Count: 1000000, Digest: digest}, + Revisions: policySequenceCheckpoint{Count: 1000000, Digest: digest}, + } + b.ReportAllocs() + for range b.N { + if _, err := encodePolicyState(state); err != nil { + b.Fatal(err) + } + } +} + +func testPolicyHistory(count int) []*pb.DraftHistoryEntry { + entries := make([]*pb.DraftHistoryEntry, 0, count) + for index := 1; index <= count; index++ { + entries = append(entries, &pb.DraftHistoryEntry{ + TimestampMs: int64(1700000000000 + index), + EventType: "analysis_cycle", + Description: fmt.Sprintf("history entry %d", index), + ChunkId: fmt.Sprintf("chunk-%d", index), + }) + } + return entries +} + +func testPolicyRevisions(count int) []*pb.SandboxPolicyRevision { + revisions := make([]*pb.SandboxPolicyRevision, 0, count) + for version := count; version >= 1; version-- { + revisions = append(revisions, &pb.SandboxPolicyRevision{ + Version: uint32(version), + PolicyHash: fmt.Sprintf("sha256:policy-%d", version), + CreatedAtMs: int64(1700000000000 + version), + }) + } + return revisions +} diff --git a/projects/openshell-exporter/template.config.yaml b/projects/openshell-exporter/template.config.yaml new file mode 100644 index 00000000..668872fd --- /dev/null +++ b/projects/openshell-exporter/template.config.yaml @@ -0,0 +1,654 @@ +# OpenShell Event Exporter v0.0.5-rc.1 / Collector v0.160.0 +# Configuration reference: every exporter-owned setting and examples for all +# component types included in the exporter image. Advanced upstream transport +# and parser settings are outside this reference. Values are examples, not a +# statement of defaults. Start with config.yaml for the short runnable example. +# +# Before use: select inputs/pipelines, replace identities, paths and endpoints, +# mount required token/CA/client-certificate files under /run/secrets, and mount +# /input read-only plus separate persistent /state and /output directories. +# Secrets are raw tokens without the Bearer prefix. Never commit real secrets. +# Only logs/files is enabled below. Remove unused component definitions before +# deploying; Collector validation can check unused definitions and secret files. +# Preserve stable source identity and one writer per checkpoint/queue directory. +# Keep health/metrics private. Do not publish OTLP ports without authorized TLS +# clients and the corresponding input credentials/network restrictions. + +extensions: + bearertokenauth/destination: + filename: /run/secrets/destination-token + bearertokenauth/nemo_relay: + filename: /run/secrets/relay-token + bearertokenauth/forwarded_ocsf: + filename: /run/secrets/forwarded-token + bearertokenauth/otlp_destination: + filename: /run/secrets/otlp-destination-token + file_storage/checkpoints: + directory: /state/checkpoints + create_directory: true + file_storage/cloudevents_queue: + directory: /state/cloudevents-queue + create_directory: true + file_storage/otlp_queue: + directory: /state/otlp-grpc-queue + create_directory: true + file_storage/otlphttp_queue: + directory: /state/otlp-http-queue + create_directory: true + # All storagehealth settings; only these five path labels are supported. + # interval >= 1s; max_entries: 1..10000. Enable in service.extensions only + # after provisioning all listed paths, or remove paths for unused exporters. + storagehealth: + interval: 30s + max_entries: 1024 + paths: + checkpoints: /state/checkpoints + cloudevents_queue: /state/cloudevents-queue + otlp_grpc_queue: /state/otlp-grpc-queue + otlp_http_queue: /state/otlp-http-queue + recovery: /output/events.json + health_check: + endpoint: 0.0.0.0:13133 + file_storage/internal_queue: + directory: /state/internal-queue + create_directory: true + bearertokenauth/internal: + filename: /run/secrets/internal-token +receivers: + otlp/nemo_relay: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + auth: + authenticator: bearertokenauth/nemo_relay + tls: + cert_file: /run/secrets/relay.crt + key_file: /run/secrets/relay.key + client_ca_file: /run/secrets/relay-ca.pem + http: + endpoint: 0.0.0.0:4318 + auth: + authenticator: bearertokenauth/nemo_relay + tls: + cert_file: /run/secrets/relay.crt + key_file: /run/secrets/relay.key + client_ca_file: /run/secrets/relay-ca.pem + otlp/openshell_native: + protocols: + grpc: + endpoint: 0.0.0.0:4319 + tls: + cert_file: /run/secrets/native.crt + key_file: /run/secrets/native.key + reload_interval: 1m + otlp/forwarded_ocsf: + protocols: + http: + endpoint: 0.0.0.0:4320 + auth: + authenticator: bearertokenauth/forwarded_ocsf + tls: + cert_file: /run/secrets/forwarded.crt + key_file: /run/secrets/forwarded.key + reload_interval: 1m + client_ca_file_reload: true + client_ca_file: /run/secrets/forwarded-ca.pem + file_log/openshell_ocsf: + include: + - /input/sandboxes/*/openshell-ocsf.*.log + start_at: beginning + storage: file_storage/checkpoints + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 0s + include_file_path: true + include_file_path_resolved: true + include_file_record_offset: true + include_file_record_number: true + on_truncate: read_whole_file + max_log_size: 8MiB + max_log_size_behavior: split + attributes: + openshell.acquisition.kind: ocsf.file + openshell.acquisition.source_instance: sandbox-ocsf-jsonl + operators: + - type: json_parser + parse_from: body + parse_to: body + on_error: send + file_log/openshell_sandbox: + include: + - /input/sandboxes/*/openshell.*.log + start_at: beginning + storage: file_storage/checkpoints + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 0s + include_file_path: true + include_file_path_resolved: true + include_file_record_offset: true + include_file_record_number: true + on_truncate: read_whole_file + max_log_size: 8MiB + max_log_size_behavior: split + attributes: + openshell.acquisition.kind: sandbox.file_log + openshell.acquisition.source_instance: sandbox-operational-log + file_log/nemo_relay: + include: + - /input/relay/*.jsonl + start_at: beginning + storage: file_storage/checkpoints + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 0s + include_file_path: true + include_file_path_resolved: true + include_file_record_offset: true + include_file_record_number: true + on_truncate: read_whole_file + max_log_size: 8MiB + max_log_size_behavior: split + attributes: + openshell.acquisition.kind: nemo_relay.log + openshell.acquisition.source_instance: nemo-relay-file + operators: + - type: json_parser + parse_from: body + parse_to: body + on_error: send + # All WatchSandbox settings. sandbox_names selects explicit names; an empty + # list uses discovery with label_selector. Use read-only source credentials. + # token_env is an alternative to token_file; never set both. For mTLS set + # both cert_file/key_file. insecure_skip_verify must stay false (true rejected). + # Reconciliation workers: 1..16, queue_size: 1..10000, max_revisions: 1..100000. + # Effective policy bodies are opt-in; snapshots cannot reconstruct stream gaps. + watchsandbox: + endpoint: https://gateway.example.com:8080 + allow_insecure_http: false + workspace: default + gateway_id: gateway-1 + label_selector: observability=enabled + discovery_interval: 30s + token_env: '' + token_file: /run/secrets/gateway-token + log_tail_lines: 200 + event_tail: 200 + reconnect_initial: 1s + reconnect_max: 30s + policy_reconciliation: + enabled: true + interval: 5m + timeout: 15s + workers: 2 + queue_size: 128 + max_revisions: 10000 + include_history: true + include_effective_policies: false + storage: file_storage/checkpoints + tls: + ca_file: /run/secrets/gateway-ca.pem + cert_file: '' + key_file: '' + insecure_skip_verify: false + sandbox_names: [] + # Optional Kubernetes context: provision namespace-scoped list/watch RBAC + # and a service account token. The Docker/Podman starter does not provide it. + k8s_objects: + auth_type: serviceAccount + storage: file_storage/checkpoints + error_mode: propagate + include_initial_state: true + objects: + - name: pods + mode: watch + namespaces: + - sandbox-namespace + - name: events + group: events.k8s.io + mode: watch + namespaces: + - sandbox-namespace + otlp/internal: + protocols: + grpc: + endpoint: 0.0.0.0:4330 + auth: + authenticator: bearertokenauth/internal + tls: + cert_file: /run/secrets/central.crt + key_file: /run/secrets/central.key + client_ca_file: /run/secrets/internal-ca.pem +processors: + memory_limiter: + check_interval: 1s + limit_mib: 512 + spike_limit_mib: 128 + # All normalizer settings. source_profiles declares expected evidence types; + # it does not enable receivers. validation.mode only supports mark. + # Redaction arrays replace defaults: retain the built-in protections when + # adding patterns. Identity is calculated before redaction. + openshell: + source_profiles: + - ocsf.file + - ocsf.forwarded + - openshell.log + - openshell.log.forwarded + - watchsandbox + - policy.reconciliation + - nemo_relay.log + - nemo_relay.trace + - openshell.trace + - kubernetes.context + gateway_id: gateway-1 + workspace: default + source_instance: gateway-evidence + default_sandbox_id: unknown + validation: + mode: mark + redaction: + profile_id: openshell-default + version: '1' + keys: + - access_token + - api_key + - authorization + - client_secret + - password + - refresh_token + - secret + patterns: + - (?i)\bBearer\s+[A-Za-z0-9._~+/-]+=* + - (?i)\b(api[_-]?key|password|secret)\s*[:=]\s*[^\s,;]+ + correlation_fields: + - metadata.original_event_uid + - unmapped.correlation_id + - unmapped.policy_chunk_id + resource/nemo_relay: + attributes: + - key: telemetry.source + value: nemo_relay + action: upsert + - key: openshell.gateway.id + value: gateway-1 + action: upsert + - key: openshell.workspace + value: default + action: upsert + # All Relay settings. allow mode keeps approved metadata and removes unknown + # attributes and content. Patterns use glob syntax. Required correlation keys + # must be protected attributes. aliases maps canonical key -> source alias. + relay/nemo_relay: + gateway_id: gateway-1 + workspace: default + telemetry_source: nemo_relay + canonicalize_span_names: true + required_correlation_attributes: + - openshell.sandbox.id + - agent.session.id + privacy: + mode: allow + allowed_attributes: + - deployment.environment + - error.category + - error.code + - error.type + - exception.type + - gen_ai.conversation.id + - gen_ai.operation.name + - gen_ai.request.model + - gen_ai.response.finish_reasons + - gen_ai.response.id + - gen_ai.response.model + - gen_ai.tool.call.id + - gen_ai.usage.* + - http.response.status_code + - llm.cost.* + - llm.model_name + - llm.provider + - llm.token_count.* + - nemo_relay.agent.kind + - nemo_relay.llm.cost.* + - nemo_relay.mark.parent_uuid + - nemo_relay.mark.uuid + - nemo_relay.model_name + - nemo_relay.scope_type + - nemo_relay.session.instance_id + - nemo_relay.tool_call_id + - nemo_relay.uuid + - openinference.span.kind + - openshell.sandbox.name + - request.id + - service.instance.id + - service.name + - service.namespace + - service.version + - session.id + - telemetry.source + - token_type + - tokens_in + - tokens_out + - tool.name + - tool_call.function.name + - tool_call.id + denied_attributes: + - '*authorization*' + - '*credential*' + - '*input*' + - '*output*' + - '*password*' + - '*prompt*' + - '*response*content*' + - '*secret*' + - '*token*' + - '*tool*argument*' + - '*tool*result*' + aliases: + agent.session.id: session.id + openshell.policy.version: policy.version + request_id: gen_ai.request.id + tool_call_id: nemo_relay.tool_call_id + resource/openshell_native: + attributes: + - key: telemetry.source + value: openshell_native + action: upsert + - key: openshell.gateway.id + value: gateway-1 + action: upsert + - key: openshell.workspace + value: default + action: upsert + # Native OpenShell traces use a distinct profile. deny mode preserves unknown + # attributes, so use only for authorized native telemetry, never Relay content. + relay/openshell_native: + gateway_id: gateway-1 + workspace: default + telemetry_source: openshell_native + canonicalize_span_names: false + required_correlation_attributes: [] + privacy: + mode: deny + allowed_attributes: + - deployment.environment + - error.category + - error.code + - error.type + - exception.type + - gen_ai.conversation.id + - gen_ai.operation.name + - gen_ai.request.model + - gen_ai.response.finish_reasons + - gen_ai.response.id + - gen_ai.response.model + - gen_ai.tool.call.id + - gen_ai.usage.* + - http.response.status_code + - llm.cost.* + - llm.model_name + - llm.provider + - llm.token_count.* + - nemo_relay.agent.kind + - nemo_relay.llm.cost.* + - nemo_relay.mark.parent_uuid + - nemo_relay.mark.uuid + - nemo_relay.model_name + - nemo_relay.scope_type + - nemo_relay.session.instance_id + - nemo_relay.tool_call_id + - nemo_relay.uuid + - openinference.span.kind + - openshell.sandbox.name + - request.id + - service.instance.id + - service.name + - service.namespace + - service.version + - session.id + - telemetry.source + - token_type + - tokens_in + - tokens_out + - tool.name + - tool_call.function.name + - tool_call.id + denied_attributes: + - '*authorization*' + - '*credential*' + - '*input*' + - '*output*' + - '*password*' + - '*prompt*' + - '*response*content*' + - '*secret*' + - '*token*' + - '*tool*argument*' + - '*tool*result*' + aliases: + agent.session.id: session.id + openshell.policy.version: policy.version + request_id: gen_ai.request.id + tool_call_id: nemo_relay.tool_call_id + attributes/forwarded_files: + actions: + - key: openshell.acquisition.source_instance + value: sandbox-ocsf-forwarder + action: insert + - key: openshell.acquisition.transport + value: otlp_http + action: upsert + # Edge only: stamp AFTER openshell (logs) or relay (traces), then forward to + # central over authenticated mTLS. mode is stamp or verify; version is 1.0. + # tenant_id: [a-z0-9][a-z0-9._-]{0,62}. A contract is not authentication. + evidencecontract/stamp: + mode: stamp + contract_version: '1.0' + tenant_id: default + # Central only: verify the trusted internal stream before final delivery. + # Do not normalize again or stamp and verify in the same pipeline. + evidencecontract/verify: + mode: verify + contract_version: '1.0' + tenant_id: default + attributes/kubernetes_context: + actions: + - key: openshell.acquisition.kind + value: kubernetes.context + action: upsert +exporters: + otlp: + endpoint: otel.example.com:4317 + auth: + authenticator: bearertokenauth/otlp_destination + tls: + ca_file: /run/secrets/otlp-ca.pem + cert_file: '' + key_file: '' + retry_on_failure: + enabled: true + max_elapsed_time: 0s + initial_interval: 1s + max_interval: 30s + sending_queue: + enabled: true + storage: file_storage/otlp_queue + queue_size: 10000 + num_consumers: 4 + block_on_overflow: true + batch: + flush_timeout: 1s + min_size: 512 + max_size: 1024 + sizer: items + otlphttp: + endpoint: https://otel.example.com:4318 + auth: + authenticator: bearertokenauth/otlp_destination + tls: + ca_file: /run/secrets/otlp-ca.pem + cert_file: '' + key_file: '' + retry_on_failure: + enabled: true + max_elapsed_time: 0s + initial_interval: 1s + max_interval: 30s + sending_queue: + enabled: true + storage: file_storage/otlphttp_queue + queue_size: 10000 + num_consumers: 4 + block_on_overflow: true + batch: + flush_timeout: 1s + min_size: 512 + max_size: 1024 + sizer: items + # All CloudEvents-specific settings plus HTTP/auth/TLS and durable delivery. + # max_events: 1..500, max_event_bytes <= 1 MiB, max_request_bytes <= 4 MiB + # and >= max_event_bytes + 2. default_source is the fallback source URI. + # HTTP is for local fixtures only; credentials/headers require HTTPS. + # TLS insecure/insecure_skip_verify are rejected. Supply trusted CAs instead. + # Each destination needs its own queue. This queue counts requests, not minutes. + cloudevents: + endpoint: https://receiver.example.com/v1/events + timeout: 15s + max_events: 500 + max_event_bytes: 1048576 + max_request_bytes: 4194304 + auth: + authenticator: bearertokenauth/destination + tls: + ca_file: /run/secrets/destination-ca.pem + cert_file: '' + key_file: '' + retry_on_failure: + enabled: true + initial_interval: 1s + max_interval: 30s + max_elapsed_time: 0s + sending_queue: + enabled: true + storage: file_storage/cloudevents_queue + sizer: requests + queue_size: 10000 + num_consumers: 4 + block_on_overflow: true + batch: + flush_timeout: 1s + min_size: 512 + max_size: 1024 + sizer: items + allow_insecure_http: false + default_source: openshell://local + file/recovery: + path: /output/events.json + format: json + append: true + # Optional edge-to-central sharding. Use INSTEAD of final delivery on edge; + # each central replica must own private queues. Drain before changing shards. + load_balancing/internal: + routing_key: attributes + routing_attributes: + - openshell.exporter.tenant.id + - openshell.exporter.shard.key + timeout: 15s + retry_on_failure: + enabled: true + max_elapsed_time: 0s + sending_queue: + enabled: true + storage: file_storage/internal_queue + queue_size: 10000 + num_consumers: 4 + block_on_overflow: true + protocol: + otlp: + auth: + authenticator: bearertokenauth/internal + tls: + ca_file: /run/secrets/internal-ca.pem + cert_file: /run/secrets/edge.crt + key_file: /run/secrets/edge.key + server_name_override: central.example.internal + retry_on_failure: + enabled: true + max_elapsed_time: 0s + resolver: + dns: + hostname: central-headless.observability.svc.cluster.local + port: '4330' + interval: 30s + timeout: 5s +# Declaring a component above does not add it to a pipeline. Add its required +# auth/storage extensions here when enabling it. Logs -> CloudEvents or OTLP; +# traces -> OTLP. Avoid double collection of direct and forwarded source files. +service: + telemetry: + logs: + encoding: json + metrics: + level: normal + readers: + - pull: + exporter: + prometheus: + host: 0.0.0.0 + port: 8888 + extensions: + - file_storage/checkpoints + - health_check + # Edge-to-central: uncomment BOTH entries when using load_balancing/internal. + # - bearertokenauth/internal + # - file_storage/internal_queue + pipelines: + logs/files: + receivers: + - file_log/openshell_ocsf + processors: + - memory_limiter + - openshell + exporters: + - file/recovery + + # Optional pipeline examples: enable only the sources you have configured. + # logs/gateway: + # receivers: [watchsandbox] + # processors: [memory_limiter, openshell] + # exporters: [cloudevents, file/recovery] + # logs/operational: + # receivers: [file_log/openshell_sandbox, file_log/nemo_relay] + # processors: [memory_limiter, openshell] + # exporters: [cloudevents, file/recovery] + # logs/forwarded: + # receivers: [otlp/forwarded_ocsf] + # processors: [memory_limiter, attributes/forwarded_files, openshell] + # exporters: [cloudevents, file/recovery] + # logs/kubernetes: + # receivers: [k8s_objects] + # processors: [memory_limiter, attributes/kubernetes_context, openshell] + # exporters: [cloudevents, file/recovery] + # traces/relay: + # receivers: [otlp/nemo_relay] + # processors: [memory_limiter, resource/nemo_relay, relay/nemo_relay] + # exporters: [otlp] + # traces/native: + # receivers: [otlp/openshell_native] + # processors: [memory_limiter, resource/openshell_native, relay/openshell_native] + # exporters: [otlphttp] + # logs/central: + # receivers: [otlp/internal] + # processors: [memory_limiter, evidencecontract/verify] + # exporters: [cloudevents, file/recovery] + # traces/central: + # receivers: [otlp/internal] + # processors: [memory_limiter, evidencecontract/verify] + # exporters: [otlp] + # Edge alternative: append evidencecontract/stamp after the appropriate + # normalizer/privacy processor and replace exporters with [load_balancing/internal]. diff --git a/projects/openshell-exporter/testdata/ocsf/ai-inference.json b/projects/openshell-exporter/testdata/ocsf/ai-inference.json new file mode 100644 index 00000000..219e17b9 --- /dev/null +++ b/projects/openshell-exporter/testdata/ocsf/ai-inference.json @@ -0,0 +1,36 @@ +{ + "activity_id": 99, + "activity_name": "Other", + "ai_model": { + "name": "nvidia/nemotron-3-super", + "provider": "NVIDIA" + }, + "api": { + "operation": "chat.completions" + }, + "category_uid": 6, + "class_uid": 6003, + "class_name": "API Activity", + "metadata": { + "product": { + "name": "OpenShell" + }, + "profiles": [ + "container", + "host", + "ai_operation" + ], + "version": "1.8.0" + }, + "severity_id": 1, + "time": 1787073600000, + "type_uid": 600399, + "unmapped": { + "agent_session_id": "session-fixture-1", + "input_tokens": 128, + "latency_ms": 42, + "output_tokens": 64, + "sandbox_id": "sandbox-fixture-1", + "trace_id": "00112233445566778899aabbccddeeff" + } +} diff --git a/projects/openshell-exporter/testdata/ocsf/network-denied.json b/projects/openshell-exporter/testdata/ocsf/network-denied.json new file mode 100644 index 00000000..a9e27708 --- /dev/null +++ b/projects/openshell-exporter/testdata/ocsf/network-denied.json @@ -0,0 +1,19 @@ +{ + "activity_id": 1, + "category_uid": 4, + "class_uid": 4001, + "message": "CONNECT denied api.example.com:443", + "metadata": { + "product": { + "name": "OpenShell" + }, + "version": "1.8.0" + }, + "severity_id": 4, + "time": 1786377600000, + "unmapped": { + "request_id": "request-fixture-1", + "sandbox_id": "sandbox-fixture-1" + }, + "type_uid": 400101 +}