Context
pkg/component/read.go:readResource calls resource.Object() which returns
a deep copy of BaseResource.DesiredObject (see pkg/generic/resource_base.go).
It then client.Gets into that COPY, populating it with the cluster's current
state. But BaseResource.ExtractData() (same file) deep-copies DesiredObject
AGAIN before running each extractor. The extractor therefore receives the
original empty base, not the populated post-fetch object.
This means data extractors on read-only resources cannot observe the actual
cluster state of the resource. They only ever see whatever shape the consumer
put into the base object passed to NewBuilder.
The problem
The whole point of the data-extraction pattern for read-only resources is to
let the Component observe state of resources it does not own (e.g.
user-provided Secrets carrying credentials) and feed that into downstream
mutations. With the extractor seeing the empty base instead of the live
fetched object, this pattern is broken for the read-only case.
Concrete impact (the use case that surfaced this): a CamundaCluster controller
wants to detect when a referenced (user-owned) Secret rotates and roll the
pod template via a checksum/external-secrets annotation. The natural
implementation per examples/extraction-and-guards is:
```go
secret.NewBuilder(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
}).
WithDataExtractor(func(s corev1.Secret) error {
h, err := secret.DataHash(s)
*hashPtr = h
return err
}).
Build()
```
The extractor always sees an empty Secret (Data and StringData both nil), so
`secret.DataHash` always returns the SHA-256 of `{}` (`44136fa3...`) regardless
of what the cluster Secret actually contains. Rotation never updates the hash,
so the rollout never fires.
The example `examples/extraction-and-guards` does not exhibit the bug
because its base ConfigMap (`BaseConfigMap`) already has the values the
extractor reads (`db-host: postgres.default.svc`). It tests a managed
resource where the base IS the desired state. The bug only manifests for
read-only resources where the consumer cannot know the cluster state at
build time.
Workarounds today (unsatisfying)
- Skip the framework's read-only resource + extractor pattern entirely. Do
`r.Get` + `secret.DataHash` inline in the controller body, then pass the
hash as a string into a non-gated workload mutation. Loses framework
symmetry; we now have two ways to observe external state (framework for
managed, ad-hoc for read-only).
- Populate the base object with what we think is in the cluster, then trust
the extractor (which sees the base). Defeats the purpose.
Proposed solution shape
`readResource` should write back the fetched object into `DesiredObject`
(or store it separately as a `FetchedObject`), and `ExtractData` should run
extractors against the fetched object rather than re-deep-copying
`DesiredObject`. Sketch:
```go
// pkg/component/read.go
func readResource(...) (*reconcileResult, error) {
object, err := resource.Object()
if err != nil { return nil, ... }
if err := rec.Client.Get(...); err != nil { return nil, ... }
// ... new: persist the fetched state on the Resource so ExtractData sees it
resource.SetFetchedObject(object)
...
}
// pkg/generic/resource_base.go
func (r *BaseResource[T, M]) ExtractData() error {
src := r.DesiredObject
if r.fetched != nil {
src = r.fetched
}
copyObj, _ := src.DeepCopyObject().(T)
for _, extractor := range r.DataExtractors {
if err := extractor(copyObj); err != nil { return err }
}
return nil
}
```
(Exact method names TBD; the above is just the intent.)
Open question
Is the current behavior intentional? If extractors on read-only resources
are meant to operate on the desired base only, the documentation in
`builder.go` ("register a function to read values from the Secret after it
has been successfully reconciled") and `extraction-and-guards/README.md`
should be updated to call that out, and the docs should recommend an
alternative pattern for observing external-resource state.
Context
pkg/component/read.go:readResourcecallsresource.Object()which returnsa deep copy of
BaseResource.DesiredObject(seepkg/generic/resource_base.go).It then
client.Gets into that COPY, populating it with the cluster's currentstate. But
BaseResource.ExtractData()(same file) deep-copiesDesiredObjectAGAIN before running each extractor. The extractor therefore receives the
original empty base, not the populated post-fetch object.
This means data extractors on read-only resources cannot observe the actual
cluster state of the resource. They only ever see whatever shape the consumer
put into the base object passed to
NewBuilder.The problem
The whole point of the data-extraction pattern for read-only resources is to
let the Component observe state of resources it does not own (e.g.
user-provided Secrets carrying credentials) and feed that into downstream
mutations. With the extractor seeing the empty base instead of the live
fetched object, this pattern is broken for the read-only case.
Concrete impact (the use case that surfaced this): a CamundaCluster controller
wants to detect when a referenced (user-owned) Secret rotates and roll the
pod template via a
checksum/external-secretsannotation. The naturalimplementation per
examples/extraction-and-guardsis:```go
secret.NewBuilder(&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
}).
WithDataExtractor(func(s corev1.Secret) error {
h, err := secret.DataHash(s)
*hashPtr = h
return err
}).
Build()
```
The extractor always sees an empty Secret (Data and StringData both nil), so
`secret.DataHash` always returns the SHA-256 of `{}` (`44136fa3...`) regardless
of what the cluster Secret actually contains. Rotation never updates the hash,
so the rollout never fires.
The example `examples/extraction-and-guards` does not exhibit the bug
because its base ConfigMap (`BaseConfigMap`) already has the values the
extractor reads (`db-host: postgres.default.svc`). It tests a managed
resource where the base IS the desired state. The bug only manifests for
read-only resources where the consumer cannot know the cluster state at
build time.
Workarounds today (unsatisfying)
`r.Get` + `secret.DataHash` inline in the controller body, then pass the
hash as a string into a non-gated workload mutation. Loses framework
symmetry; we now have two ways to observe external state (framework for
managed, ad-hoc for read-only).
the extractor (which sees the base). Defeats the purpose.
Proposed solution shape
`readResource` should write back the fetched object into `DesiredObject`
(or store it separately as a `FetchedObject`), and `ExtractData` should run
extractors against the fetched object rather than re-deep-copying
`DesiredObject`. Sketch:
```go
// pkg/component/read.go
func readResource(...) (*reconcileResult, error) {
object, err := resource.Object()
if err != nil { return nil, ... }
if err := rec.Client.Get(...); err != nil { return nil, ... }
// ... new: persist the fetched state on the Resource so ExtractData sees it
resource.SetFetchedObject(object)
...
}
// pkg/generic/resource_base.go
func (r *BaseResource[T, M]) ExtractData() error {
src := r.DesiredObject
if r.fetched != nil {
src = r.fetched
}
copyObj, _ := src.DeepCopyObject().(T)
for _, extractor := range r.DataExtractors {
if err := extractor(copyObj); err != nil { return err }
}
return nil
}
```
(Exact method names TBD; the above is just the intent.)
Open question
Is the current behavior intentional? If extractors on read-only resources
are meant to operate on the desired base only, the documentation in
`builder.go` ("register a function to read values from the Secret after it
has been successfully reconciled") and `extraction-and-guards/README.md`
should be updated to call that out, and the docs should recommend an
alternative pattern for observing external-resource state.