From b414a6653b545b9a3ac498671f9f2f783b493b9c Mon Sep 17 00:00:00 2001 From: tdakkota Date: Tue, 18 Aug 2026 12:12:56 +0300 Subject: [PATCH 1/2] feat(api): add opt-in spec.ingest.tenant write routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors odbingest's tenant block (header, resource_attributes, default, require) onto the CRD, and validates what odbingest would otherwise crash-loop on at startup. It stays off by default, and the field docs say why: oteldb's read path is still pinned to one tenant — the query Backend's tenant id is never assigned, so every query resolves "default" (oteldb#820). Enabling ingest-side tenancy today routes writes into tenants nothing can query. Absent renders no tenant block at all, byte-identical to today. --- README.md | 39 +++++- api/v1alpha1/oteldbcluster_types.go | 61 ++++++++- internal/controller/ingest.go | 106 +++++++++++++++ internal/controller/ingest_tenant_test.go | 155 ++++++++++++++++++++++ internal/controller/naming.go | 9 ++ 5 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 internal/controller/ingest_tenant_test.go diff --git a/README.md b/README.md index 39bd288..68a7df7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,39 @@ operator renders all three roles from the same `spec.cluster`, so the mismatch c > `ghcr.io/oteldb/oteldb` tag contains either one. Set `spec.ingest.image` / `spec.query.image` to a > build that ships them until that is fixed upstream. +#### Ingest tenancy + +`spec.ingest.tenant` maps onto odbingest's `tenant` block +([oteldb/oteldb#1280](https://github.com/oteldb/oteldb/pull/1280)) and resolves the tenant a write +routes to. The sources compose narrowest-first — `header` (a whole request) beats +`resourceAttributes` (one resource within it) beats `default`: + +```yaml +spec: + ingest: + tenant: + header: X-Scope-OrgID # what Grafana-stack senders use + resourceAttributes: [service.namespace] + default: shared + require: false # 400 a request with no header instead of using default +``` + +> **Leave it absent unless you are running a deliberate migration.** oteldb's *read* path is still +> pinned to one tenant: the query `Backend`'s tenant id is never assigned, so every PromQL, LogQL, +> TraceQL and Pyroscope query resolves `"default"` +> ([oteldb/oteldb#820](https://github.com/oteldb/oteldb/issues/820)). Turning tenant resolution on +> at ingest today routes writes into tenants **nothing can query** — the data is stored, under a +> different shard key, and is invisible until the read path catches up. The field exists so the +> deployment is configurable ahead of that, not because it is ready. + +Absent renders no `tenant` block at all, which is byte-identical to the config an ingest pool got +before this field existed: odbingest installs no resolver and every write lands in `"default"`. + +Because a tenant id picks the shard key, which picks the ring owners, changing how tenants resolve +moves where data lands — a migration, not a config tweak. The operator validates what odbingest +would otherwise crash-loop on (`require` without `header`, an empty attribute key, a `default` that +is not a legal tenant id, a block naming no source at all) and reports it as `Degraded`/`InvalidSpec`. + #### The query pool's shape `odbingest` serves OTLP/HTTP, remote write and its health endpoints on one listener. `odbselect` @@ -105,7 +138,8 @@ for a fuller example including the S3 backend. | Field | Purpose | |---|---| | `replicas` | Number of oteldb storage nodes (StatefulSet size). Use `>= cluster.replicationFactor`. | -| `ingest` | Optional stateless `odbingest` write pool (Deployment). Absent ⇒ symmetric nodes handle ingest. Takes `replicas` (default 2), `image`, `service`, `extraConfig` and the standard scheduling/security knobs. | +| `ingest` | Optional stateless `odbingest` write pool (Deployment). Absent ⇒ symmetric nodes handle ingest. Takes `replicas` (default 2), `image`, `service`, `tenant`, `extraConfig` and the standard scheduling/security knobs. | +| `ingest.tenant` | Opt-in multi-tenant write routing: `{header, resourceAttributes, default, require}`. **Absent by default, and that is the recommended setting** — see [Ingest tenancy](#ingest-tenancy). | | `query` | Optional stateless `odbselect` query pool (Deployment). Absent ⇒ symmetric nodes answer queries. Same knobs as `ingest`. Which APIs it serves follows `spec.signals`. | | `image` / `imagePullPolicy` / `imagePullSecrets` | oteldb container image (default `ghcr.io/oteldb/oteldb:v0.46.0`). | | `etcd.endpoints` | **Required.** External etcd endpoint list. | @@ -199,7 +233,8 @@ spec field to use instead. `storage.policy` is now modelled in full, so the whole block is reserved. `spec.ingest.extraConfig` is merged over the generated `odbingest.yml` under the same rules, with -the whole `cluster` block reserved for `spec.cluster` and `spec.etcd.endpoints`. +the whole `cluster` block reserved for `spec.cluster` and `spec.etcd.endpoints`, and the whole +`tenant` block reserved for `spec.ingest.tenant`. `spec.query.extraConfig` is merged over the generated `odbselect.yml`, reserving `cluster` and the five listener addresses (`prometheus.bind`, `loki.bind`, `tempo.bind`, `pyroscope.bind`, diff --git a/api/v1alpha1/oteldbcluster_types.go b/api/v1alpha1/oteldbcluster_types.go index 835cfa5..295c3b4 100644 --- a/api/v1alpha1/oteldbcluster_types.go +++ b/api/v1alpha1/oteldbcluster_types.go @@ -190,6 +190,9 @@ type EtcdSpec struct { // mismatch there does not fail — it resolves a different owner set than the nodes do, and writes // land where no read will look for them — so it is deliberately not configurable per pool. // +// Writes route to the "default" tenant unless Tenant opts the pool into tenant resolution; see the +// warning on that field before doing so. +// // The odbingest binary is not yet shipped in the released oteldb image (it is absent from oteldb's // goreleaser builds and release Dockerfile). Until it is, set Image to a build that contains it. type IngestSpec struct { @@ -251,17 +254,71 @@ type IngestSpec struct { // +optional SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"` + // Tenant opts the ingest pool into multi-tenant write routing. Absent — the default — routes + // every write to the "default" tenant, exactly as an unconfigured odbingest does, and renders + // no tenant block at all. + // + // Leave it absent unless you are running a deliberate migration. oteldb's *read* path is still + // pinned to a single tenant: the query Backend's tenant id is never assigned, so every PromQL, + // LogQL, TraceQL and Pyroscope query resolves "default" (oteldb/oteldb#820). Enabling tenant + // resolution here therefore routes writes into tenants that nothing can currently query — the + // data is stored, under a different shard key, and is invisible until the read path catches up. + // The field exists so the deployment is configurable ahead of that, not because it is ready. + // +optional + Tenant *TenantSpec `json:"tenant,omitempty"` + // ExtraConfig is arbitrary additional odbingest config deeply merged over the generated // config, as a top-level YAML/JSON object. Use it to set fields the CRD does not model, such as // prometheus_remote_write.time_threshold or the OTLP body-size limits. // - // The cluster block is reserved and rejected instead of merged: it must stay in step with the - // storage nodes. Configure it through spec.etcd and spec.cluster. + // The cluster and tenant blocks are reserved and rejected instead of merged: cluster must stay + // in step with the storage nodes, and tenant is modelled in full. Configure them through + // spec.etcd, spec.cluster and spec.ingest.tenant. // +optional // +kubebuilder:pruning:PreserveUnknownFields ExtraConfig *runtime.RawExtension `json:"extraConfig,omitempty"` } +// TenantSpec configures which tenant an ingested write routes to, mapping onto odbingest's tenant +// config block. +// +// The sources compose narrowest-first: Header names the tenant of a whole request and wins, then +// ResourceAttributes names the tenant of one resource within it, and Default backs both. A header +// beats an attribute because a gateway that authenticated the sender sets the header, whereas an +// attribute is whatever the sender put in its own payload. +// +// A tenant id picks the shard key, which picks the ring owners, so changing how tenants resolve +// moves where data lands. That makes enabling this a migration rather than a config tweak — and, +// while oteldb's read path is single-tenant (see IngestSpec.Tenant), a one-way one. +type TenantSpec struct { + // Header is the request header — and OTLP/gRPC metadata key — carrying the tenant. Empty does + // not read a header. "X-Scope-OrgID" is what Grafana-stack senders (Loki, Mimir, and their + // Grafana datasources) put it in. + // +optional + Header string `json:"header,omitempty"` + + // ResourceAttributes are OTLP resource attribute keys, read in order until one holds a usable + // tenant. Empty does not read resource attributes. "service.namespace" is the OTel-native + // candidate. + // + // A missing, non-string or malformed value falls back to Default rather than failing the batch: + // by the time a resource is framed, the request can no longer be answered with an error. + // +optional + // +listType=atomic + ResourceAttributes []string `json:"resourceAttributes,omitempty"` + + // Default is the tenant a write routes to when no source names one. Empty uses odbingest's own + // default tenant, "default", which is where every write goes today. + // +optional + Default string `json:"default,omitempty"` + + // Require refuses a request that does not carry Header with 400 (InvalidArgument over gRPC), + // instead of routing it to Default. Use it when two senders must not be able to land in one + // shared tenant by omitting the header. It needs Header to be set. + // +optional + Require bool `json:"require,omitempty"` +} + // QuerySpec configures the stateless odbselect query pool: a Deployment of nodes that serve the // PromQL, LogQL, TraceQL and Pyroscope APIs by reading through the ring instead of local storage. // diff --git a/internal/controller/ingest.go b/internal/controller/ingest.go index e7cccc8..44493c2 100644 --- a/internal/controller/ingest.go +++ b/internal/controller/ingest.go @@ -35,6 +35,7 @@ import ( // set, so writes land where no read will look for them. var ingestReservedConfigPaths = map[string]string{ keyCluster: hintRingFromSpec, + keyTenant: "use spec.ingest.tenant", } func ingestEnabled(cr *dbv1alpha1.OtelDBCluster) bool { return cr.Spec.Ingest != nil } @@ -52,6 +53,10 @@ func ingestReplicasOf(cr *dbv1alpha1.OtelDBCluster) int32 { // renderIngestConfig builds odbingest.yml. odbingest holds no data and joins nothing, so the whole // config is the ring it routes into plus the two listeners it serves. func renderIngestConfig(cr *dbv1alpha1.OtelDBCluster, etcdEndpoints []string) (string, error) { + if err := validateIngestTenant(cr); err != nil { + return "", err + } + cfg := map[string]any{ keyCluster: ringConfig(cr, etcdEndpoints), "prometheus_remote_write": map[string]any{ @@ -62,6 +67,10 @@ func renderIngestConfig(cr *dbv1alpha1.OtelDBCluster, etcdEndpoints []string) (s }, } + if tenant := renderTenant(cr.Spec.Ingest.Tenant); tenant != nil { + cfg[keyTenant] = tenant + } + if raw := cr.Spec.Ingest.ExtraConfig; raw != nil && len(raw.Raw) > 0 { var extra map[string]any if err := json.Unmarshal(raw.Raw, &extra); err != nil { @@ -233,3 +242,100 @@ func buildIngestDeployment(cr *dbv1alpha1.OtelDBCluster, configHash string) *app } func ingestProbe(path string) *corev1.Probe { return roleProbe(path, portNameIngestHTTP) } + +// renderTenant builds odbingest's tenant block, or nil when the spec configures nothing. odbingest +// installs no resolver for an empty block, which is the unconfigured behaviour: everything routes +// to "default". +func renderTenant(spec *dbv1alpha1.TenantSpec) map[string]any { + if spec == nil { + return nil + } + + tenant := map[string]any{} + if spec.Header != "" { + tenant[keyTenantHeader] = spec.Header + } + if len(spec.ResourceAttributes) > 0 { + attrs := make([]any, 0, len(spec.ResourceAttributes)) + for _, k := range spec.ResourceAttributes { + attrs = append(attrs, k) + } + tenant[keyTenantResourceAttr] = attrs + } + if spec.Default != "" { + tenant[keyTenantDefault] = spec.Default + } + // require is only meaningful alongside a header, which validateIngestTenant enforces, so an + // unset one is left out rather than rendered as an explicit false. + if spec.Require { + tenant[keyTenantRequire] = true + } + + if len(tenant) == 0 { + return nil + } + return tenant +} + +// maxTenantLen bounds a tenant id, mirroring cmd/odbingest. A tenant id becomes a shard key, which +// becomes a backend path segment and an etcd key component. +const maxTenantLen = 150 + +// validateIngestTenant rejects a tenant block odbingest would refuse at startup, or that would +// resolve to nothing. Every check mirrors cmd/odbingest's newTenantResolver and parseTenantID: a +// pod that crash-loops on its config is a worse report than a Degraded condition naming the field. +func validateIngestTenant(cr *dbv1alpha1.OtelDBCluster) error { + if !ingestEnabled(cr) { + return nil + } + spec := cr.Spec.Ingest.Tenant + if spec == nil { + return nil + } + + if spec.Require && spec.Header == "" { + return invalidSpec("spec.ingest.tenant.require needs spec.ingest.tenant.header: " + + "there is no header to require") + } + for i, key := range spec.ResourceAttributes { + if key == "" { + return invalidSpec("spec.ingest.tenant.resourceAttributes[%d] must not be empty", i) + } + } + if spec.Default != "" { + if err := validateTenantID(spec.Default); err != nil { + return invalidSpec("spec.ingest.tenant.default: %s", err) + } + } + + // A block that names no source resolves nothing: odbingest builds no resolver and routes to + // "default", so the stanza is present and inert. + if spec.Header == "" && spec.Default == "" && len(spec.ResourceAttributes) == 0 { + return invalidSpec("spec.ingest.tenant sets no source: give it a header, " + + "resourceAttributes or a default, or remove the block") + } + + return nil +} + +// validateTenantID mirrors cmd/odbingest's parseTenantID. The character set is what stays safe as a +// backend path segment and an etcd key. +func validateTenantID(s string) error { + switch { + case len(s) > maxTenantLen: + return fmt.Errorf("tenant id longer than %d bytes", maxTenantLen) + case s == ".", s == "..": + return fmt.Errorf("invalid tenant id %q", s) + } + for _, c := range []byte(s) { + switch { + case c >= 'a' && c <= 'z', + c >= 'A' && c <= 'Z', + c >= '0' && c <= '9', + c == '-', c == '_', c == '.': + default: + return fmt.Errorf("invalid character %q in tenant id", string(c)) + } + } + return nil +} diff --git a/internal/controller/ingest_tenant_test.go b/internal/controller/ingest_tenant_test.go new file mode 100644 index 0000000..4a15402 --- /dev/null +++ b/internal/controller/ingest_tenant_test.go @@ -0,0 +1,155 @@ +/* +Copyright 2026. + +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. +*/ + +package controller + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + + dbv1alpha1 "github.com/oteldb/operator/api/v1alpha1" +) + +// The default must stay today's behaviour: no tenant block, so odbingest installs no resolver and +// every write lands in "default" — the one tenant oteldb's read path can currently query. +func TestRenderIngestConfigTenantAbsentByDefault(t *testing.T) { + cfg := renderedIngest(t, ingestCluster()) + require.NotContains(t, cfg, "tenant", + "an ingest pool without spec.ingest.tenant must render exactly as before") +} + +func TestRenderIngestConfigTenant(t *testing.T) { + cr := ingestCluster() + cr.Spec.Ingest.Tenant = &dbv1alpha1.TenantSpec{ + Header: "X-Scope-OrgID", + ResourceAttributes: []string{"service.namespace", "deployment.environment"}, + Default: "shared", + Require: true, + } + + tenant, ok := renderedIngest(t, cr)["tenant"].(map[string]any) + require.True(t, ok, "tenant block missing") + require.Equal(t, map[string]any{ + "header": "X-Scope-OrgID", + "resource_attributes": []any{"service.namespace", "deployment.environment"}, + "default": "shared", + "require": true, + }, tenant) +} + +func TestRenderIngestConfigTenantOmitsUnsetKeys(t *testing.T) { + cr := ingestCluster() + cr.Spec.Ingest.Tenant = &dbv1alpha1.TenantSpec{Default: "team-a"} + + tenant := renderedIngest(t, cr)["tenant"].(map[string]any) + require.Equal(t, map[string]any{"default": "team-a"}, tenant, + "unset sources must be omitted rather than rendered empty") +} + +// The tenant block is modelled in full, so extraConfig may not reach around the spec into it. +func TestRenderIngestConfigReservedTenant(t *testing.T) { + cr := ingestCluster() + cr.Spec.Ingest.ExtraConfig = &runtime.RawExtension{ + Raw: []byte(`{"tenant":{"header":"X-Scope-OrgID"}}`), + } + + _, err := renderIngestConfig(cr, cr.Spec.Etcd.Endpoints) + require.ErrorContains(t, err, "tenant (use spec.ingest.tenant)") + + var invalid validationError + require.True(t, errors.As(err, &invalid), "reserved paths must fail as a spec validation error") +} + +func TestValidateIngestTenant(t *testing.T) { + tests := []struct { + name string + tenant *dbv1alpha1.TenantSpec + wantErr string + }{ + { + name: "absent is valid", + }, + { + name: "a header alone", + tenant: &dbv1alpha1.TenantSpec{Header: "X-Scope-OrgID"}, + }, + { + name: "a required header", + tenant: &dbv1alpha1.TenantSpec{Header: "X-Scope-OrgID", Require: true}, + }, + { + name: "require without a header", + tenant: &dbv1alpha1.TenantSpec{Default: "a", Require: true}, + wantErr: "spec.ingest.tenant.require needs spec.ingest.tenant.header", + }, + { + name: "no source at all", + tenant: &dbv1alpha1.TenantSpec{}, + wantErr: "spec.ingest.tenant sets no source", + }, + { + name: "an empty attribute key", + tenant: &dbv1alpha1.TenantSpec{ResourceAttributes: []string{"service.namespace", ""}}, + wantErr: "spec.ingest.tenant.resourceAttributes[1] must not be empty", + }, + { + name: "a default with a path separator", + tenant: &dbv1alpha1.TenantSpec{Default: "team/a"}, + wantErr: `invalid character "/" in tenant id`, + }, + { + name: "a default that is a relative path", + tenant: &dbv1alpha1.TenantSpec{Default: ".."}, + wantErr: `invalid tenant id ".."`, + }, + { + name: "a default past the length bound", + tenant: &dbv1alpha1.TenantSpec{Default: strings.Repeat("a", 151)}, + wantErr: "tenant id longer than 150 bytes", + }, + { + name: "a default at the length bound", + tenant: &dbv1alpha1.TenantSpec{Default: strings.Repeat("a", 150)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cr := ingestCluster() + cr.Spec.Ingest.Tenant = tt.tenant + + err := validateIngestTenant(cr) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + + var invalid validationError + require.True(t, errors.As(err, &invalid), "must be reported as a spec validation error") + }) + } +} + +// A tenant block on a cluster with no ingest pool is inert rather than an error: spec.ingest is +// what carries it, so it cannot outlive its pool. +func TestValidateIngestTenantWithoutPool(t *testing.T) { + require.NoError(t, validateIngestTenant(testCluster())) +} diff --git a/internal/controller/naming.go b/internal/controller/naming.go index 1824006..072b532 100644 --- a/internal/controller/naming.go +++ b/internal/controller/naming.go @@ -73,6 +73,15 @@ const ( keyPrivateBackend = "private_backend" ) +// odbingest tenant block keys. +const ( + keyTenant = "tenant" + keyTenantHeader = "header" + keyTenantResourceAttr = "resource_attributes" + keyTenantDefault = "default" + keyTenantRequire = "require" +) + // Query API config block keys, shared by cmd/oteldb and cmd/odbselect. const ( keyPrometheus = "prometheus" From 884a840aeb1374010a1e686c168d7d89c982eb11 Mon Sep 17 00:00:00 2001 From: tdakkota Date: Tue, 18 Aug 2026 12:13:01 +0300 Subject: [PATCH 2/2] chore(crd): regenerate manifests for spec.ingest.tenant --- api/v1alpha1/zz_generated.deepcopy.go | 25 ++++++++++ .../bases/db.oteldb.io_oteldbclusters.yaml | 48 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5aca5d6..9703116 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -213,6 +213,11 @@ func (in *IngestSpec) DeepCopyInto(out *IngestSpec) { *out = new(v1.SecurityContext) (*in).DeepCopyInto(*out) } + if in.Tenant != nil { + in, out := &in.Tenant, &out.Tenant + *out = new(TenantSpec) + (*in).DeepCopyInto(*out) + } if in.ExtraConfig != nil { in, out := &in.ExtraConfig, &out.ExtraConfig *out = new(runtime.RawExtension) @@ -752,3 +757,23 @@ func (in *StorageSpec) DeepCopy() *StorageSpec { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { + *out = *in + if in.ResourceAttributes != nil { + in, out := &in.ResourceAttributes, &out.ResourceAttributes + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantSpec. +func (in *TenantSpec) DeepCopy() *TenantSpec { + if in == nil { + return nil + } + out := new(TenantSpec) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/db.oteldb.io_oteldbclusters.yaml b/config/crd/bases/db.oteldb.io_oteldbclusters.yaml index e01f454..b8db658 100644 --- a/config/crd/bases/db.oteldb.io_oteldbclusters.yaml +++ b/config/crd/bases/db.oteldb.io_oteldbclusters.yaml @@ -2062,8 +2062,9 @@ spec: config, as a top-level YAML/JSON object. Use it to set fields the CRD does not model, such as prometheus_remote_write.time_threshold or the OTLP body-size limits. - The cluster block is reserved and rejected instead of merged: it must stay in step with the - storage nodes. Configure it through spec.etcd and spec.cluster. + The cluster and tenant blocks are reserved and rejected instead of merged: cluster must stay + in step with the storage nodes, and tenant is modelled in full. Configure them through + spec.etcd, spec.cluster and spec.ingest.tenant. type: object x-kubernetes-preserve-unknown-fields: true image: @@ -2601,6 +2602,49 @@ spec: ServiceAccountName is the ServiceAccount for the odbingest pods. Empty falls back to spec.serviceAccountName. type: string + tenant: + description: |- + Tenant opts the ingest pool into multi-tenant write routing. Absent — the default — routes + every write to the "default" tenant, exactly as an unconfigured odbingest does, and renders + no tenant block at all. + + Leave it absent unless you are running a deliberate migration. oteldb's *read* path is still + pinned to a single tenant: the query Backend's tenant id is never assigned, so every PromQL, + LogQL, TraceQL and Pyroscope query resolves "default" (oteldb/oteldb#820). Enabling tenant + resolution here therefore routes writes into tenants that nothing can currently query — the + data is stored, under a different shard key, and is invisible until the read path catches up. + The field exists so the deployment is configurable ahead of that, not because it is ready. + properties: + default: + description: |- + Default is the tenant a write routes to when no source names one. Empty uses odbingest's own + default tenant, "default", which is where every write goes today. + type: string + header: + description: |- + Header is the request header — and OTLP/gRPC metadata key — carrying the tenant. Empty does + not read a header. "X-Scope-OrgID" is what Grafana-stack senders (Loki, Mimir, and their + Grafana datasources) put it in. + type: string + require: + description: |- + Require refuses a request that does not carry Header with 400 (InvalidArgument over gRPC), + instead of routing it to Default. Use it when two senders must not be able to land in one + shared tenant by omitting the header. It needs Header to be set. + type: boolean + resourceAttributes: + description: |- + ResourceAttributes are OTLP resource attribute keys, read in order until one holds a usable + tenant. Empty does not read resource attributes. "service.namespace" is the OTel-native + candidate. + + A missing, non-string or malformed value falls back to Default rather than failing the batch: + by the time a resource is framed, the request can no longer be answered with an error. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object tolerations: description: Tolerations for odbingest pods. items: