diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b5cba2d1df..ca5fe80683 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -100,6 +100,55 @@ jobs: path: /tmp/scan-results/odigos-scheduler.json retention-days: 30 + build-browser-proxy: + name: build-browser-proxy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Browser Proxy Image + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64 + push: false + tags: browser-proxy:pr-${{ github.event.number || github.run_number }} + provenance: false + file: browser-proxy/Dockerfile + outputs: type=docker,dest=/tmp/browser-proxy.tar + - name: Load Docker image for scanning + run: docker load -i /tmp/browser-proxy.tar + - name: Scan image for vulnerabilities + id: scan-browser-proxy + uses: odigos-io/ci-core/vulnerabilities-scanner@main + with: + image: browser-proxy:pr-${{ github.event.number || github.run_number }} + severity-cutoff: "CRITICAL" + continue-on-error: true + - name: Save browser-proxy scan JSON + if: always() + run: | + mkdir -p /tmp/scan-results + cat > /tmp/scan-results/odigos-browser-proxy.json << 'EOF' + ${{ steps.scan-browser-proxy.outputs.results-json }} + EOF + jq --arg img "odigos-browser-proxy" --arg tag "pr-${{ github.event.number || github.run_number }}" '. + {imageName: $img, imageTag: $tag}' /tmp/scan-results/odigos-browser-proxy.json > /tmp/scan-results/odigos-browser-proxy_enriched.json + mv /tmp/scan-results/odigos-browser-proxy_enriched.json /tmp/scan-results/odigos-browser-proxy.json + - name: Upload browser-proxy scan results + if: always() + uses: actions/upload-artifact@v4 + with: + name: vuln-scan-result-browser-proxy-${{ github.sha }}.json + path: /tmp/scan-results/odigos-browser-proxy.json + retention-days: 30 + - uses: actions/setup-go@v6 + with: + go-version: "1.26.2" + - name: run tests + working-directory: ./browser-proxy + run: | + make test + build-agents: name: build-agents runs-on: depot-ubuntu-latest @@ -438,6 +487,7 @@ jobs: needs: - build-and-test-autoscaler - build-scheduler + - build-browser-proxy - build-agents - build-and-test-instrumentor - build-and-test-odigos-collector diff --git a/.github/workflows/full-build.yaml b/.github/workflows/full-build.yaml index c0c4c4e661..9de0581d76 100644 --- a/.github/workflows/full-build.yaml +++ b/.github/workflows/full-build.yaml @@ -19,7 +19,7 @@ jobs: name: build-all-services strategy: matrix: - service: [instrumentor, odiglet, agents, frontend, operator] + service: [instrumentor, odiglet, agents, frontend, operator, browser-proxy] include: - service: instrumentor dockerfile_path: . @@ -31,6 +31,8 @@ jobs: dockerfile_path: frontend - service: operator dockerfile_path: operator + - service: browser-proxy + dockerfile_path: browser-proxy runs-on: depot-ubuntu-24.04-8 steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-modules.yml b/.github/workflows/publish-modules.yml index e878c45694..f1362ac259 100644 --- a/.github/workflows/publish-modules.yml +++ b/.github/workflows/publish-modules.yml @@ -215,7 +215,7 @@ jobs: strategy: matrix: - service: ['autoscaler', 'scheduler', 'instrumentor', 'collector', 'odiglet', 'ui', 'operator', 'agents'] + service: ['autoscaler', 'scheduler', 'instrumentor', 'collector', 'odiglet', 'ui', 'operator', 'agents', 'browser-proxy'] include: - service: autoscaler runner: depot-ubuntu-latest @@ -249,6 +249,10 @@ jobs: runner: depot-ubuntu-latest summary: 'Odigos Agents' description: 'The Odigos Agents used to copy Odigos agent relevant files into the user workloads.' + - service: browser-proxy + runner: depot-ubuntu-latest + summary: 'Browser proxy for Odigos' + description: 'Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector.' runs-on: ${{ matrix.runner }} steps: - name: Checkout repository @@ -327,6 +331,7 @@ jobs: matrix.service == 'ui' && 'frontend/Dockerfile' || matrix.service == 'operator' && 'operator/Dockerfile' || matrix.service == 'agents' && 'odiglet/Dockerfile' || + matrix.service == 'browser-proxy' && 'browser-proxy/Dockerfile' || 'Dockerfile' }} target: >- ${{ matrix.service == 'agents' && 'agents' || '' }} diff --git a/.github/workflows/update-instrumentation-agents-version.yml b/.github/workflows/update-instrumentation-agents-version.yml index f39dedd8f1..a8ec96c994 100644 --- a/.github/workflows/update-instrumentation-agents-version.yml +++ b/.github/workflows/update-instrumentation-agents-version.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: instrumentation_agent: - description: "Canonical agent category (e.g. python, php, ruby, nodejs)" + description: "Canonical agent category (e.g. python, php, ruby, nodejs, browser)" required: true type: string new_agent_version: diff --git a/.github/workflows/vulnerability-scan.yaml b/.github/workflows/vulnerability-scan.yaml index d4f160e3d0..43dc04edce 100644 --- a/.github/workflows/vulnerability-scan.yaml +++ b/.github/workflows/vulnerability-scan.yaml @@ -30,7 +30,8 @@ jobs: "odigos-collector", "odigos-ui", "odigos-operator", - "odigos-agents" + "odigos-agents", + "odigos-browser-proxy" ]' echo "images=$(echo "$IMAGES" | jq -c .)" >> "$GITHUB_OUTPUT" echo "count=$(echo "$IMAGES" | jq 'length')" >> "$GITHUB_OUTPUT" diff --git a/Makefile b/Makefile index aa7b224ee3..ce90b7eaa7 100644 --- a/Makefile +++ b/Makefile @@ -168,6 +168,10 @@ build-instrumentor: build-scheduler: $(MAKE) build-image/scheduler SUMMARY="Scheduler for Odigos" DESCRIPTION="Scheduler manages the installation of OpenTelemetry Collectors with Odigos." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) +.PHONY: build-browser-proxy +build-browser-proxy: + $(MAKE) build-image/browser-proxy DOCKERFILE=browser-proxy/$(DOCKERFILE) SUMMARY="Browser proxy for Odigos" DESCRIPTION="Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) + .PHONY: build-collector build-collector: $(MAKE) build-image/collector DOCKERFILE=collector/$(DOCKERFILE) SUMMARY="Odigos Collector" DESCRIPTION="The Odigos build of the OpenTelemetry Collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) @@ -186,7 +190,7 @@ verify-nodejs-agent: .PHONY: build-images build-images: # prefer to build timeconsuimg images first to make better use of parallelism - make -j $(nproc) build-ui build-collector build-odiglet build-autoscaler build-scheduler build-instrumentor build-agents TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make -j $(nproc) build-ui build-collector build-odiglet build-autoscaler build-scheduler build-instrumentor build-browser-proxy build-agents TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: build-images-rhel build-images-rhel: @@ -226,6 +230,10 @@ push-instrumentor: push-scheduler: $(MAKE) push-image/scheduler SUMMARY="Scheduler for Odigos" DESCRIPTION="Scheduler manages the installation of OpenTelemetry Collectors with Odigos." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) +.PHONY: push-browser-proxy +push-browser-proxy: + $(MAKE) push-image/browser-proxy DOCKERFILE=browser-proxy/$(DOCKERFILE) SUMMARY="Browser proxy for Odigos" DESCRIPTION="Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) + .PHONY: push-collector push-collector: $(MAKE) push-image/collector DOCKERFILE=collector/$(DOCKERFILE) BUILD_DIR=. SUMMARY="Odigos Collector" DESCRIPTION="The Odigos build of the OpenTelemetry Collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) @@ -240,7 +248,7 @@ push-agents: .PHONY: push-images push-images: - make push-autoscaler push-scheduler push-odiglet push-instrumentor push-collector push-ui TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make push-autoscaler push-scheduler push-odiglet push-instrumentor push-browser-proxy push-collector push-ui TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: push-images-rhel push-images-rhel: @@ -264,24 +272,9 @@ load-to-kind-victoria-metrics: - kind load docker-image $(ORG)/odigos-victoria-metrics$(IMG_SUFFIX):$(TAG) -# Victoria Metrics is not built from this repo — pull the published image and retag for e2e. -# Materialize a single-platform image via buildx --load so kind load does not fail on -# multi-arch manifests (ctr: content digest ... not found). -# kind's LoadImageArchive always runs: ctr images import --all-platforms -# See https://github.com/kubernetes-sigs/kind/issues/3795 -.PHONY: load-to-kind-victoria-metrics -load-to-kind-victoria-metrics: - printf 'FROM $(ORG)/odigos-victoria-metrics:latest\n' | docker buildx build \ - --platform=linux/$$(docker version -f '{{.Server.Arch}}') \ - --pull \ - -t $(ORG)/odigos-victoria-metrics$(IMG_SUFFIX):$(TAG) \ - --load \ - - - kind load docker-image $(ORG)/odigos-victoria-metrics$(IMG_SUFFIX):$(TAG) - .PHONY: load-to-kind load-to-kind: - make -j 6 load-to-kind-instrumentor load-to-kind-autoscaler load-to-kind-scheduler load-to-kind-odiglet load-to-kind-collector load-to-kind-ui load-to-kind-cli load-to-kind-agents load-to-kind-victoria-metrics ORG=$(ORG) TAG=$(TAG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) + make -j 6 load-to-kind-instrumentor load-to-kind-autoscaler load-to-kind-scheduler load-to-kind-odiglet load-to-kind-browser-proxy load-to-kind-collector load-to-kind-ui load-to-kind-cli load-to-kind-agents load-to-kind-victoria-metrics ORG=$(ORG) TAG=$(TAG) IMG_SUFFIX=$(IMG_SUFFIX) DOCKERFILE=$(DOCKERFILE) .PHONY: restart-ui restart-ui: @@ -551,6 +544,7 @@ publish-to-ecr: make -j 3 build-tag-push-ecr-image/scheduler SUMMARY="Scheduler for Odigos" DESCRIPTION="Scheduler manages the installation of OpenTelemetry Collectors with Odigos." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) make -j 3 build-tag-push-ecr-image/collector DOCKERFILE=collector/$(DOCKERFILE) SUMMARY="Odigos Collector" DESCRIPTION="The Odigos build of the OpenTelemetry Collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) make -j 3 build-tag-push-ecr-image/ui DOCKERFILE=frontend/$(DOCKERFILE) SUMMARY="UI for Odigos" DESCRIPTION="UI provides the frontend webapp for managing an Odigos installation." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) + make -j 3 build-tag-push-ecr-image/browser-proxy DOCKERFILE=browser-proxy/$(DOCKERFILE) SUMMARY="Browser proxy for Odigos" DESCRIPTION="Sidecar that injects the OpenTelemetry browser SDK into served HTML and proxies browser telemetry to the Odigos collector." TAG=$(TAG) ORG=$(ORG) IMG_SUFFIX=$(IMG_SUFFIX) echo "✅ Deployed Odigos to EKS, now install the CLI" # install gatekeeper to prevent: diff --git a/api/config/crd/bases/odigos.io_actions.yaml b/api/config/crd/bases/odigos.io_actions.yaml index 4599fedb1a..ba6bcb0b18 100644 --- a/api/config/crd/bases/odigos.io_actions.yaml +++ b/api/config/crd/bases/odigos.io_actions.yaml @@ -93,6 +93,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -234,6 +235,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -471,6 +473,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -548,6 +551,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -620,6 +624,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -712,6 +717,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml b/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml index 80e66beb5a..0c1610c76e 100644 --- a/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml +++ b/api/config/crd/bases/odigos.io_instrumentationconfigs.yaml @@ -124,6 +124,7 @@ spec: - RuntimeDetailsUnavailable - CrashLoopBackOff - ImagePullBackOff + - BrowserPortMissing type: string containerName: description: The name of the container to which this configuration @@ -708,6 +709,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -746,6 +748,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -890,6 +893,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1507,6 +1511,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_instrumentationrules.yaml b/api/config/crd/bases/odigos.io_instrumentationrules.yaml index dbdbaf4077..cf4f4de0e3 100644 --- a/api/config/crd/bases/odigos.io_instrumentationrules.yaml +++ b/api/config/crd/bases/odigos.io_instrumentationrules.yaml @@ -237,6 +237,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -398,6 +399,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -465,6 +467,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -502,6 +505,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_samplings.yaml b/api/config/crd/bases/odigos.io_samplings.yaml index 69938a5e30..4b63118ce3 100644 --- a/api/config/crd/bases/odigos.io_samplings.yaml +++ b/api/config/crd/bases/odigos.io_samplings.yaml @@ -131,6 +131,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -279,6 +280,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -478,6 +480,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/config/crd/bases/odigos.io_sources.yaml b/api/config/crd/bases/odigos.io_sources.yaml index c46991d728..3dc626a011 100644 --- a/api/config/crd/bases/odigos.io_sources.yaml +++ b/api/config/crd/bases/odigos.io_sources.yaml @@ -129,6 +129,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/api/k8sconsts/browserproxy.go b/api/k8sconsts/browserproxy.go new file mode 100644 index 0000000000..215563c49f --- /dev/null +++ b/api/k8sconsts/browserproxy.go @@ -0,0 +1,76 @@ +package k8sconsts + +// Contract shared between the instrumentor pod webhook (which injects the sidecar) and the +// odigos-browser-proxy binary (which runs as the sidecar). These MUST stay in sync; the +// browser-proxy module imports these constants directly. + +const ( + // BrowserProxyContainerName is the name of the sidecar container injected in front of a + // browser-instrumented web server. + BrowserProxyContainerName = "odigos-browser-proxy" + + // BrowserProxyInitContainerName is the name of the init container that sets up the iptables + // rules redirecting the application's inbound traffic to the sidecar. + BrowserProxyInitContainerName = "odigos-browser-proxy-init" + + // BrowserProxyRunAsUser is the UID the sidecar runs as. The iptables redirect excludes traffic + // owned by this UID so the sidecar can reach the application on loopback without looping. + BrowserProxyRunAsUser int64 = 1337 + + // BrowserProxyListenPort is the port the sidecar listens on. Inbound traffic to the application + // port is redirected here by the init container. + BrowserProxyListenPort = 15001 + + // Default location (under the agents directory) of the browser SDK bundle the sidecar serves. + BrowserProxyDefaultAgentDir = OdigosAgentsDirectory + "/browser" + BrowserProxyDefaultAgentFile = "agent.js" + + // Same-origin URL path prefix the sidecar reserves for itself. The application is assumed not to + // serve anything under this prefix. + BrowserProxyPathPrefix = "/__odigos/" + // Path (under the prefix) where the sidecar serves the browser SDK bundle. + BrowserProxyAgentJsPath = "/__odigos/agent.js" + // Path (under the prefix) where the sidecar serves the dynamic window.__ODIGOS__ config. + BrowserProxyConfigJsPath = "/__odigos/config.js" + // Path used by Kubernetes liveness/readiness probes on the sidecar. + BrowserProxyHealthPath = "/__odigos/healthz" + // Path (under the prefix) where the sidecar receives OTLP/HTTP traces from the browser and + // forwards them to the node-local collector (requires Bearer export token). + BrowserProxyTracesPath = "/__odigos/v1/traces" + // Path for OTLP/HTTP logs/events from the browser agent. + BrowserProxyLogsPath = "/__odigos/v1/logs" + + // Image name (without prefix/tag) of the browser-proxy sidecar. + OdigosBrowserProxyImage = "odigos-browser-proxy" + // Environment variable the instrumentor reads to override the browser-proxy image (set at install/upgrade). + OdigosBrowserProxyEnvVarName = "ODIGOS_BROWSER_PROXY_IMAGE" +) + +// Environment variables passed by the webhook to the sidecar / init container. +const ( + // Full http(s) URL of the application the sidecar forwards browser requests to (e.g. http://127.0.0.1:8080). + BrowserProxyUpstreamEnvVar = "ODIGOS_BROWSER_PROXY_UPSTREAM" + // Address the sidecar listens on (e.g. ":15001"). + BrowserProxyListenAddrEnvVar = "ODIGOS_BROWSER_PROXY_LISTEN_ADDR" + // Directory the sidecar serves the browser SDK bundle from (e.g. /var/odigos/browser). + BrowserProxyAgentDirEnvVar = "ODIGOS_BROWSER_PROXY_AGENT_DIR" + // File name (within the agent dir) of the browser SDK bundle (e.g. agent.js). + BrowserProxyAgentFileEnvVar = "ODIGOS_BROWSER_PROXY_AGENT_FILE" + // Base OTLP/HTTP endpoint of the node-local collector the sidecar forwards browser telemetry to + // (e.g. http://10.0.0.1:4318). The sidecar appends the OTLP signal path (e.g. /v1/traces). + BrowserProxyOtlpHttpEndpointEnvVar = "ODIGOS_BROWSER_PROXY_OTLP_HTTP_ENDPOINT" + // service.name reported by the browser SDK. + BrowserProxyServiceNameEnvVar = "ODIGOS_BROWSER_PROXY_SERVICE_NAME" + // OTEL_RESOURCE_ATTRIBUTES-style (key1=val1,key2=val2) resource attributes forwarded to the browser config. + BrowserProxyResourceAttributesEnvVar = "ODIGOS_BROWSER_PROXY_RESOURCE_ATTRIBUTES" + // Comma-separated list of URLs/regexes the browser SDK may attach trace-context headers to. + BrowserProxyPropagateCorsUrlsEnvVar = "ODIGOS_BROWSER_PROXY_PROPAGATE_CORS_URLS" + // Optional fixed export token for OTLP POSTs. When unset the sidecar generates one at startup + // and embeds it in /__odigos/config.js. + BrowserProxyExportTokenEnvVar = "ODIGOS_BROWSER_PROXY_EXPORT_TOKEN" + + // Init-container-only: the application's inbound port that should be redirected to the sidecar. + BrowserProxyAppPortEnvVar = "ODIGOS_BROWSER_PROXY_APP_PORT" + // Init-container-only: the UID the sidecar runs as (excluded from the iptables redirect). + BrowserProxyUidEnvVar = "ODIGOS_BROWSER_PROXY_UID" +) diff --git a/api/odigos/v1alpha1/instrumentationconfig_types.go b/api/odigos/v1alpha1/instrumentationconfig_types.go index 4a7b56fd58..9e839ac537 100644 --- a/api/odigos/v1alpha1/instrumentationconfig_types.go +++ b/api/odigos/v1alpha1/instrumentationconfig_types.go @@ -77,7 +77,7 @@ const ( RuntimeDetectionReasonError RuntimeDetectionReason = "Error" ) -// +kubebuilder:validation:Enum=EnabledSuccessfully;EnabledWithOtherAgents;WaitingForRuntimeInspection;WaitingForNodeCollector;IgnoredContainer;NoCollectedSignals;InjectionConflict;UnsupportedProgrammingLanguage;NoAvailableAgent;UnsupportedRuntimeVersion;MissingDistroParameter;OtherAgentDetected;RuntimeDetailsUnavailable;CrashLoopBackOff;ImagePullBackOff +// +kubebuilder:validation:Enum=EnabledSuccessfully;EnabledWithOtherAgents;WaitingForRuntimeInspection;WaitingForNodeCollector;IgnoredContainer;NoCollectedSignals;InjectionConflict;UnsupportedProgrammingLanguage;NoAvailableAgent;UnsupportedRuntimeVersion;MissingDistroParameter;OtherAgentDetected;RuntimeDetailsUnavailable;CrashLoopBackOff;ImagePullBackOff;BrowserPortMissing type AgentEnabledReason string const ( @@ -102,6 +102,10 @@ const ( // used for the rollback feature, when an application was instrumented and it caused an ImagePullBackOff // We're marking it as that and rolling back the instrumentation AgentEnabledReasonImagePullBackOff AgentEnabledReason = "ImagePullBackOff" + // browser instrumentation is delivered by the odigos-browser-proxy sidecar, which transparently + // redirects inbound traffic to the app container's TCP port. If the app container declares no TCP + // containerPort, the sidecar cannot be wired and browser instrumentation cannot be applied. + AgentEnabledReasonBrowserPortMissing AgentEnabledReason = "BrowserPortMissing" ) // Used to return that an agent should be disabled for a container. @@ -151,6 +155,8 @@ func AgentInjectionReasonPriority(reason AgentEnabledReason) int { return 45 case AgentEnabledReasonInjectionConflict: return 48 + case AgentEnabledReasonBrowserPortMissing: + return 55 case AgentEnabledReasonUnsupportedProgrammingLanguage: return 50 case AgentEnabledReasonUnsupportedRuntimeVersion: @@ -173,7 +179,8 @@ func AgentInjectionReasonPriority(reason AgentEnabledReason) int { func IsReasonStatusDisabled(reason string) bool { switch reason { // Agent-related reasons - case string(RuntimeDetectionReasonNoRunningPods): + case string(RuntimeDetectionReasonNoRunningPods), + string(AgentEnabledReasonBrowserPortMissing): return true // rollout-related reasons diff --git a/autoscaler/controllers/nodecollector/collectorconfig/logs.go b/autoscaler/controllers/nodecollector/collectorconfig/logs.go index 41dd86e45a..dc99cf1fe0 100644 --- a/autoscaler/controllers/nodecollector/collectorconfig/logs.go +++ b/autoscaler/controllers/nodecollector/collectorconfig/logs.go @@ -113,6 +113,9 @@ func LogsConfig(nodeCG *odigosv1.CollectorsGroup, opts LogsConfigOptions) config pipelineProcessors = append(pipelineProcessors, odigosTrafficMetricsProcessorName) receivers, pipelineReceivers := getReceivers(opts.Logger, opts.Sources, opts.OdigosNamespace, opts.Tier) + // Always accept OTLP logs on the node collector (agents, browser-proxy clicks/events). + // otlp/in itself is defined in the common_application_telemetry domain. + pipelineReceivers = append(pipelineReceivers, OTLPInReceiverName) return config.Config{ Receivers: receivers, diff --git a/autoscaler/controllers/nodecollector/testdata/logs_included.yaml b/autoscaler/controllers/nodecollector/testdata/logs_included.yaml index cfa00f5b9e..0160ed7c84 100644 --- a/autoscaler/controllers/nodecollector/testdata/logs_included.yaml +++ b/autoscaler/controllers/nodecollector/testdata/logs_included.yaml @@ -170,6 +170,7 @@ service: - odigostrafficmetrics receivers: - filelog + - otlp/in metrics: exporters: - otlp_grpc/out-cluster-collector-metrics diff --git a/browser-proxy/Dockerfile b/browser-proxy/Dockerfile new file mode 100644 index 0000000000..09ef312ecd --- /dev/null +++ b/browser-proxy/Dockerfile @@ -0,0 +1,43 @@ +# Build context is the repository root (BUILD_DIR=.), matching the other Odigos components. +# Unlike the shared root Dockerfile, the browser-proxy image is alpine-based because the init +# mode needs the `iptables` binary to install the inbound traffic redirect. + +FROM --platform=$BUILDPLATFORM golang:1.26.2 AS builder +WORKDIR /workspace + +# browser-proxy is a self-contained, dependency-free module (stdlib only), so no sibling +# modules need to be copied. +COPY browser-proxy/ browser-proxy/ + +WORKDIR /workspace/browser-proxy +ARG TARGETARCH +ARG LD_FLAGS +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg \ + CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \ + go build -ldflags="${LD_FLAGS}" -o /workspace/build/odigos-browser-proxy ./cmd + +######### Final image ######### +FROM alpine:3.20 +ARG VERSION +ARG RELEASE +ARG SUMMARY +ARG DESCRIPTION +LABEL "name"="odigos-browser-proxy" +LABEL "vendor"="Odigos" +LABEL "maintainer"="Odigos" +LABEL "version"=$VERSION +LABEL "release"=$RELEASE +LABEL "summary"=$SUMMARY +LABEL "description"=$DESCRIPTION + +# iptables is required by the `init` mode to redirect inbound traffic to the sidecar. +RUN apk add --no-cache iptables + +COPY --from=builder /workspace/build/odigos-browser-proxy /usr/local/bin/odigos-browser-proxy + +# The serve (sidecar) mode runs as this fixed UID; the iptables redirect excludes traffic owned by +# it so the sidecar can reach the application on loopback. The init mode is run as root with +# CAP_NET_ADMIN via the pod securityContext set by the instrumentor webhook. +USER 1337:1337 +ENTRYPOINT ["/usr/local/bin/odigos-browser-proxy"] diff --git a/browser-proxy/LICENSE b/browser-proxy/LICENSE new file mode 100644 index 0000000000..d164b5f728 --- /dev/null +++ b/browser-proxy/LICENSE @@ -0,0 +1,201 @@ + 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 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 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 those 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Odigos + + 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/browser-proxy/Makefile b/browser-proxy/Makefile new file mode 100644 index 0000000000..195c44bafc --- /dev/null +++ b/browser-proxy/Makefile @@ -0,0 +1,32 @@ +.PHONY: all +all: build + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: fmt vet ## Run tests. + go test ./... -coverprofile cover.out + +.PHONY: build +build: fmt vet ## Build the browser-proxy binary. + go build -o bin/odigos-browser-proxy ./cmd + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +.PHONY: go-licenses +go-licenses: $(LOCALBIN) + GOBIN=$(LOCALBIN) go install github.com/google/go-licenses@latest + +.PHONY: licenses +licenses: go-licenses + rm -rf $(PWD)/licenses + $(LOCALBIN)/go-licenses save . --save_path=licenses diff --git a/browser-proxy/cmd/main.go b/browser-proxy/cmd/main.go new file mode 100644 index 0000000000..e0773fb907 --- /dev/null +++ b/browser-proxy/cmd/main.go @@ -0,0 +1,53 @@ +// Command odigos-browser-proxy runs as a sidecar in front of a browser-instrumented web server. +// +// It has two modes: +// +// odigos-browser-proxy - run the proxy server (default; the sidecar container) +// odigos-browser-proxy init - apply the iptables inbound redirect (the init container) +package main + +import ( + "log" + "os" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" + "github.com/odigos-io/odigos/browser-proxy/internal/iptables" + "github.com/odigos-io/odigos/browser-proxy/internal/server" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "init" { + runInit() + return + } + runServe() +} + +func runInit() { + cfg, err := config.LoadInit() + if err != nil { + log.Fatalf("browser-proxy init: invalid configuration: %v", err) + } + if err := iptables.Apply(iptables.Config{ + AppPort: cfg.AppPort, + ProxyPort: cfg.ProxyPort, + ProxyUID: cfg.ProxyUID, + }); err != nil { + log.Fatalf("browser-proxy init: failed to apply iptables redirect: %v", err) + } + log.Printf("browser-proxy init: redirected inbound tcp/%d -> sidecar tcp/%d", cfg.AppPort, cfg.ProxyPort) +} + +func runServe() { + cfg, err := config.LoadServe() + if err != nil { + log.Fatalf("browser-proxy: invalid configuration: %v", err) + } + srv, err := server.New(cfg) + if err != nil { + log.Fatalf("browser-proxy: failed to start: %v", err) + } + if err := srv.Run(); err != nil { + log.Fatalf("browser-proxy: server exited: %v", err) + } +} diff --git a/browser-proxy/go.mod b/browser-proxy/go.mod new file mode 100644 index 0000000000..31c3f57fdb --- /dev/null +++ b/browser-proxy/go.mod @@ -0,0 +1,3 @@ +module github.com/odigos-io/odigos/browser-proxy + +go 1.26.2 diff --git a/browser-proxy/internal/config/config.go b/browser-proxy/internal/config/config.go new file mode 100644 index 0000000000..66c1231562 --- /dev/null +++ b/browser-proxy/internal/config/config.go @@ -0,0 +1,146 @@ +// Package config loads the odigos-browser-proxy sidecar configuration from environment variables. +package config + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strconv" + "strings" +) + +// Environment variable names. These MUST match the constants in +// github.com/odigos-io/odigos/api/k8sconsts (browserproxy.go), which the instrumentor webhook +// uses when injecting the sidecar. They are duplicated here so the sidecar stays a tiny, +// dependency-free module rather than pulling in the api/k8s module graph. +const ( + envUpstream = "ODIGOS_BROWSER_PROXY_UPSTREAM" + envListenAddr = "ODIGOS_BROWSER_PROXY_LISTEN_ADDR" + envAgentDir = "ODIGOS_BROWSER_PROXY_AGENT_DIR" + envAgentFile = "ODIGOS_BROWSER_PROXY_AGENT_FILE" + envOtlpHTTPEndpoint = "ODIGOS_BROWSER_PROXY_OTLP_HTTP_ENDPOINT" + envServiceName = "ODIGOS_BROWSER_PROXY_SERVICE_NAME" + envResourceAttributes = "ODIGOS_BROWSER_PROXY_RESOURCE_ATTRIBUTES" + envPropagateCorsUrls = "ODIGOS_BROWSER_PROXY_PROPAGATE_CORS_URLS" + envExportToken = "ODIGOS_BROWSER_PROXY_EXPORT_TOKEN" + envAppPort = "ODIGOS_BROWSER_PROXY_APP_PORT" + envProxyUID = "ODIGOS_BROWSER_PROXY_UID" +) + +// Defaults, also mirrored from api/k8sconsts. +const ( + DefaultListenPort = 15001 + DefaultAgentDir = "/var/odigos/browser" + DefaultAgentFile = "agent.js" + + // Same-origin paths the sidecar reserves for itself. + PathPrefix = "/__odigos/" + AgentJsPath = "/__odigos/agent.js" + ConfigJsPath = "/__odigos/config.js" + HealthPath = "/__odigos/healthz" + TracesPath = "/__odigos/v1/traces" + LogsPath = "/__odigos/v1/logs" + OtlpPathPrefix = "/__odigos/v1/" +) + +// Config holds the resolved sidecar configuration. +type Config struct { + // ListenAddr is the address the sidecar's HTTP server binds to (e.g. ":15001"). + ListenAddr string + // Upstream is the application base URL the sidecar forwards browser requests to. + Upstream string + // AgentDir is the directory the browser SDK bundle is served from. + AgentDir string + // AgentFile is the file name of the browser SDK bundle within AgentDir. + AgentFile string + // OtlpHTTPEndpoint is the base OTLP/HTTP endpoint of the node-local collector. + OtlpHTTPEndpoint string + // ServiceName is reported as service.name by the browser SDK. + ServiceName string + // ResourceAttributes is an OTEL_RESOURCE_ATTRIBUTES-style string (k=v,k2=v2). + ResourceAttributes string + // PropagateCorsUrls is a comma-separated list of URLs/regexes for trace-context propagation. + PropagateCorsUrls string + // ExportToken is the bearer token browsers must present on OTLP POSTs. Generated at startup + // when unset so every sidecar instance has a unique credential. + ExportToken string +} + +// LoadServe loads and validates the configuration needed to run the proxy server. +func LoadServe() (*Config, error) { + cfg := &Config{ + ListenAddr: getenvDefault(envListenAddr, fmt.Sprintf(":%d", DefaultListenPort)), + Upstream: os.Getenv(envUpstream), + AgentDir: getenvDefault(envAgentDir, DefaultAgentDir), + AgentFile: getenvDefault(envAgentFile, DefaultAgentFile), + OtlpHTTPEndpoint: strings.TrimRight(os.Getenv(envOtlpHTTPEndpoint), "/"), + ServiceName: os.Getenv(envServiceName), + ResourceAttributes: os.Getenv(envResourceAttributes), + PropagateCorsUrls: os.Getenv(envPropagateCorsUrls), + ExportToken: strings.TrimSpace(os.Getenv(envExportToken)), + } + + if cfg.Upstream == "" { + return nil, fmt.Errorf("%s is required", envUpstream) + } + if cfg.OtlpHTTPEndpoint == "" { + return nil, fmt.Errorf("%s is required", envOtlpHTTPEndpoint) + } + if cfg.ExportToken == "" { + tok, err := generateExportToken() + if err != nil { + return nil, fmt.Errorf("generate export token: %w", err) + } + cfg.ExportToken = tok + } + + return cfg, nil +} + +func generateExportToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// InitConfig holds the configuration for the iptables init mode. +type InitConfig struct { + AppPort int + ProxyPort int + ProxyUID int +} + +// LoadInit loads and validates the configuration needed to apply the iptables redirect. +func LoadInit() (*InitConfig, error) { + appPort, err := strconv.Atoi(os.Getenv(envAppPort)) + if err != nil || appPort <= 0 || appPort > 65535 { + return nil, fmt.Errorf("%s must be a valid TCP port, got %q", envAppPort, os.Getenv(envAppPort)) + } + + proxyUID, err := strconv.Atoi(os.Getenv(envProxyUID)) + if err != nil || proxyUID < 0 { + return nil, fmt.Errorf("%s must be a valid UID, got %q", envProxyUID, os.Getenv(envProxyUID)) + } + + proxyPort := DefaultListenPort + if v := os.Getenv(envListenAddr); v != "" { + // ListenAddr is ":15001"; extract the port. + if idx := strings.LastIndex(v, ":"); idx >= 0 { + if p, perr := strconv.Atoi(v[idx+1:]); perr == nil && p > 0 { + proxyPort = p + } + } + } + + return &InitConfig{AppPort: appPort, ProxyPort: proxyPort, ProxyUID: proxyUID}, nil +} + +func getenvDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/browser-proxy/internal/iptables/iptables.go b/browser-proxy/internal/iptables/iptables.go new file mode 100644 index 0000000000..709ee4219d --- /dev/null +++ b/browser-proxy/internal/iptables/iptables.go @@ -0,0 +1,49 @@ +// Package iptables applies the transparent inbound traffic redirect for the odigos-browser-proxy +// sidecar, modeled on the Istio init-container approach: inbound TCP destined to the application +// port is redirected to the sidecar's listen port, while the sidecar's own traffic (matched by UID) +// is excluded so it can reach the application on loopback without looping. +package iptables + +import ( + "fmt" + "os/exec" + "strconv" +) + +// Config parameters for the redirect. +type Config struct { + AppPort int + ProxyPort int + ProxyUID int +} + +// Apply installs the nat rules. It requires CAP_NET_ADMIN (the init container runs with that +// capability). The `iptables` binary must be present in the image. +func Apply(cfg Config) error { + appPort := strconv.Itoa(cfg.AppPort) + proxyPort := strconv.Itoa(cfg.ProxyPort) + uid := strconv.Itoa(cfg.ProxyUID) + + rules := [][]string{ + // Inbound (from outside the pod): redirect TCP to the application port -> sidecar port. + {"-t", "nat", "-A", "PREROUTING", "-p", "tcp", "--dport", appPort, "-j", "REDIRECT", "--to-ports", proxyPort}, + + // Locally generated traffic owned by the sidecar UID must NOT be redirected, so the sidecar + // can connect to the application on 127.0.0.1:. + {"-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", appPort, "-m", "owner", "--uid-owner", uid, "-j", "RETURN"}, + // Any other local process targeting the application port is redirected to the sidecar too, + // so in-pod clients are also instrumented consistently. + {"-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", appPort, "-j", "REDIRECT", "--to-ports", proxyPort}, + } + + for _, rule := range rules { + // "-w" makes iptables wait for the xtables lock instead of failing if it is held. + args := append([]string{"-w"}, rule...) + cmd := exec.Command("iptables", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("iptables %v failed: %w: %s", rule, err, string(out)) + } + } + + return nil +} diff --git a/browser-proxy/internal/server/inject.go b/browser-proxy/internal/server/inject.go new file mode 100644 index 0000000000..f42077c13e --- /dev/null +++ b/browser-proxy/internal/server/inject.go @@ -0,0 +1,175 @@ +package server + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +// browserConfig is the JSON shape written to window.__ODIGOS__ for the agent bundle to read. +// It mirrors the OdigosBrowserConfig contract in the opentelemetry-browser agent (src/config.ts). +type browserConfig struct { + ServiceName string `json:"serviceName,omitempty"` + TracesPath string `json:"tracesPath"` + LogsPath string `json:"logsPath"` + ExportToken string `json:"exportToken,omitempty"` + ResourceAttributes map[string]string `json:"resourceAttributes,omitempty"` + PropagateTraceHeaderCorsUrls []string `json:"propagateTraceHeaderCorsUrls,omitempty"` +} + +var cspNonceRE = regexp.MustCompile(`(?i)'nonce-([^']+)'`) + +// buildConfigJS renders the body of /__odigos/config.js (assigns window.__ODIGOS__). +func buildConfigJS(cfg *config.Config) ([]byte, error) { + bc := browserConfig{ + ServiceName: cfg.ServiceName, + TracesPath: config.TracesPath, + LogsPath: config.LogsPath, + ExportToken: cfg.ExportToken, + ResourceAttributes: parseResourceAttributes(cfg.ResourceAttributes), + PropagateTraceHeaderCorsUrls: parseList(cfg.PropagateCorsUrls), + } + + configJSON, err := json.Marshal(bc) + if err != nil { + return nil, err + } + + var b bytes.Buffer + b.WriteString("window.__ODIGOS__=") + b.Write(configJSON) + b.WriteString(";") + return b.Bytes(), nil +} + +// buildSnippet renders CSP-safe external ") +} + +// extractCSPNonce returns the first CSP nonce value from a Content-Security-Policy header, if any. +func extractCSPNonce(cspHeader string) string { + m := cspNonceRE.FindStringSubmatch(cspHeader) + if len(m) < 2 { + return "" + } + return m[1] +} + +// parseResourceAttributes parses an OTEL_RESOURCE_ATTRIBUTES-style string ("k1=v1,k2=v2"). +func parseResourceAttributes(raw string) map[string]string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + out := map[string]string{} + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + k, v, found := strings.Cut(pair, "=") + k = strings.TrimSpace(k) + if !found || k == "" { + continue + } + out[k] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +func parseList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var out []string + for _, v := range strings.Split(raw, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} + +// injectIntoHTML inserts snippet into the document at the best available location. It prefers to +// inject immediately after the opening tag so the SDK initializes as early as possible. +// Matching is case-insensitive. If no suitable anchor is found, the snippet is prepended. +func injectIntoHTML(body, snippet []byte) []byte { + lower := bytes.ToLower(body) + + // Inject right after the opening tag. + if idx := indexAfterTag(lower, "= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to just before . + if idx := bytes.Index(lower, []byte("")); idx >= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to right after the opening tag. + if idx := indexAfterTag(lower, "= 0 { + return spliceAt(body, snippet, idx) + } + // Fall back to just before . + if idx := bytes.Index(lower, []byte("")); idx >= 0 { + return spliceAt(body, snippet, idx) + } + // Last resort: prepend. + return spliceAt(body, snippet, 0) +} + +// indexAfterTag returns the byte offset just past the end ('>') of the first tag that starts with +// tagStart (e.g. "') + if end < 0 { + return -1 + } + return start + end + 1 +} + +func spliceAt(body, snippet []byte, idx int) []byte { + out := make([]byte, 0, len(body)+len(snippet)) + out = append(out, body[:idx]...) + out = append(out, snippet...) + out = append(out, body[idx:]...) + return out +} + +// snippetForResponse picks a nonce-aware injection snippet for this HTML response. +func (s *Server) snippetForResponse(cspHeader string) []byte { + nonce := extractCSPNonce(cspHeader) + if nonce == "" { + return s.snippet + } + return buildSnippet(nonce) +} diff --git a/browser-proxy/internal/server/inject_test.go b/browser-proxy/internal/server/inject_test.go new file mode 100644 index 0000000000..78fb5e0d5c --- /dev/null +++ b/browser-proxy/internal/server/inject_test.go @@ -0,0 +1,141 @@ +package server + +import ( + "strings" + "testing" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +func TestInjectIntoHTML_AfterHead(t *testing.T) { + body := []byte("xhi") + snippet := []byte("") + out := string(injectIntoHTML(body, snippet)) + + if !strings.Contains(out, "") { + t.Fatalf("snippet not injected right after <head>: %s", out) + } +} + +func TestInjectIntoHTML_HeadWithAttributes(t *testing.T) { + body := []byte(`<head data-x="1">content`) + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, `<head data-x="1">S`) { + t.Fatalf("snippet not injected after head tag with attributes: %s", out) + } +} + +func TestInjectIntoHTML_FallbackBody(t *testing.T) { + body := []byte("<body>only body</body>") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, "<body>S") { + t.Fatalf("snippet not injected after <body>: %s", out) + } +} + +func TestInjectIntoHTML_FallbackPrepend(t *testing.T) { + body := []byte("no tags here") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.HasPrefix(out, "Sno tags") { + t.Fatalf("snippet not prepended: %s", out) + } +} + +func TestInjectIntoHTML_CaseInsensitive(t *testing.T) { + body := []byte("<HTML><HEAD></HEAD></HTML>") + snippet := []byte("S") + out := string(injectIntoHTML(body, snippet)) + if !strings.Contains(out, "<HEAD>S") { + t.Fatalf("case-insensitive head match failed: %s", out) + } +} + +func TestBuildSnippetExternalOnly(t *testing.T) { + s := string(buildSnippet("")) + if strings.Contains(s, "window.__ODIGOS__") { + t.Fatalf("snippet must not contain inline config: %s", s) + } + if !strings.Contains(s, `src="`+config.ConfigJsPath+`"`) { + t.Fatalf("missing config.js script tag: %s", s) + } + if !strings.Contains(s, `src="`+config.AgentJsPath+`"`) { + t.Fatalf("missing agent script tag: %s", s) + } + if strings.Count(s, "<script") != 2 { + t.Fatalf("expected exactly 2 script tags: %s", s) + } +} + +func TestBuildSnippetWithNonce(t *testing.T) { + s := string(buildSnippet("n-1")) + if strings.Count(s, `nonce="n-1"`) != 2 { + t.Fatalf("expected nonce on both tags: %s", s) + } +} + +func TestBuildConfigJS(t *testing.T) { + cfg := &config.Config{ + ServiceName: "my-frontend", + ResourceAttributes: "k8s.namespace.name=demo,k8s.pod.name=p1", + PropagateCorsUrls: "https://api.example.com,/.*backend.*/", + ExportToken: "tok", + } + body, err := buildConfigJS(cfg) + if err != nil { + t.Fatalf("buildConfigJS error: %v", err) + } + s := string(body) + + if !strings.HasPrefix(s, "window.__ODIGOS__=") { + t.Fatalf("missing config assignment: %s", s) + } + if !strings.Contains(s, `"serviceName":"my-frontend"`) { + t.Fatalf("missing service name: %s", s) + } + if !strings.Contains(s, `"tracesPath":"`+config.TracesPath+`"`) { + t.Fatalf("missing traces path: %s", s) + } + if !strings.Contains(s, `"logsPath":"`+config.LogsPath+`"`) { + t.Fatalf("missing logs path: %s", s) + } + if !strings.Contains(s, `"exportToken":"tok"`) { + t.Fatalf("missing export token: %s", s) + } + if !strings.Contains(s, "k8s.namespace.name") || !strings.Contains(s, "demo") { + t.Fatalf("missing resource attributes: %s", s) + } +} + +func TestExtractCSPNonce(t *testing.T) { + if got := extractCSPNonce(`default-src 'self'; script-src 'nonce-XYZ' 'self'`); got != "XYZ" { + t.Fatalf("got %q", got) + } + if got := extractCSPNonce(`default-src 'self'`); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestParseResourceAttributes(t *testing.T) { + got := parseResourceAttributes(" a = 1 , b=2 , ,c= ") + if got["a"] != "1" || got["b"] != "2" || got["c"] != "" { + t.Fatalf("unexpected parse result: %#v", got) + } + if parseResourceAttributes("") != nil { + t.Fatalf("empty input should return nil") + } +} + +func TestHostsEqual(t *testing.T) { + if !hostsEqual("frontend.example.com", "frontend.example.com") { + t.Fatal("equal hosts") + } + if !hostsEqual("frontend.example.com:443", "frontend.example.com") { + t.Fatal("host with port vs without") + } + if hostsEqual("evil.example.com", "frontend.example.com") { + t.Fatal("different hosts") + } +} diff --git a/browser-proxy/internal/server/otlp.go b/browser-proxy/internal/server/otlp.go new file mode 100644 index 0000000000..39f554a64d --- /dev/null +++ b/browser-proxy/internal/server/otlp.go @@ -0,0 +1,165 @@ +package server + +import ( + "bytes" + "crypto/subtle" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +const ( + defaultOTLPPerIPPerMin = 120 + defaultOTLPPerTokenPerMin = 240 + // Tighter than the previous 16 MiB — browser batches are small; large bodies are abuse. + maxOTLPBodyBytes = 1 << 20 // 1 MiB +) + +// corsHeaders sets CORS for OTLP only when the request Origin passes the same-site check. +// Never emits Access-Control-Allow-Origin: *. +func (s *Server) corsHeaders(w http.ResponseWriter, r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if !sameSiteOrigin(origin, r.Host) { + return false + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "POST, OPTIONS") + h.Set("Access-Control-Allow-Headers", "content-type, authorization, traceparent, tracestate, baggage") + h.Set("Access-Control-Max-Age", "86400") + return true +} + +// handleOTLP authenticates, rate-limits, and forwards browser OTLP/HTTP to the node-local collector. +func (s *Server) handleOTLP(w http.ResponseWriter, r *http.Request) { + if !s.corsHeaders(w, r) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if !s.validateSameSite(r) { + http.Error(w, "cross-site request blocked", http.StatusForbidden) + return + } + + token := bearerToken(r) + if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.ExportToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ip := clientIP(r) + if !s.ipLimiter.allow(ip) || !s.tokenLimiter.allow(token) { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + + // Map /__odigos/v1/<signal> -> <collector>/v1/<signal>. + signalPath := strings.TrimPrefix(r.URL.Path, config.OtlpPathPrefix) + targetURL := s.cfg.OtlpHTTPEndpoint + "/v1/" + signalPath + + body, err := io.ReadAll(io.LimitReader(r.Body, maxOTLPBodyBytes+1)) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + if len(body) > maxOTLPBodyBytes { + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, targetURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "failed to build upstream request", http.StatusInternalServerError) + return + } + // Preserve the payload framing so the collector can decode it (protobuf or json, possibly gzip). + // Do NOT forward Authorization — the export token is gateway-local only. + copyHeader(req.Header, r.Header, "Content-Type") + copyHeader(req.Header, r.Header, "Content-Encoding") + + resp, err := s.otlpClient.Do(req) + if err != nil { + // The browser cannot reach the collector directly; swallow upstream errors as 502 but keep + // the page healthy (telemetry loss must never surface to end users). + log.Printf("browser-proxy: failed to forward OTLP to %s: %v", targetURL, err) + w.WriteHeader(http.StatusBadGateway) + return + } + defer resp.Body.Close() + + copyHeader(w.Header(), resp.Header, "Content-Type") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, io.LimitReader(resp.Body, maxOTLPBodyBytes)) +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(h) < len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(h[len(prefix):]) +} + +// validateSameSite rejects cross-site OTLP POSTs when Origin or Referer is present and mismatches Host. +func (s *Server) validateSameSite(r *http.Request) bool { + if origin := r.Header.Get("Origin"); origin != "" { + return sameSiteOrigin(origin, r.Host) + } + if referer := r.Header.Get("Referer"); referer != "" { + return sameSiteOrigin(referer, r.Host) + } + return true +} + +func sameSiteOrigin(originOrURL, requestHost string) bool { + u, err := url.Parse(originOrURL) + if err != nil || u.Host == "" { + return false + } + return hostsEqual(u.Host, requestHost) +} + +func hostsEqual(a, b string) bool { + ah, ap, _ := net.SplitHostPort(a) + if ah == "" { + ah = a + } + bh, bp, _ := net.SplitHostPort(b) + if bh == "" { + bh = b + } + if !strings.EqualFold(ah, bh) { + return false + } + // If both specify ports, require equality; if one omits port, treat as match on hostname. + if ap != "" && bp != "" && ap != bp { + return false + } + return true +} + +func copyHeader(dst, src http.Header, key string) { + if v := src.Get(key); v != "" { + dst.Set(key, v) + } +} diff --git a/browser-proxy/internal/server/ratelimit.go b/browser-proxy/internal/server/ratelimit.go new file mode 100644 index 0000000000..93f8f80904 --- /dev/null +++ b/browser-proxy/internal/server/ratelimit.go @@ -0,0 +1,88 @@ +package server + +import ( + "net" + "net/http" + "sync" + "time" +) + +// rateLimiter is a simple per-key token bucket used to bound OTLP abuse. +type rateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // tokens per second + burst float64 + lastSweep time.Time +} + +type bucket struct { + tokens float64 + last time.Time + lastSeen time.Time +} + +func newRateLimiter(perMinute int, burst int) *rateLimiter { + if perMinute <= 0 { + perMinute = 120 + } + if burst <= 0 { + burst = perMinute / 4 + if burst < 5 { + burst = 5 + } + } + return &rateLimiter{ + buckets: make(map[string]*bucket), + rate: float64(perMinute) / 60.0, + burst: float64(burst), + lastSweep: time.Now(), + } +} + +func (l *rateLimiter) allow(key string) bool { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + + if now.Sub(l.lastSweep) > 5*time.Minute { + l.sweepLocked(now) + l.lastSweep = now + } + + b := l.buckets[key] + if b == nil { + b = &bucket{tokens: l.burst, last: now, lastSeen: now} + l.buckets[key] = b + } + + elapsed := now.Sub(b.last).Seconds() + b.tokens += elapsed * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.last = now + b.lastSeen = now + + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func (l *rateLimiter) sweepLocked(now time.Time) { + for k, b := range l.buckets { + if now.Sub(b.lastSeen) > 10*time.Minute { + delete(l.buckets, k) + } + } +} + +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/browser-proxy/internal/server/server.go b/browser-proxy/internal/server/server.go new file mode 100644 index 0000000000..37168aedb1 --- /dev/null +++ b/browser-proxy/internal/server/server.go @@ -0,0 +1,244 @@ +// Package server implements the odigos-browser-proxy HTTP server: a reverse proxy in front of a +// web-server container that injects CSP-safe OpenTelemetry browser SDK <script> tags into HTML +// responses and proxies authenticated browser OTLP/HTTP telemetry to the node-local collector. +package server + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +const ( + // Upper bound on HTML bodies we will buffer to inject into. Larger responses are streamed + // through untouched (a multi-MB HTML document is almost certainly not a normal page). + maxHTMLInjectBytes = 8 << 20 // 8 MiB +) + +// Server is the browser-proxy HTTP server. +type Server struct { + cfg *config.Config + snippet []byte // default (no CSP nonce) injection tags + configJS []byte + proxy *httputil.ReverseProxy + otlpClient *http.Client + ipLimiter *rateLimiter + tokenLimiter *rateLimiter +} + +// New builds a Server from the given configuration. +func New(cfg *config.Config) (*Server, error) { + if cfg.ExportToken == "" { + tok, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generate export token: %w", err) + } + cfg.ExportToken = tok + } + + upstreamURL, err := url.Parse(cfg.Upstream) + if err != nil { + return nil, fmt.Errorf("invalid upstream URL %q: %w", cfg.Upstream, err) + } + + configJS, err := buildConfigJS(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build config.js: %w", err) + } + + s := &Server{ + cfg: cfg, + snippet: buildSnippet(""), + configJS: configJS, + otlpClient: &http.Client{Timeout: 30 * time.Second}, + ipLimiter: newRateLimiter(defaultOTLPPerIPPerMin, 30), + tokenLimiter: newRateLimiter(defaultOTLPPerTokenPerMin, 60), + } + + proxy := httputil.NewSingleHostReverseProxy(upstreamURL) + defaultDirector := proxy.Director + proxy.Director = func(req *http.Request) { + defaultDirector(req) + req.Host = upstreamURL.Host + // Only accept encodings we can decode for injection. This avoids receiving brotli/zstd + // HTML that we would otherwise have to skip. Non-HTML responses are passed through as-is. + req.Header.Set("Accept-Encoding", "gzip") + } + proxy.ModifyResponse = s.injectResponse + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("browser-proxy: upstream error for %s: %v", r.URL.Path, err) + w.WriteHeader(http.StatusBadGateway) + } + s.proxy = proxy + + return s, nil +} + +func generateToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// Handler returns the root HTTP handler with routing for the reserved /__odigos/ paths and the +// reverse proxy fallthrough. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(config.HealthPath, s.handleHealth) + mux.HandleFunc(config.ConfigJsPath, s.handleConfigJS) + mux.HandleFunc(config.AgentJsPath, s.handleAgentJS) + // All OTLP signals (traces/metrics/logs) under the reserved prefix. + mux.HandleFunc(config.OtlpPathPrefix, s.handleOTLP) + mux.HandleFunc("/", s.handleProxy) + return mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +// Run starts the HTTP server and blocks. +func (s *Server) Run() error { + srv := &http.Server{ + Addr: s.cfg.ListenAddr, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("browser-proxy: listening on %s, forwarding to %s (service=%q, collector=%s)", + s.cfg.ListenAddr, s.cfg.Upstream, s.cfg.ServiceName, s.cfg.OtlpHTTPEndpoint) + return srv.ListenAndServe() +} + +func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { + s.proxy.ServeHTTP(w, r) +} + +func (s *Server) handleConfigJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "private, max-age=60") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(s.configJS) +} + +// handleAgentJS serves the browser SDK bundle from the mounted agents directory. +func (s *Server) handleAgentJS(w http.ResponseWriter, r *http.Request) { + path := filepath.Join(s.cfg.AgentDir, filepath.Base(s.cfg.AgentFile)) + f, err := os.Open(path) + if err != nil { + log.Printf("browser-proxy: agent bundle not found at %s: %v", path, err) + http.Error(w, "agent bundle not available", http.StatusNotFound) + return + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + http.Error(w, "agent bundle not available", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=300") + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, s.cfg.AgentFile, info.ModTime(), f) +} + +// injectResponse is the ReverseProxy ModifyResponse hook. It injects the SDK snippet into HTML +// responses, decompressing/recompressing gzip as needed, and leaves all other responses untouched. +func (s *Server) injectResponse(resp *http.Response) error { + if !isHTML(resp.Header.Get("Content-Type")) { + return nil + } + + encoding := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) + if encoding != "" && encoding != "gzip" && encoding != "identity" { + // We forced Accept-Encoding: gzip upstream, so this is unexpected; skip injection to be safe. + return nil + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxHTMLInjectBytes+1)) + if err != nil { + return err + } + _ = resp.Body.Close() + + if len(raw) > maxHTMLInjectBytes { + // Too large to safely buffer/inject; pass through unchanged. + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + + decoded := raw + if encoding == "gzip" { + gr, gzErr := gzip.NewReader(bytes.NewReader(raw)) + if gzErr != nil { + // Not actually gzip / corrupt; pass through unchanged. + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + decoded, err = io.ReadAll(gr) + _ = gr.Close() + if err != nil { + resp.Body = io.NopCloser(bytes.NewReader(raw)) + return nil + } + } + + csp := resp.Header.Get("Content-Security-Policy") + if csp == "" { + csp = resp.Header.Get("Content-Security-Policy-Report-Only") + } + snippet := s.snippetForResponse(csp) + injected := injectIntoHTML(decoded, snippet) + + out := injected + if encoding == "gzip" { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(injected); err != nil { + _ = gw.Close() + resp.Header.Del("Content-Encoding") + resp.Body = io.NopCloser(bytes.NewReader(injected)) + resp.ContentLength = int64(len(injected)) + resp.Header.Set("Content-Length", strconv.Itoa(len(injected))) + return nil + } + if err := gw.Close(); err != nil { + resp.Header.Del("Content-Encoding") + resp.Body = io.NopCloser(bytes.NewReader(injected)) + resp.ContentLength = int64(len(injected)) + resp.Header.Set("Content-Length", strconv.Itoa(len(injected))) + return nil + } + out = buf.Bytes() + resp.Header.Set("Content-Encoding", "gzip") + } else { + resp.Header.Del("Content-Encoding") + } + + resp.Body = io.NopCloser(bytes.NewReader(out)) + resp.ContentLength = int64(len(out)) + resp.Header.Set("Content-Length", strconv.Itoa(len(out))) + return nil +} + +func isHTML(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/html") +} diff --git a/browser-proxy/internal/server/server_test.go b/browser-proxy/internal/server/server_test.go new file mode 100644 index 0000000000..b4c024276c --- /dev/null +++ b/browser-proxy/internal/server/server_test.go @@ -0,0 +1,265 @@ +package server + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/odigos-io/odigos/browser-proxy/internal/config" +) + +func newTestServer(t *testing.T, upstream string) *Server { + t.Helper() + s, err := New(&config.Config{ + ListenAddr: ":0", + Upstream: upstream, + AgentDir: "/var/odigos/browser", + AgentFile: "agent.js", + OtlpHTTPEndpoint: "http://collector:4318", + ServiceName: "test-frontend", + ExportToken: "test-export-token", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return s +} + +func TestProxyInjectsHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, "<html><head></head><body>app</body></html>") + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + body := rec.Body.String() + if strings.Contains(body, "window.__ODIGOS__=") { + t.Fatalf("must not inject inline config script, got: %s", body) + } + if !strings.Contains(body, `src="`+config.ConfigJsPath+`"`) { + t.Fatalf("expected injected config.js script, got: %s", body) + } + if !strings.Contains(body, `src="`+config.AgentJsPath+`"`) { + t.Fatalf("expected injected agent script, got: %s", body) + } + if rec.Header().Get("Content-Encoding") != "" { + t.Fatalf("expected content-encoding to be stripped after injection of identity response") + } +} + +func TestProxyInjectsGzippedHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + _, _ = gw.Write([]byte("<html><head></head><body>app</body></html>")) + _ = gw.Close() + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Encoding", "gzip") + _, _ = w.Write(buf.Bytes()) + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rec.Header().Get("Content-Encoding") != "gzip" { + t.Fatalf("expected gzip content-encoding after recompression, got %q", rec.Header().Get("Content-Encoding")) + } + gr, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("response is not valid gzip: %v", err) + } + defer gr.Close() + decoded, err := io.ReadAll(gr) + if err != nil { + t.Fatalf("failed to read gzipped body: %v", err) + } + body := string(decoded) + if !strings.Contains(body, `src="`+config.ConfigJsPath+`"`) { + t.Fatalf("expected injected config.js in gzipped html, got: %s", body) + } + if !strings.Contains(body, "app") { + t.Fatalf("expected original content preserved, got: %s", body) + } +} + +func TestProxyPropagatesCSPNonce(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'nonce-abc123'") + _, _ = io.WriteString(w, "<html><head></head><body>app</body></html>") + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + body := rec.Body.String() + if !strings.Contains(body, `nonce="abc123"`) { + t.Fatalf("expected CSP nonce on injected scripts, got: %s", body) + } +} + +func TestConfigJS(t *testing.T) { + s := newTestServer(t, "http://unused.local") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, config.ConfigJsPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.HasPrefix(body, "window.__ODIGOS__=") { + t.Fatalf("expected config.js assignment, got: %s", body) + } + if !strings.Contains(body, `"exportToken":"test-export-token"`) { + t.Fatalf("expected export token in config.js, got: %s", body) + } + if !strings.Contains(body, `"logsPath":"`+config.LogsPath+`"`) { + t.Fatalf("expected logsPath in config.js, got: %s", body) + } + if rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("expected nosniff header") + } +} + +func TestHealthz(t *testing.T) { + s := newTestServer(t, "http://unused.local") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, config.HealthPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if rec.Body.String() != "ok" { + t.Fatalf("expected ok body, got %q", rec.Body.String()) + } +} + +func TestProxyDoesNotInjectNonHTML(t *testing.T) { + app := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer app.Close() + + s := newTestServer(t, app.URL) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api", nil)) + + if strings.Contains(rec.Body.String(), "__ODIGOS__") || strings.Contains(rec.Body.String(), config.ConfigJsPath) { + t.Fatalf("must not inject into non-HTML responses: %s", rec.Body.String()) + } +} + +func TestOTLPRequiresToken(t *testing.T) { + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + s, err := New(&config.Config{ + ListenAddr: ":0", + Upstream: "http://unused.local", + OtlpHTTPEndpoint: collector.URL, + AgentDir: "/var/odigos/browser", + AgentFile: "agent.js", + ExportToken: "secret-token", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, config.TracesPath, strings.NewReader("payload")) + req.Header.Set("Content-Type", "application/x-protobuf") + req.Host = "frontend.example.com" + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without token, got %d", rec.Code) + } +} + +func TestOTLPForwardingAndCORS(t *testing.T) { + var gotPath string + var gotBody []byte + var gotAuth string + collector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer collector.Close() + + s, err := New(&config.Config{ + ListenAddr: ":0", + Upstream: "http://unused.local", + OtlpHTTPEndpoint: collector.URL, + AgentDir: "/var/odigos/browser", + AgentFile: "agent.js", + ExportToken: "secret-token", + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + // Preflight from same site + pre := httptest.NewRecorder() + preReq := httptest.NewRequest(http.MethodOptions, config.TracesPath, nil) + preReq.Header.Set("Origin", "https://frontend.example.com") + preReq.Host = "frontend.example.com" + s.Handler().ServeHTTP(pre, preReq) + if pre.Code != http.StatusNoContent { + t.Fatalf("preflight expected 204, got %d", pre.Code) + } + if pre.Header().Get("Access-Control-Allow-Origin") != "https://frontend.example.com" { + t.Fatalf("missing CORS origin on preflight: %v", pre.Header()) + } + if !strings.Contains(pre.Header().Get("Access-Control-Allow-Headers"), "authorization") { + t.Fatalf("CORS must allow authorization header: %v", pre.Header()) + } + + // Cross-site preflight rejected + bad := httptest.NewRecorder() + badReq := httptest.NewRequest(http.MethodOptions, config.TracesPath, nil) + badReq.Header.Set("Origin", "https://evil.example.com") + badReq.Host = "frontend.example.com" + s.Handler().ServeHTTP(bad, badReq) + if bad.Code != http.StatusForbidden { + t.Fatalf("cross-site preflight expected 403, got %d", bad.Code) + } + + // Authenticated POST + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, config.TracesPath, strings.NewReader("payload")) + req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("Authorization", "Bearer secret-token") + req.Header.Set("Origin", "https://frontend.example.com") + req.Host = "frontend.example.com" + s.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 from forwarded OTLP, got %d body=%s", rec.Code, rec.Body.String()) + } + if gotPath != "/v1/traces" { + t.Fatalf("expected collector path /v1/traces, got %q", gotPath) + } + if string(gotBody) != "payload" { + t.Fatalf("expected forwarded body 'payload', got %q", string(gotBody)) + } + if gotAuth != "" { + t.Fatalf("export token must not be forwarded to collector, got %q", gotAuth) + } + if rec.Header().Get("Access-Control-Allow-Origin") != "https://frontend.example.com" { + t.Fatalf("expected CORS header on OTLP response") + } +} diff --git a/common/lang_detection.go b/common/lang_detection.go index a5e9b8ff76..4df99ffe6a 100644 --- a/common/lang_detection.go +++ b/common/lang_detection.go @@ -17,7 +17,7 @@ type ProgramLanguageDetails struct { RuntimeVersion string } -// +kubebuilder:validation:Enum=java;python;go;dotnet;javascript;php;ruby;rust;cplusplus;mysql;nginx;redis;postgres;unknown;ignored;* +// +kubebuilder:validation:Enum=java;python;go;dotnet;javascript;browser;php;ruby;rust;cplusplus;mysql;nginx;redis;postgres;unknown;ignored;* type ProgrammingLanguage string const ( @@ -28,13 +28,18 @@ const ( GoProgrammingLanguage ProgrammingLanguage = "go" DotNetProgrammingLanguage ProgrammingLanguage = "dotnet" JavascriptProgrammingLanguage ProgrammingLanguage = "javascript" - PhpProgrammingLanguage ProgrammingLanguage = "php" - RubyProgrammingLanguage ProgrammingLanguage = "ruby" - RustProgrammingLanguage ProgrammingLanguage = "rust" - CPlusPlusProgrammingLanguage ProgrammingLanguage = "cplusplus" - CSharpProgrammingLanguage ProgrammingLanguage = "csharp" - SwiftProgrammingLanguage ProgrammingLanguage = "swift" - ElixirProgrammingLanguage ProgrammingLanguage = "elixir" + // BrowserProgrammingLanguage is JavaScript that runs in the end-user's browser (the OpenTelemetry + // Web SDK), as opposed to JavascriptProgrammingLanguage which is server-side Node.js. Unlike the + // other languages, the code is not executed by a process inside the pod, so it cannot be detected + // via /proc and is delivered to the browser by the odigos-browser-proxy sidecar. + BrowserProgrammingLanguage ProgrammingLanguage = "browser" + PhpProgrammingLanguage ProgrammingLanguage = "php" + RubyProgrammingLanguage ProgrammingLanguage = "ruby" + RustProgrammingLanguage ProgrammingLanguage = "rust" + CPlusPlusProgrammingLanguage ProgrammingLanguage = "cplusplus" + CSharpProgrammingLanguage ProgrammingLanguage = "csharp" + SwiftProgrammingLanguage ProgrammingLanguage = "swift" + ElixirProgrammingLanguage ProgrammingLanguage = "elixir" // This is an experimental feature, It is not a language // but in order to avoid huge refactoring we are adding it here for now MySQLProgrammingLanguage ProgrammingLanguage = "mysql" @@ -56,6 +61,8 @@ func MapOdigosToSemConv(odigosPrograminglang ProgrammingLanguage) string { switch odigosPrograminglang { case JavascriptProgrammingLanguage: return semconv.TelemetrySDKLanguageNodejs.Value.AsString() + case BrowserProgrammingLanguage: + return semconv.TelemetrySDKLanguageWebjs.Value.AsString() default: return string(odigosPrograminglang) } diff --git a/distros/distro/oteldistribution.go b/distros/distro/oteldistribution.go index 44d26d7eb2..37b53e1a1d 100644 --- a/distros/distro/oteldistribution.go +++ b/distros/distro/oteldistribution.go @@ -142,6 +142,23 @@ type Option struct { Value string `yaml:"value"` } +// BrowserSidecar marks a distribution whose telemetry SDK runs in the end-user's browser rather +// than in a process inside the pod. Such a distribution is not delivered by mounting agent files +// or setting runtime env vars on the application container. Instead, the instrumentor injects the +// odigos-browser-proxy sidecar (and a traffic-redirect init container) in front of the web server. +// The sidecar injects a <script> tag that loads the OpenTelemetry Web SDK bundle into served HTML +// responses, and proxies the browser's OTLP/HTTP telemetry back to the node-local collector. +type BrowserSidecar struct { + // The directory (under the agents dir) that contains the browser SDK bundle the sidecar serves. + // The special value {{ODIGOS_AGENTS_DIR}} is replaced with the actual agents directory at runtime + // (e.g. "{{ODIGOS_AGENTS_DIR}}/browser" -> "/var/odigos/browser" on k8s). + AgentDirectory string `yaml:"agentDirectory"` + + // The file name (within AgentDirectory) of the browser SDK bundle that the sidecar serves to the + // browser and references from the injected <script> tag (e.g. "agent.js"). + AgentFileName string `yaml:"agentFileName"` +} + type SpanMetrics struct { // if true, the agent supports span metrics. Supported bool `yaml:"supported,omitempty"` @@ -310,6 +327,11 @@ type OtelDistro struct { // Can be nil in case no runtime agent is required. RuntimeAgent *RuntimeAgent `yaml:"runtimeAgent,omitempty"` + // If set, this distribution targets the end-user's browser and is delivered by the + // odigos-browser-proxy sidecar (HTML <script> injection + same-origin OTLP proxy) instead of + // an in-pod runtime agent. When set, RuntimeAgent and runtime env-var injection are not used. + BrowserSidecar *BrowserSidecar `yaml:"browserSidecar,omitempty"` + // if true, the distro receives it's configuration as environment variables. // it means the distro does not support opamp and not configurable via ebpf. // these pods will require a restart to apply the new configuration. diff --git a/distros/distro/utils.go b/distros/distro/utils.go index 30cabf7686..6386b3c7b8 100644 --- a/distros/distro/utils.go +++ b/distros/distro/utils.go @@ -8,6 +8,11 @@ func IsRestartRequired(d *OtelDistro, config *common.OdigosConfiguration) bool { if d == nil { return false } + // Browser distributions are delivered by the odigos-browser-proxy sidecar (plus its iptables init container), which is injected into the pod manifest by the instrumentor webhook at pod creation. + // Unlike in-pod runtime agents, this cannot be applied to already-running pods, so a restart/rollout is always required for the sidecar to be added. + if d.BrowserSidecar != nil { + return true + } if d.RuntimeAgent == nil { return false } diff --git a/distros/distro/utils_test.go b/distros/distro/utils_test.go new file mode 100644 index 0000000000..e68e93b9ed --- /dev/null +++ b/distros/distro/utils_test.go @@ -0,0 +1,67 @@ +package distro + +import ( + "testing" + + "github.com/odigos-io/odigos/common" +) + +func boolPtr(b bool) *bool { return &b } + +func TestIsRestartRequired(t *testing.T) { + emptyConfig := &common.OdigosConfiguration{} + + tests := []struct { + name string + distro *OtelDistro + config *common.OdigosConfiguration + want bool + }{ + { + name: "nil distro does not require restart", + distro: nil, + config: emptyConfig, + want: false, + }, + { + // regression: browser distros have no in-pod RuntimeAgent, so the BrowserSidecar + // check must run before the RuntimeAgent nil-check, otherwise this returns false. + name: "browser distro requires restart despite nil runtime agent", + distro: &OtelDistro{BrowserSidecar: &BrowserSidecar{AgentDirectory: "{{ODIGOS_AGENTS_DIR}}/browser", AgentFileName: "agent.js"}}, + config: emptyConfig, + want: true, + }, + { + name: "distro with no runtime agent and no browser sidecar does not require restart", + distro: &OtelDistro{}, + config: emptyConfig, + want: false, + }, + { + name: "runtime agent without NoRestartRequired requires restart", + distro: &OtelDistro{RuntimeAgent: &RuntimeAgent{}}, + config: emptyConfig, + want: true, + }, + { + name: "runtime agent with NoRestartRequired does not require restart", + distro: &OtelDistro{RuntimeAgent: &RuntimeAgent{NoRestartRequired: true}}, + config: emptyConfig, + want: false, + }, + { + name: "wasp enabled and supported requires restart even when NoRestartRequired", + distro: &OtelDistro{RuntimeAgent: &RuntimeAgent{NoRestartRequired: true, WaspSupported: true}}, + config: &common.OdigosConfiguration{WaspEnabled: boolPtr(true)}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsRestartRequired(tt.distro, tt.config); got != tt.want { + t.Errorf("IsRestartRequired() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/distros/oteldistributions.go b/distros/oteldistributions.go index 20700bf814..54831eeb95 100644 --- a/distros/oteldistributions.go +++ b/distros/oteldistributions.go @@ -48,6 +48,7 @@ func NewGetterFromFS(fs embed.FS) (*Getter, error) { func (c *communityDefaulter) GetDefaultDistroNames() map[common.ProgrammingLanguage]string { return map[common.ProgrammingLanguage]string{ common.JavascriptProgrammingLanguage: "nodejs-community", + common.BrowserProgrammingLanguage: "browser-community", common.PythonProgrammingLanguage: "python-community", common.DotNetProgrammingLanguage: "dotnet-community", common.JavaProgrammingLanguage: "java-community", diff --git a/distros/yamls/browser-community.yaml b/distros/yamls/browser-community.yaml new file mode 100644 index 0000000000..5174a32ee6 --- /dev/null +++ b/distros/yamls/browser-community.yaml @@ -0,0 +1,26 @@ +apiVersion: internal.odigos.io/v1beta1 +kind: OtelDistribution +metadata: + name: browser-community +spec: + name: browser-community + language: browser + runtimeEnvironments: + - name: browser + supportedVersions: '*' + displayName: 'Browser Community Native Instrumentation' + description: | + This distribution instruments front-end web applications using the OpenTelemetry Web SDK + and instrumentation libraries from the OpenTelemetry community. + + Unlike server-side distributions, the telemetry SDK runs in the end-user's browser. Odigos + does not mount agent files into the application container or set runtime environment variables. + Instead, the odigos-browser-proxy sidecar is injected in front of the web server: it injects a + <script> tag that loads the OpenTelemetry Web SDK bundle into served HTML responses, and proxies + the browser's OTLP/HTTP telemetry (same-origin) back to the node-local collector. + browserSidecar: + agentDirectory: '{{ODIGOS_AGENTS_DIR}}/browser' + agentFileName: 'agent.js' + traces: + headersCollection: + supported: true diff --git a/docs/docs.json b/docs/docs.json index 1496a510b8..0046fd1962 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -157,6 +157,13 @@ "oss/instrumentations/dotnet/enrichment" ] }, + { + "group": "Browser", + "icon": "globe", + "pages": [ + "oss/instrumentations/browser/native" + ] + }, { "group": "Advanced", "icon": "gear", diff --git a/docs/oss/instrumentations/browser/native.mdx b/docs/oss/instrumentations/browser/native.mdx new file mode 100644 index 0000000000..9470aa9abf --- /dev/null +++ b/docs/oss/instrumentations/browser/native.mdx @@ -0,0 +1,100 @@ +--- +title: "Browser (Web) Instrumentation" +sidebarTitle: "Browser" +icon: "globe" +--- + +Odigos can instrument **front-end web applications** with the [OpenTelemetry Web SDK](https://opentelemetry.io/docs/languages/js/getting-started/browser/), +capturing browser-side telemetry such as page loads, `fetch`/`XHR` requests, and user interactions. + +<Note> + Browser instrumentation is fundamentally different from Odigos' server-side language agents. The + telemetry SDK runs in the **end user's browser**, not in a process inside the pod, so it cannot be + auto-detected from `/proc` and must be enabled explicitly (see [Enabling](#enabling) below). +</Note> + +## How it works + +Because the OpenTelemetry Web SDK runs in the browser, Odigos does not mount agent files into the +application container or set runtime environment variables. Instead it injects a hardened +same-origin gateway sidecar, `odigos-browser-proxy`, in front of the web server container: + +```mermaid +flowchart TD + User["End-user browser"] -->|"GET / (HTML)"| SC["odigos-browser-proxy sidecar"] + SC -->|"forward"| App["web server container<br/>(nginx / serve / ...)"] + App -->|"HTML response"| SC + SC -->|"inject external <script> tags + recompress"| User + User -->|"GET /__odigos/config.js"| SC + User -->|"GET /__odigos/agent.js"| SC + User -->|"POST /__odigos/v1/traces\|logs<br/>Bearer token"| SC + SC -->|"validate + rate limit + forward"| NC["node-local collector :4318"] +``` + +The sidecar: + +1. **Injects** CSP-safe external `<script src="/__odigos/config.js">` and + `<script src="/__odigos/agent.js">` tags into `text/html` responses (gzip-aware). No inline + JavaScript is injected, so default `script-src 'self'` CSPs keep working. When the upstream + response CSP includes a nonce, that nonce is copied onto the injected tags. +2. **Serves** `/__odigos/config.js` (assigns `window.__ODIGOS__`, including a per-sidecar + **export token**) and the SDK bundle at `/__odigos/agent.js`. +3. **Receives** authenticated browser OTLP/HTTP at `/__odigos/v1/*` (Bearer token + rate limits + + same-site Origin checks) and **forwards** it to the node-local Odigos collector. Because + telemetry is sent same-origin, no public collector endpoint is required. + +An init container installs an `iptables` rule (Istio-style) that transparently redirects the +application's inbound traffic to the sidecar, so the Kubernetes `Service` does not need to change. + +Design docs for the security model live in the agent repo: +[odigos-io/opentelemetry-browser/docs](https://github.com/odigos-io/opentelemetry-browser/tree/main/docs). + +## Enabling + +Front-end workloads cannot be reliably auto-detected, so browser instrumentation is **opt-in** per +container. Create (or edit) a `Source` for the workload and set a container override that selects the +`browser-community` distribution on the serving container: + +```yaml +apiVersion: odigos.io/v1alpha1 +kind: Source +metadata: + name: my-frontend-source + namespace: my-namespace +spec: + workload: + name: my-frontend + namespace: my-namespace + kind: Deployment + containerOverrides: + - containerName: my-frontend # the container serving the HTML + otelDistroName: browser-community +``` + +<Tip> + If your front-end is served by a process Odigos would otherwise auto-instrument as server-side code + (for example a Node.js static-file server), the `browser-community` override takes precedence and the + server-side agent is not applied to that container. +</Tip> + +When the workload's pods are (re)created, Odigos injects the `odigos-browser-proxy` sidecar and the +traffic-redirect init container. Open the application in a browser and confirm that browser traces +arrive at your configured destination. + +## Requirements & notes + +- The serving container must expose a TCP `containerPort`; the sidecar fronts that port. +- The redirect init container requires the `NET_ADMIN` capability. Namespaces enforcing a restrictive + Pod Security Standard may need an exception for the instrumented workload. +- Only `text/html` responses are rewritten; all other responses (assets, APIs) pass through unchanged. +- **One distro per container:** selecting `browser-community` replaces any server-side language + agent on that container. To instrument both a Node.js API and browser HTML, split them into + separate containers (or workloads) and override only the HTML-serving one. +- **Service meshes:** browser instrumentation is **not supported** alongside sidecar meshes such as + Istio or Linkerd. Both use iptables redirects; co-injecting them would collide. Odigos skips + browser-proxy injection when an `istio-proxy` / `linkerd-proxy` container is already present. +- **CSP:** prefer `script-src 'self'` (or allow `/__odigos/*.js`). Hash-only CSPs without `'self'` + need an explicit allow-list update. Nonce-based CSPs are honored automatically when present on + the HTML response. +- **Security:** OTLP paths reject unauthenticated requests and apply per-IP / per-token rate limits. + The export token is a telemetry write credential embedded in `config.js`, not a user session secret. diff --git a/helm/odigos/templates/crds/odigos.io_actions.yaml b/helm/odigos/templates/crds/odigos.io_actions.yaml index 4599fedb1a..ba6bcb0b18 100644 --- a/helm/odigos/templates/crds/odigos.io_actions.yaml +++ b/helm/odigos/templates/crds/odigos.io_actions.yaml @@ -93,6 +93,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -234,6 +235,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -471,6 +473,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -548,6 +551,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -620,6 +624,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -712,6 +717,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml b/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml index 80e66beb5a..0c1610c76e 100644 --- a/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml +++ b/helm/odigos/templates/crds/odigos.io_instrumentationconfigs.yaml @@ -124,6 +124,7 @@ spec: - RuntimeDetailsUnavailable - CrashLoopBackOff - ImagePullBackOff + - BrowserPortMissing type: string containerName: description: The name of the container to which this configuration @@ -708,6 +709,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -746,6 +748,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -890,6 +893,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -1507,6 +1511,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml b/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml index dbdbaf4077..cf4f4de0e3 100644 --- a/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml +++ b/helm/odigos/templates/crds/odigos.io_instrumentationrules.yaml @@ -237,6 +237,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -398,6 +399,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -465,6 +467,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -502,6 +505,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_samplings.yaml b/helm/odigos/templates/crds/odigos.io_samplings.yaml index 69938a5e30..4b63118ce3 100644 --- a/helm/odigos/templates/crds/odigos.io_samplings.yaml +++ b/helm/odigos/templates/crds/odigos.io_samplings.yaml @@ -131,6 +131,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -279,6 +280,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust @@ -478,6 +480,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/crds/odigos.io_sources.yaml b/helm/odigos/templates/crds/odigos.io_sources.yaml index c46991d728..3dc626a011 100644 --- a/helm/odigos/templates/crds/odigos.io_sources.yaml +++ b/helm/odigos/templates/crds/odigos.io_sources.yaml @@ -129,6 +129,7 @@ spec: - go - dotnet - javascript + - browser - php - ruby - rust diff --git a/helm/odigos/templates/instrumentor/deployment.yaml b/helm/odigos/templates/instrumentor/deployment.yaml index cbb6d8010f..3b3a3165d6 100644 --- a/helm/odigos/templates/instrumentor/deployment.yaml +++ b/helm/odigos/templates/instrumentor/deployment.yaml @@ -108,6 +108,8 @@ spec: {{- else }} value: {{ template "utils.imageName" (dict "Values" .Values "Release" .Release "Component" "agents" "Tag" $imageTag) }} {{- end }} + - name: ODIGOS_BROWSER_PROXY_IMAGE + value: {{ template "utils.imageName" (dict "Values" .Values "Release" .Release "Component" "browser-proxy" "Tag" $imageTag) }} {{- if dig "scheduleOnlyOnInstrumentedNodes" "enabled" false (.Values.odiglet | default dict) }} - name: ODIGOS_INSTRUMENTED_PODS_NODE_LABEL_RETENTION value: {{ dig "scheduleOnlyOnInstrumentedNodes" "nodeLabelRetention" "5m" (.Values.odiglet | default dict) | quote }} diff --git a/instrumentor/controllers/agentenabled/browser_guard_test.go b/instrumentor/controllers/agentenabled/browser_guard_test.go new file mode 100644 index 0000000000..98c54ac82e --- /dev/null +++ b/instrumentor/controllers/agentenabled/browser_guard_test.go @@ -0,0 +1,86 @@ +package agentenabled + +import ( + "testing" + + "github.com/odigos-io/odigos/k8sutils/pkg/workload" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func deploymentWorkloadWithContainers(containers []corev1.Container) workload.Workload { + return &workload.DeploymentWorkload{ + Deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "web", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{Containers: containers}, + }, + }, + }, + } +} + +func TestContainerHasTCPPort(t *testing.T) { + tests := []struct { + name string + workloadObj workload.Workload + containerName string + want bool + }{ + { + name: "nil workload is treated as having a port (don't block here)", + workloadObj: nil, + containerName: "web", + want: true, + }, + { + name: "container with an implicit-TCP port", + workloadObj: deploymentWorkloadWithContainers([]corev1.Container{ + {Name: "web", Ports: []corev1.ContainerPort{{ContainerPort: 8080}}}, + }), + containerName: "web", + want: true, + }, + { + name: "container with an explicit TCP port", + workloadObj: deploymentWorkloadWithContainers([]corev1.Container{ + {Name: "web", Ports: []corev1.ContainerPort{{ContainerPort: 8080, Protocol: corev1.ProtocolTCP}}}, + }), + containerName: "web", + want: true, + }, + { + name: "container with no ports", + workloadObj: deploymentWorkloadWithContainers([]corev1.Container{ + {Name: "web"}, + }), + containerName: "web", + want: false, + }, + { + name: "container with only a UDP port", + workloadObj: deploymentWorkloadWithContainers([]corev1.Container{ + {Name: "web", Ports: []corev1.ContainerPort{{ContainerPort: 53, Protocol: corev1.ProtocolUDP}}}, + }), + containerName: "web", + want: false, + }, + { + name: "container not found in pod template is treated as having a port", + workloadObj: deploymentWorkloadWithContainers([]corev1.Container{ + {Name: "other", Ports: []corev1.ContainerPort{{ContainerPort: 8080}}}, + }), + containerName: "web", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, containerHasTCPPort(tt.workloadObj, tt.containerName)) + }) + } +} diff --git a/instrumentor/controllers/agentenabled/browser_proxy.go b/instrumentor/controllers/agentenabled/browser_proxy.go new file mode 100644 index 0000000000..68d177a214 --- /dev/null +++ b/instrumentor/controllers/agentenabled/browser_proxy.go @@ -0,0 +1,191 @@ +package agentenabled + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/odigos-io/odigos/api/k8sconsts" + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/common/consts" + "github.com/odigos-io/odigos/distros/distro" + "github.com/odigos-io/odigos/instrumentor/controllers/agentenabled/podswebhook" + "github.com/odigos-io/odigos/k8sutils/pkg/service" +) + +// Mesh sidecar container names that install their own iptables redirect. Co-injecting +// odigos-browser-proxy would race on the same NAT rules and break traffic. +var meshSidecarContainerNames = map[string]struct{}{ + "istio-proxy": {}, + "linkerd-proxy": {}, +} + +// injectBrowserProxy builds the odigos-browser-proxy sidecar and the iptables init container for a +// browser-instrumented web server container. Browser instrumentation does not run inside the pod, +// so the application container is left untouched: no env vars and no agent mounts are added to it. +// Instead the sidecar is placed in front of it (via the init container's iptables redirect) to +// inject the OpenTelemetry browser SDK <script> into served HTML and to proxy the browser's OTLP +// telemetry to the node-local collector. +// +// Returns the sidecar container, the init container, the agent directories that need to be copied +// (for the init-container mount method), and whether a pod volume mount is required. If the +// application container exposes no TCP port, injection is skipped (nil sidecar) since there is +// nothing to redirect. +func (p *PodsWebhook) injectBrowserProxy( + logger logr.Logger, + pod *corev1.Pod, + appContainer *corev1.Container, + namespace string, + serviceName string, + config common.OdigosConfiguration, + distroMetadata *distro.OtelDistro, +) (*corev1.Container, *corev1.Container, map[string]struct{}, bool, error) { + if mesh := meshSidecarInPod(pod); mesh != "" { + logger.Info("browser instrumentation: skipping browser-proxy injection because a service-mesh sidecar is present (iptables redirect would collide)", + "container", appContainer.Name, "meshSidecar", mesh) + return nil, nil, nil, false, nil + } + + appPort := firstTCPContainerPort(appContainer) + if appPort == 0 { + logger.Info("browser instrumentation: container has no TCP containerPort, skipping browser-proxy injection", + "container", appContainer.Name) + return nil, nil, nil, false, nil + } + + mountMethod := common.K8sVirtualDeviceMountMethod + if config.MountMethod != nil { + mountMethod = *config.MountMethod + } + + proxyImage := getBrowserProxyImage(config) + agentDirResolved := strings.ReplaceAll(distroMetadata.BrowserSidecar.AgentDirectory, distro.AgentPlaceholderDirectory, k8sconsts.OdigosAgentsDirectory) + + falsePtr := false + truePtr := true + runAsProxy := k8sconsts.BrowserProxyRunAsUser + runAsRoot := int64(0) + + // Same helper used by in-pod agents: on k8s >= 1.26 this resolves to the + // odigos-data-collection-local-traffic ClusterIP service (InternalTrafficPolicy=Local). + // On older clusters it falls back to http://$(NODE_IP):4318 — NODE_IP is injected below + // only so that fallback can expand. User pods do not need hostNetwork. + otlpEndpoint := service.LocalTrafficOTLPHttpDataCollectionEndpoint("$(NODE_IP)") + + probe := &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: k8sconsts.BrowserProxyHealthPath, + Port: intstr.FromInt32(int32(k8sconsts.BrowserProxyListenPort)), + }, + }, + InitialDelaySeconds: 1, + PeriodSeconds: 10, + TimeoutSeconds: 2, + FailureThreshold: 3, + } + + sidecar := corev1.Container{ + Name: k8sconsts.BrowserProxyContainerName, + Image: proxyImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Ports: []corev1.ContainerPort{ + {ContainerPort: int32(k8sconsts.BrowserProxyListenPort)}, + }, + Env: []corev1.EnvVar{ + // NODE_IP must come first so the OTLP endpoint env var can reference $(NODE_IP) + // when the LocalTraffic helper falls back to the node-IP form (k8s < 1.26). + { + Name: "NODE_IP", + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "status.hostIP"}}, + }, + {Name: k8sconsts.BrowserProxyUpstreamEnvVar, Value: fmt.Sprintf("http://127.0.0.1:%d", appPort)}, + {Name: k8sconsts.BrowserProxyListenAddrEnvVar, Value: fmt.Sprintf(":%d", k8sconsts.BrowserProxyListenPort)}, + {Name: k8sconsts.BrowserProxyAgentDirEnvVar, Value: agentDirResolved}, + {Name: k8sconsts.BrowserProxyAgentFileEnvVar, Value: distroMetadata.BrowserSidecar.AgentFileName}, + {Name: k8sconsts.BrowserProxyOtlpHttpEndpointEnvVar, Value: otlpEndpoint}, + {Name: k8sconsts.BrowserProxyServiceNameEnvVar, Value: serviceName}, + {Name: k8sconsts.BrowserProxyResourceAttributesEnvVar, Value: fmt.Sprintf("k8s.namespace.name=%s", namespace)}, + }, + LivenessProbe: probe, + ReadinessProbe: probe.DeepCopy(), + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsProxy, + AllowPrivilegeEscalation: &falsePtr, + Privileged: &falsePtr, + }, + } + + // Make the browser SDK bundle available to the sidecar, using the configured mount method. + dirsToCopy := make(map[string]struct{}) + volumeMounted := false + switch mountMethod { + case common.K8sHostPathMountMethod, common.K8sInitContainerMountMethod, common.K8sCsiDriverMountMethod: + podswebhook.MountDirectory(&sidecar, distroMetadata.BrowserSidecar.AgentDirectory) + dirsToCopy[distroMetadata.BrowserSidecar.AgentDirectory] = struct{}{} + volumeMounted = true + case common.K8sVirtualDeviceMountMethod: + podswebhook.InjectDeviceToContainer(&sidecar, k8sconsts.OdigosGenericDeviceName) + } + + // The init container installs the iptables redirect (inbound app port -> sidecar) before the + // application starts. It needs root + CAP_NET_ADMIN; the sidecar's own traffic (UID + // BrowserProxyRunAsUser) is excluded from the redirect so it can reach the app on loopback. + initContainer := corev1.Container{ + Name: k8sconsts.BrowserProxyInitContainerName, + Image: proxyImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: []string{"init"}, + Env: []corev1.EnvVar{ + {Name: k8sconsts.BrowserProxyAppPortEnvVar, Value: strconv.Itoa(int(appPort))}, + {Name: k8sconsts.BrowserProxyUidEnvVar, Value: strconv.FormatInt(k8sconsts.BrowserProxyRunAsUser, 10)}, + {Name: k8sconsts.BrowserProxyListenAddrEnvVar, Value: fmt.Sprintf(":%d", k8sconsts.BrowserProxyListenPort)}, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsRoot, + AllowPrivilegeEscalation: &truePtr, + Capabilities: &corev1.Capabilities{ + Add: []corev1.Capability{"NET_ADMIN"}, + }, + }, + } + + return &sidecar, &initContainer, dirsToCopy, volumeMounted, nil +} + +func firstTCPContainerPort(container *corev1.Container) int32 { + for _, port := range container.Ports { + if port.ContainerPort > 0 && (port.Protocol == "" || port.Protocol == corev1.ProtocolTCP) { + return port.ContainerPort + } + } + return 0 +} + +func meshSidecarInPod(pod *corev1.Pod) string { + for _, c := range pod.Spec.Containers { + if _, ok := meshSidecarContainerNames[c.Name]; ok { + return c.Name + } + } + for _, c := range pod.Spec.InitContainers { + if _, ok := meshSidecarContainerNames[c.Name]; ok { + return c.Name + } + } + return "" +} + +func getBrowserProxyImage(config common.OdigosConfiguration) string { + // In the installation/upgrade we set the image as env var, so prefer it when present. + if img, ok := os.LookupEnv(k8sconsts.OdigosBrowserProxyEnvVarName); ok { + return img + } + imageVersion := os.Getenv(consts.OdigosVersionEnvVarName) + return config.ImagePrefix + "/" + k8sconsts.OdigosBrowserProxyImage + ":" + imageVersion +} diff --git a/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go b/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go index 3c8944dc16..66217a00c8 100644 --- a/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go +++ b/instrumentor/controllers/agentenabled/distroresolver/distroresolver.go @@ -26,6 +26,14 @@ func resolveDistroByOverride(overwriteDistroName string, distroGetter *distros.G return distro, nil } + // Browser (web) distros are delivered by a sidecar in front of whatever web server runs in the + // pod. The frontend nature of a workload cannot be reliably auto-detected from the in-pod + // process, so browser instrumentation is opt-in via this override and must work regardless of + // the detected in-pod language (nginx, javascript/node static server, unknown, ...). + if distro.BrowserSidecar != nil { + return distro, nil + } + // verify the distro matches the language, since it might be overridden by the container override. if distro.Language != containerLanguage { return nil, &odigosv1.AgentDisabledInfo{ @@ -128,7 +136,10 @@ func ResolveDistroForContainer( } // check unknown language first. if language is not supported, we can skip the rest of the checks. - if runtimeDetails.Language == common.UnknownProgrammingLanguage { + // An explicit distro override is honored even when the language is unknown (e.g. opting a + // static-file web server into browser instrumentation); resolveDistroByOverride validates it. + hasDistroOverride := containerOverride != nil && containerOverride.OtelDistroName != nil + if runtimeDetails.Language == common.UnknownProgrammingLanguage && !hasDistroOverride { return nil, &odigosv1.AgentDisabledInfo{ AgentEnabledReason: odigosv1.AgentEnabledReason(agentInjectionEnabled.AgentEnabledReasonUnsupportedProgrammingLanguage), AgentEnabledMessage: agentInjectionEnabled.AgentEnabledUnsupportedProgrammingLanguage.Message, diff --git a/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go b/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go index c5353c4937..b00623bac9 100644 --- a/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go +++ b/instrumentor/controllers/agentenabled/distroresolver/distroresolver_test.go @@ -108,6 +108,53 @@ func TestResolveDistroForContainer_prereleaseRuntimeVersionAccepted(t *testing.T require.Equal(t, "golang-community", d.Name) } +func TestResolveDistroForContainer_browserOverrideAcceptsMismatchedContainerLanguage(t *testing.T) { + g := mustNewCommunityGetter(t) + overrideName := "browser-community" + config := &common.OdigosConfiguration{} + // A frontend served by a Node static server is detected as server-side javascript; the explicit + // browser override must still win since the frontend nature cannot be auto-detected. + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.JavascriptProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{common.JavascriptProgrammingLanguage: "nodejs-community"} + co := &odigosv1.ContainerOverride{OtelDistroName: &overrideName} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, co, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) + require.NotNil(t, d.BrowserSidecar, "browser-community must carry a browserSidecar marker") +} + +func TestResolveDistroForContainer_browserOverrideAcceptsUnknownLanguage(t *testing.T) { + g := mustNewCommunityGetter(t) + overrideName := "browser-community" + config := &common.OdigosConfiguration{} + // A plain static-file server may not be detected at all; an explicit browser override must + // still be honored (the unknown-language early return is skipped when an override is present). + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.UnknownProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{} + co := &odigosv1.ContainerOverride{OtelDistroName: &overrideName} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, co, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) +} + +func TestResolveDistroForContainer_browserByLanguageOverride(t *testing.T) { + g := mustNewCommunityGetter(t) + config := &common.OdigosConfiguration{} + // Opting in by setting the runtime language to browser (via containerOverride RuntimeInfo) + // resolves to the default browser distro without an explicit distro name. + rt := &odigosv1.RuntimeDetailsByContainer{Language: common.BrowserProgrammingLanguage} + dpl := map[common.ProgrammingLanguage]string{common.BrowserProgrammingLanguage: "browser-community"} + + d, info := ResolveDistroForContainer(config, rt, dpl, g, nil, "frontend") + require.Nil(t, info) + require.NotNil(t, d) + require.Equal(t, "browser-community", d.Name) +} + func TestResolveDistroForContainer_nonWildcardEnforcesRuntimeSemver(t *testing.T) { g := mustNewCommunityGetter(t) config := &common.OdigosConfiguration{} diff --git a/instrumentor/controllers/agentenabled/pods_webhook.go b/instrumentor/controllers/agentenabled/pods_webhook.go index 1ffd261ea8..5071cce9c5 100644 --- a/instrumentor/controllers/agentenabled/pods_webhook.go +++ b/instrumentor/controllers/agentenabled/pods_webhook.go @@ -178,6 +178,12 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm volumeMounted := false waspSupported := false + // Browser-instrumented containers get a sidecar (+iptables init container) injected instead of + // in-container env/agent mounts. We collect them here and append after the loop to avoid + // mutating pod.Spec.Containers while ranging over it. + var browserSidecars []corev1.Container + var browserInitContainers []corev1.Container + dirsToCopy := make(map[string]struct{}) for i := range pod.Spec.Containers { podContainerSpec := &pod.Spec.Containers[i] @@ -197,6 +203,25 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm return ErrUnknownDistroName } + // Browser distros are delivered by the odigos-browser-proxy sidecar; the application + // container is not modified (no env vars, no agent mount). + if distroMetadata.BrowserSidecar != nil { + sidecar, initContainer, browserDirsToCopy, browserVolumeMounted, berr := p.injectBrowserProxy( + logger.Logr(), pod, podContainerSpec, pw.Namespace, serviceName, odigosConfiguration, distroMetadata) + if berr != nil { + return berr + } + if sidecar != nil { + browserSidecars = append(browserSidecars, *sidecar) + if initContainer != nil { + browserInitContainers = append(browserInitContainers, *initContainer) + } + volumeMounted = volumeMounted || browserVolumeMounted + dirsToCopy = mergeMaps(dirsToCopy, browserDirsToCopy) + } + continue + } + containerVolumeMounted, containerDirsToCopy, err := p.injectOdigosToContainer( containerConfig, podContainerSpec, &ic, *pw, serviceName, odigosConfiguration, distroMetadata, pod.OwnerReferences) if err != nil { @@ -211,6 +236,18 @@ func (p *PodsWebhook) injectOdigos(ctx context.Context, pod *corev1.Pod, req adm dirsToCopy = mergeMaps(dirsToCopy, containerDirsToCopy) } + // Append the collected browser-proxy sidecar(s) and iptables init container(s). + for i := range browserInitContainers { + if !containerNameExists(pod.Spec.InitContainers, browserInitContainers[i].Name) { + pod.Spec.InitContainers = append(pod.Spec.InitContainers, browserInitContainers[i]) + } + } + for i := range browserSidecars { + if !containerNameExists(pod.Spec.Containers, browserSidecars[i].Name) { + pod.Spec.Containers = append(pod.Spec.Containers, browserSidecars[i]) + } + } + if mountMethod == common.K8sHostPathMountMethod && volumeMounted { // only mount the volume if at least one container has a volume to mount podswebhook.MountPodVolumeToHostPath(pod) @@ -261,6 +298,15 @@ func mergeMaps[T any](a, b map[string]T) map[string]T { return a } +func containerNameExists(containers []corev1.Container, name string) bool { + for i := range containers { + if containers[i].Name == name { + return true + } + } + return false +} + func (p *PodsWebhook) podWorkload(pod *corev1.Pod, req admission.Request) (*k8sconsts.PodWorkload, error) { pw, err := workload.PodWorkloadObject(pod) if err != nil { @@ -308,6 +354,12 @@ func (p *PodsWebhook) injectOdigosInstrumentation(ctx context.Context, pod *core continue } + // Browser distros do not run an in-pod agent, so there are no agent env vars to inject into + // the application container; the odigos-browser-proxy sidecar handles delivery instead. + if otelDistro.BrowserSidecar != nil { + continue + } + err := webhookenvinjector.InjectOdigosAgentEnvVars(ctx, logger.Logr(), container, otelDistro, runtimeDetails, config) if err != nil { return err diff --git a/instrumentor/controllers/agentenabled/sync.go b/instrumentor/controllers/agentenabled/sync.go index 4bcce8bc12..a6786fd74f 100644 --- a/instrumentor/controllers/agentenabled/sync.go +++ b/instrumentor/controllers/agentenabled/sync.go @@ -269,6 +269,21 @@ func updateInstrumentationConfigSpec(ctx context.Context, c client.Client, pw k8 continue } + // Browser instrumentation is delivered by the odigos-browser-proxy sidecar, which transparently + // redirects inbound traffic to the app container's TCP port. If the app container declares no TCP + // containerPort, the sidecar cannot be wired (the webhook silently skips injection), so surface a + // clear disabled reason instead of leaving the user wondering why nothing happened. + if containerDistro.BrowserSidecar != nil && !containerHasTCPPort(workloadObj, containerName) { + containersConfig = append(containersConfig, odigosv1.ContainerAgentConfig{ + ContainerName: containerName, + AgentEnabled: false, + AgentEnabledReason: odigosv1.AgentEnabledReasonBrowserPortMissing, + AgentEnabledMessage: "browser instrumentation requires a TCP containerPort on the app container so the odigos-browser-proxy sidecar can be wired; add a containerPort and rollout restart the workload", + OtelDistroName: containerDistro.Name, + }) + continue + } + // calculate and verify there are enabled signals for this container. enabledSignals, disabledInfo := signals.GetEnabledSignalsForContainer(nodeCollectorsGroup, &rulesForContainer) if disabledInfo != nil { @@ -510,6 +525,27 @@ func getEnvInjectionDecision( return &envInjectionDecision, nil } +// containerHasTCPPort reports whether the named container in the workload's pod template declares a +// usable TCP containerPort. It is used to guard browser instrumentation, whose sidecar can only be +// wired when the app container exposes a TCP port. When the pod template or container cannot be +// resolved, it returns true so we don't block instrumentation here (the webhook still skips +// injection defensively if no port is present). +func containerHasTCPPort(workloadObj workload.Workload, containerName string) bool { + if workloadObj == nil { + return true + } + podSpec := workloadObj.PodSpec() + if podSpec == nil { + return true + } + for i := range podSpec.Containers { + if podSpec.Containers[i].Name == containerName { + return firstTCPContainerPort(&podSpec.Containers[i]) != 0 + } + } + return true +} + func calculateContainerAgentConfig(containerName string, d *distro.OtelDistro, effectiveConfig *common.OdigosConfiguration, diff --git a/k8sutils/pkg/cache/strip.go b/k8sutils/pkg/cache/strip.go index bc20754607..af34aa158f 100644 --- a/k8sutils/pkg/cache/strip.go +++ b/k8sutils/pkg/cache/strip.go @@ -61,13 +61,16 @@ func StripWorkloadSpecTemplate(o client.Object) { if len(currentContainers) > 0 { minimalContainers := make([]v1.Container, len(currentContainers)) for i := range currentContainers { - // for each container keep only its name and probes - // probes are used for auto-head-sampling feature + // for each container keep only its name, probes and ports. + // probes are used for auto-head-sampling feature. + // ports are used to decide whether browser instrumentation can be wired + // (the odigos-browser-proxy sidecar requires a TCP containerPort on the app container). minimalContainers[i] = v1.Container{ Name: currentContainers[i].Name, StartupProbe: currentContainers[i].StartupProbe, LivenessProbe: currentContainers[i].LivenessProbe, ReadinessProbe: currentContainers[i].ReadinessProbe, + Ports: currentContainers[i].Ports, } } template.Spec = v1.PodSpec{ diff --git a/k8sutils/pkg/cache/strip_test.go b/k8sutils/pkg/cache/strip_test.go index 9cd985baa2..2959274099 100644 --- a/k8sutils/pkg/cache/strip_test.go +++ b/k8sutils/pkg/cache/strip_test.go @@ -112,7 +112,9 @@ func assertStrippedContainers(t *testing.T, containers []v1.Container) { assert.Nil(t, c.Command) assert.Nil(t, c.Args) assert.Nil(t, c.Env) - assert.Nil(t, c.Ports) + // ports are intentionally preserved: the browser instrumentation port guard + // reads them from the cached workload to decide if the proxy sidecar can be wired. + assert.Equal(t, []v1.ContainerPort{{ContainerPort: 80}}, c.Ports) assert.Empty(t, c.Resources.Limits) assert.Empty(t, c.Resources.Requests) assert.Nil(t, c.VolumeMounts) diff --git a/odiglet/Dockerfile b/odiglet/Dockerfile index a0dbb89f60..135f42ca85 100644 --- a/odiglet/Dockerfile +++ b/odiglet/Dockerfile @@ -78,6 +78,11 @@ COPY --from=public.ecr.aws/odigos/agents/php-community:v0.7.0@sha256:b3ed955a65f # ruby-community COPY --from=public.ecr.aws/odigos/agents/ruby-community:v0.0.9@sha256:5177737aba83e507ee968223fbd821d76e2df4e74bd1711e2d49e8d59f671d8c /instrumentations/ruby /instrumentations/ruby +# browser-community +# The browser SDK bundle (agent.js) is served to end-user browsers by the odigos-browser-proxy +# sidecar, which reads it from /var/odigos/browser on the node. +COPY --from=public.ecr.aws/odigos/agents/browser-community:v0.3.0 /instrumentations/browser /instrumentations/browser + # loader ARG TARGETARCH ARG ODIGOS_LOADER_VERSION=v0.0.8 diff --git a/odiglet/agent-deps.mk b/odiglet/agent-deps.mk index 8f0256914c..5ee4b294cd 100644 --- a/odiglet/agent-deps.mk +++ b/odiglet/agent-deps.mk @@ -21,6 +21,8 @@ upgrade-agent: $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=ruby-community AGENT_VERSION=$(AGENT_VERSION) ;; \ nodejs|nodejs-community) \ $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=nodejs-community AGENT_VERSION=$(AGENT_VERSION) ;; \ + browser|browser-community) \ + $(MAKE) -f $(MK) upgrade-image-agent-version AGENT_DISTRO=browser-community AGENT_VERSION=$(AGENT_VERSION) ;; \ python|python-community) \ $(MAKE) -f $(MK) upgrade-python-community-version AGENT_VERSION=$(AGENT_VERSION) ;; \ *) \ diff --git a/odiglet/debug.Dockerfile b/odiglet/debug.Dockerfile index c766b6da18..7681caa806 100644 --- a/odiglet/debug.Dockerfile +++ b/odiglet/debug.Dockerfile @@ -99,6 +99,9 @@ COPY --from=public.ecr.aws/odigos/agents/php-community:v0.7.0@sha256:b3ed955a65f # ruby-community COPY --from=public.ecr.aws/odigos/agents/ruby-community:v0.0.9@sha256:5177737aba83e507ee968223fbd821d76e2df4e74bd1711e2d49e8d59f671d8c /instrumentations/ruby /instrumentations/ruby +# browser-community +COPY --from=public.ecr.aws/odigos/agents/browser-community:v0.3.0 /instrumentations/browser /instrumentations/browser + # loader ARG ODIGOS_LOADER_VERSION=v0.0.8 RUN wget --directory-prefix=loader https://storage.googleapis.com/odigos-loader/$ODIGOS_LOADER_VERSION/$TARGETARCH/loader.so diff --git a/tests/e2e/environment-variables/assert-apps-ready.yaml b/tests/e2e/environment-variables/assert-apps-ready.yaml index 5f42d3bee2..981da343a2 100644 --- a/tests/e2e/environment-variables/assert-apps-ready.yaml +++ b/tests/e2e/environment-variables/assert-apps-ready.yaml @@ -11,7 +11,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -27,7 +26,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -43,7 +41,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -59,7 +56,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -84,7 +80,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- diff --git a/tests/e2e/log-collection/assert-logs-pipeline.yaml b/tests/e2e/log-collection/assert-logs-pipeline.yaml index 7be0e87fef..e928da34ba 100644 --- a/tests/e2e/log-collection/assert-logs-pipeline.yaml +++ b/tests/e2e/log-collection/assert-logs-pipeline.yaml @@ -1,10 +1,12 @@ # The node collector only builds a logs pipeline once the cluster collector reports that a # destination receives logs, so this waits for the logs config domain to exist and to be using the -# filelog receiver. pipeline-ready.yaml does not cover this: it asserts the collector groups are -# ready and their config maps are non-empty, not which signals are configured. +# filelog receiver plus otlp/in (OTLP logs from agents / browser-proxy). pipeline-ready.yaml does +# not cover this: it asserts the collector groups are ready and their config maps are non-empty, +# not which signals are configured. apiVersion: v1 kind: ConfigMap metadata: name: odigos-node-collector-config-domains namespace: odigos-test (contains(data.logs, 'filelog')): true +(contains(data.logs, 'otlp/in')): true diff --git a/tests/e2e/runtime-detection/assert-apps-ready.yaml b/tests/e2e/runtime-detection/assert-apps-ready.yaml index 10ccb5f66e..d044dfcc71 100644 --- a/tests/e2e/runtime-detection/assert-apps-ready.yaml +++ b/tests/e2e/runtime-detection/assert-apps-ready.yaml @@ -10,7 +10,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -28,7 +27,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -44,7 +42,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -60,7 +57,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -76,7 +72,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -92,7 +87,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -109,7 +103,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -125,7 +118,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -141,7 +133,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -157,7 +148,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -173,7 +163,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -189,7 +178,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -212,7 +200,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -237,7 +224,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -260,7 +246,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -276,7 +261,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -292,7 +276,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- @@ -308,7 +291,6 @@ status: - type: Progressing status: "True" readyReplicas: 1 - replicas: 1 updatedReplicas: 1 availableReplicas: 1 --- diff --git a/tests/e2e/source/01-browser-frontend.yaml b/tests/e2e/source/01-browser-frontend.yaml new file mode 100644 index 0000000000..c36794cd6a --- /dev/null +++ b/tests/e2e/source/01-browser-frontend.yaml @@ -0,0 +1,59 @@ +# Minimal nginx front-end used to exercise browser-community sidecar injection +# alongside the existing simple-demo workloads in this e2e scenario. +apiVersion: v1 +kind: ConfigMap +metadata: + name: browser-frontend-html + namespace: default +data: + index.html: | + <!doctype html> + <html> + <head> + <title>Odigos Browser Demo + + +

Odigos Browser Instrumentation Demo

+ + + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: browser-frontend + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: browser-frontend + template: + metadata: + labels: + app: browser-frontend + spec: + containers: + - name: browser-frontend + image: nginx:1.27-alpine + ports: + - containerPort: 80 + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + volumes: + - name: html + configMap: + name: browser-frontend-html +--- +apiVersion: v1 +kind: Service +metadata: + name: browser-frontend + namespace: default +spec: + selector: + app: browser-frontend + ports: + - port: 80 + targetPort: 80 diff --git a/tests/e2e/source/01-browser-sidecar-injected.yaml b/tests/e2e/source/01-browser-sidecar-injected.yaml new file mode 100644 index 0000000000..e1275948aa --- /dev/null +++ b/tests/e2e/source/01-browser-sidecar-injected.yaml @@ -0,0 +1,27 @@ +# Asserts that the webhook injected the browser-proxy sidecar and the iptables redirect init +# container into the browser-frontend pod. Chainsaw matches container slice length/order, so the +# app container must be listed first (as injected by the Deployment), then the sidecar. +apiVersion: v1 +kind: Pod +metadata: + namespace: default + labels: + app: browser-frontend +spec: + initContainers: + - name: odigos-browser-proxy-init + args: + - init + securityContext: + capabilities: + add: + - NET_ADMIN + containers: + - name: browser-frontend + - name: odigos-browser-proxy + ports: + - containerPort: 15001 + securityContext: + runAsUser: 1337 +status: + phase: Running diff --git a/tests/e2e/source/01-sources.yaml b/tests/e2e/source/01-sources.yaml index 325b154d92..55f3fb7877 100644 --- a/tests/e2e/source/01-sources.yaml +++ b/tests/e2e/source/01-sources.yaml @@ -112,3 +112,22 @@ spec: containerOverrides: - containerName: shipping otelDistroName: opentelemetry-ebpf-instrumentation +--- +apiVersion: odigos.io/v1alpha1 +kind: Source +metadata: + name: browser-frontend + namespace: default + labels: + odigos.io/e2e: source +spec: + workload: + name: browser-frontend + namespace: default + kind: Deployment + otelServiceName: browser-frontend-reported + # Browser instrumentation is opt-in: explicitly select the browser distribution on the serving + # container. Odigos then injects the odigos-browser-proxy sidecar instead of an in-pod agent. + containerOverrides: + - containerName: browser-frontend + otelDistroName: browser-community diff --git a/tests/e2e/source/01-workloads.yaml b/tests/e2e/source/01-workloads.yaml index f8cc4ffdcf..c5df001115 100644 --- a/tests/e2e/source/01-workloads.yaml +++ b/tests/e2e/source/01-workloads.yaml @@ -55,3 +55,10 @@ metadata: generation: 1 name: shipping namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 2 + name: browser-frontend + namespace: default diff --git a/tests/e2e/source/02-workloads.yaml b/tests/e2e/source/02-workloads.yaml index db69ac71ae..e03c69ed57 100644 --- a/tests/e2e/source/02-workloads.yaml +++ b/tests/e2e/source/02-workloads.yaml @@ -55,3 +55,10 @@ metadata: generation: 1 name: shipping namespace: default +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + generation: 3 + name: browser-frontend + namespace: default diff --git a/tests/e2e/source/README.md b/tests/e2e/source/README.md index 919d7a6555..c01a4d39b9 100644 --- a/tests/e2e/source/README.md +++ b/tests/e2e/source/README.md @@ -7,7 +7,8 @@ It has the following phases: 1. **Setup** - Install Odigos, simple-trace-db, and the Demo app ([simple-demo](https://github.com/odigos-io/simple-demo) `v0.1.36`). The demo app now includes the C++ `shipping` service, which is instrumented in this test via the OBI - container override. + container override. A small nginx `browser-frontend` workload is also installed for + browser-community sidecar injection. 2. **Workload instrumentation** - Create a Source for each individual workload, include a reported for each source. Add simple-trace-db as a destination. Verify: 1. InstrumentationConfigs are created for each deployment @@ -20,13 +21,17 @@ It has the following phases: not require a pod restart, `shipping`'s generation remains at `1` and its `InstrumentationConfig` is asserted to report `otelDistroName: opentelemetry-ebpf-instrumentation` together with `language: cplusplus`. - 6. Generated traffic to the frontend's `/buy` endpoint fans out to the C++ `shipping` + 6. The `browser-frontend` Source uses a `containerOverrides` entry that selects + `browser-community`, which injects the hardened `odigos-browser-proxy` sidecar (CSP-safe + script injection + authenticated OTLP relay) and iptables + redirect init container (verified via `01-browser-sidecar-injected.yaml`) + 7. Generated traffic to the frontend's `/buy` endpoint fans out to the C++ `shipping` service (via `SHIPPING_SERVICE_HOST`) and produces server spans observable through OBI (verified via `wait-for-shipping-trace.yaml`) - 7. Context propagation works across deployments (service name is identical to the one configured by the Source) - 8. Resource attributes are present - 9. Span attributes are present - 10. Collector metrics are collected by UI + 8. Context propagation works across deployments (service name is identical to the one configured by the Source) + 9. Resource attributes are present + 10. Span attributes are present + 11. Collector metrics are collected by UI 3. **Workload uninstrumentation** - Delete all Source objects for deployments. Verify: 1. Workloads roll out a new (uninstrumented) revision (except `shipping`, which stays at diff --git a/tests/e2e/source/chainsaw-test.yaml b/tests/e2e/source/chainsaw-test.yaml index 232f101af3..0837ec30e7 100644 --- a/tests/e2e/source/chainsaw-test.yaml +++ b/tests/e2e/source/chainsaw-test.yaml @@ -50,6 +50,11 @@ spec: timeout: 1m10s file: ../../common/assert/simple-demo-installed.yaml + - name: '[1 - Setup] Install browser front-end workload' + try: + - apply: + file: 01-browser-frontend.yaml + - name: '[1 - Setup] Add Destination' try: - apply: @@ -96,6 +101,16 @@ spec: name: odiglet namespace: odigos-test + - name: '[2 - Workload Instrumentation] Browser front-end injects browser-proxy sidecar' + try: + - assert: + timeout: 1m30s + file: 01-browser-sidecar-injected.yaml + catch: + - podLogs: + name: instrumentor + namespace: odigos-test + - name: '[2 - Workload Instrumentation] Generate Traffic' try: - apply: diff --git a/tests/e2e/trace-collection/assert-apps-ready.yaml b/tests/e2e/trace-collection/assert-apps-ready.yaml index 481d11c3f4..517994ec9c 100644 --- a/tests/e2e/trace-collection/assert-apps-ready.yaml +++ b/tests/e2e/trace-collection/assert-apps-ready.yaml @@ -7,12 +7,10 @@ metadata: name: nodejs-minimum-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -23,12 +21,10 @@ metadata: name: nodejs-latest-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -39,12 +35,10 @@ metadata: name: nodejs-dockerfile-env namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -55,12 +49,10 @@ metadata: name: nodejs-manifest-env namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -71,12 +63,10 @@ metadata: name: java-supported-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -87,12 +77,10 @@ metadata: name: java-azul namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -103,12 +91,10 @@ metadata: name: java-supported-docker-env namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -119,12 +105,10 @@ metadata: name: java-supported-manifest-env namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -135,12 +119,10 @@ metadata: name: java-latest-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -151,12 +133,10 @@ metadata: name: java-old-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -167,12 +147,10 @@ metadata: name: java-unique-exec namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -183,12 +161,10 @@ metadata: name: python-latest-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -199,12 +175,10 @@ metadata: name: python-alpine namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -215,12 +189,10 @@ metadata: name: python-min-version namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -231,12 +203,10 @@ metadata: name: python-gunicorn-server namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -247,12 +217,10 @@ metadata: name: dotnet8-musl namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -263,12 +231,10 @@ metadata: name: dotnet6-musl namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -279,12 +245,10 @@ metadata: name: dotnet8-glibc namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1 @@ -295,12 +259,10 @@ metadata: name: dotnet6-glibc namespace: default status: - conditions: - - type: Available - status: "True" - - type: Progressing - status: "True" - replicas: 1 + (conditions[?type == 'Available']): + - status: "True" + (conditions[?type == 'Progressing']): + - status: "True" readyReplicas: 1 availableReplicas: 1 updatedReplicas: 1