OBC → Operand Secret Bridge (Loki, Tempo, Quay)
ESO with the kubernetes provider used to bridge between ODF NooBaa ObjectBucketClaim outputs (AWS_*_KEY_* + ConfigMap with BUCKET_*) and the lowercase-key Secret shapes that LokiStack, TempoStack, and Quay storage expect.
This page documents the only non-Vault use of ESO on the fleet: a same-cluster Secret-to-Secret transformation that lets ODF NooBaa OBCs feed Loki, Tempo, and Quay storage. Read 01-architecture for the bigger picture before this page.
The mismatch
ODF’s NooBaa provides in-cluster S3-compatible object storage. When you provision a bucket via ObjectBucketClaim, NooBaa creates two resources:
| Resource | Name | Contains |
|---|---|---|
Secret | matches the OBC’s metadata.name | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
ConfigMap | matches the OBC’s metadata.name | BUCKET_HOST, BUCKET_NAME, BUCKET_PORT, BUCKET_REGION, BUCKET_SUBREGION |
So the OBC output is split across a Secret (the credentials) and a ConfigMap (the endpoint coordinates).
The operands that consume S3 storage expect a single Secret with different, lowercase, snake-case keys:
| Operand | Secret shape it expects |
|---|---|
LokiStack.spec.storage.secret (type s3) | endpoint, bucketnames, access_key_id, access_key_secret |
TempoStack.spec.storage.secret (type s3) | same as Loki |
QuayRegistry (spec.configBundleSecret) | a config-bundle YAML with DISTRIBUTED_STORAGE_CONFIG referencing the bucket |
OBC’s output directly is not consumable by any of them. The shapes differ; the casing differs; the endpoint lives in the ConfigMap, not the Secret.
The bridge
The fix is an ExternalSecret using ESO’s kubernetes provider (not Vault) that reads from the OBC’s Secret and templates a new Secret in the shape the operand expects. The kubernetes provider lets ESO act as a Secret-to-Secret transformer within the same cluster — no Vault round-trip required.
Pattern files live under clusters/spoke-dc-v6/platform-services/tracing/externalsecret-tempo-storage.yaml (the Tempo flavor; Loki and Quay follow the same template).
The four objects you need
For each operand:
- The ObjectBucketClaim — creates the bucket and its NooBaa outputs.
- ServiceAccount + Role + RoleBinding — gives the ESO kubernetes provider read on the OBC’s Secret.
- SecretStore (kubernetes provider) — declares the in-cluster Secret source.
- ExternalSecret with templating — composes the operand-shape Secret.
1) The OBC
apiVersion: objectbucket.io/v1alpha1
kind: ObjectBucketClaim
metadata:
name: tempo-traces
namespace: openshift-tempo
spec:
bucketName: tempo-traces # deterministic; disables NooBaa's UUID prefix
generateBucketName: ""
storageClassName: openshift-storage.noobaa.io
Setting bucketName rather than generateBucketName makes the bucket name predictable — important for the template later.
2) SA + Role + RoleBinding for ESO read
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-obc-reader
namespace: openshift-tempo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: eso-obc-reader
namespace: openshift-tempo
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["tempo-traces"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: eso-obc-reader
namespace: openshift-tempo
subjects:
- kind: ServiceAccount
name: eso-obc-reader
namespace: openshift-tempo
roleBinding: # standard RoleBinding shape:
# (kept short; full RoleBinding has subjects + roleRef)
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: eso-obc-reader
The Role is scoped by resourceNames: ["tempo-traces"] so the kubernetes-provider SA can only read this one OBC’s Secret — not any other Secret in the namespace.
3) The SecretStore (kubernetes provider)
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: obc-tempo
namespace: openshift-tempo
spec:
provider:
kubernetes:
remoteNamespace: openshift-tempo
server:
caProvider:
type: ConfigMap
name: kube-root-ca.crt
namespace: openshift-tempo
key: ca.crt
auth:
serviceAccount:
name: eso-obc-reader
The server.caProvider block makes ESO read the cluster CA from the well-known kube-root-ca.crt ConfigMap, which OpenShift mirrors into every namespace. This is required because ESO’s kubernetes provider speaks to the apiserver over TLS.
4) The ExternalSecret (the templating)
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: tempo-storage
namespace: openshift-tempo
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: obc-tempo
target:
name: tempo-storage
template:
type: Opaque
data:
endpoint: "https://s3.openshift-storage.svc"
bucketnames: "tempo-traces"
access_key_id: "{{ .access_key_id }}"
access_key_secret: "{{ .access_key_secret }}"
data:
- secretKey: access_key_id
remoteRef:
key: tempo-traces # the OBC Secret name
property: AWS_ACCESS_KEY_ID
- secretKey: access_key_secret
remoteRef:
key: tempo-traces
property: AWS_SECRET_ACCESS_KEY
This produces Secret/tempo-storage in openshift-tempo with the four lowercase keys TempoStack expects. Then:
apiVersion: tempo.grafana.com/v1alpha1
kind: TempoStack
metadata:
name: traces
namespace: openshift-tempo
spec:
storage:
secret:
name: tempo-storage # the templated Secret
type: s3
Endpoint hardcoded — why
endpoint: "https://s3.openshift-storage.svc" is hardcoded rather than read from the OBC’s ConfigMap because:
- ESO’s kubernetes provider can only read Secrets, not ConfigMaps. Loki/Tempo/Quay need the endpoint, but it lives in the OBC’s ConfigMap, which ESO can’t see through this provider.
- The endpoint is deterministic for any in-cluster NooBaa S3 use: the NooBaa Service is named
s3inopenshift-storageand resolves tos3.openshift-storage.svc(cluster DNS). - Adding a second ExternalSecret to read the ConfigMap was considered and rejected — ESO’s kubernetes provider doesn’t support cross-resource composition, so the right path is to hardcode the known service URL.
The trade-off: if the OBC ever points at a different (out-of-cluster) S3 endpoint, the hardcoded URL is wrong and the bridge must be updated. Acceptable trade for in-cluster NooBaa.
Bucket name — why deterministic
Setting bucketName: tempo-traces rather than generateBucketName: tempo-traces- matters because:
- With
generateBucketName, NooBaa auto-prefixes a UUID, e.g.tempo-traces-abc-123. The template would need to read that name from the OBC ConfigMap — same kubernetes-provider limitation. - With
bucketNameset explicitly, NooBaa uses that name verbatim; the template hardcodesbucketnames: "tempo-traces".
The cost is that bucket names must be unique cluster-wide. In a single-cluster deployment this is fine; collision risk exists only if two OBCs in different namespaces request the same bucketName.
Loki backport (follow-up #233)
The Loki flavor was not landed at the same time as Tempo and is currently Warning Degraded on spoke-dc-v6. The backport is identical to the Tempo manifests above but rooted at clusters/spoke-dc-v6/platform-services/logging/externalsecret-loki-storage.yaml. Tracked under #233.
Quay flavor
Quay uses a different consumer shape — not the simple four-key Secret but a config.yaml bundle (quay-config-bundle-secret) referenced via QuayRegistry.spec.configBundleSecret. The bridge is still an ESO ExternalSecret, but the source is Vault (path secret/ocp/<cluster>/quay/config-bundle, field config.yaml), not the OBC. NooBaa credentials are embedded inside the templated config.yaml.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
ES Ready=False permission denied get secrets | RoleBinding missing/typo | confirm oc -n openshift-tempo auth can-i get secret/tempo-traces --as system:serviceaccount:openshift-tempo:eso-obc-reader |
| Templated Secret created, but operand reports “S3 endpoint unreachable” | NooBaa Service not at s3.openshift-storage.svc (rare) | check oc -n openshift-storage get svc s3 |
LokiStack Degraded with “invalid storage credentials” | template producing wrong keys | inspect with oc -n openshift-tempo get secret tempo-storage -o yaml |
OBC stuck Pending | NooBaa unhealthy | oc -n openshift-storage get noobaa,backingstore,bucketclass |
| Bridge stops refreshing | OBC Secret regenerated by NooBaa with new keys | ESO refresh on refreshInterval picks it up — verify ES lastRefreshTime |
Generalising the pattern
The bridge works for any operand that expects a non-S3-key Secret shape and is fed from an in-cluster source — not just Loki / Tempo / Quay. The four-step recipe is generic:
- Provision the source Secret (OBC for object storage, or any other in-cluster Secret).
- Add a minimal
Role+RoleBindingscoped byresourceNamesto a dedicated ServiceAccount. - Declare a
SecretStorewith thekubernetesprovider pointing at that SA. - Write an
ExternalSecretwhosetarget.templatecomposes the operand’s expected shape.
Anytime an operand’s documentation says “create a Secret with keys X, Y, Z” and your source has the same data in differently-named keys, this is the bridge to reach for. The trade-off — the kubernetes provider only reads Secrets, not ConfigMaps — is worked around by hardcoding deterministic in-cluster values (the NooBaa service URL in this case).
References
- The canonical Tempo example:
platform-gitops/clusters/spoke-dc-v6/platform-services/tracing/externalsecret-tempo-storage.yaml. - Loki backport tracked under #233 — same shape as Tempo, rooted at
clusters/spoke-dc-v6/platform-services/logging/externalsecret-loki-storage.yamlonce it lands. - LokiStack and TempoStack API references (Grafana Operator).
- Quay Operator docs (
configBundleSecretformat). - ESO kubernetes provider docs —
external-secrets.io/v1/SecretStore.provider.kubernetes.