Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ reports their aggregate health through one condition on the owner CRD.
- [Prerequisite Behavior](#prerequisite-behavior)
- [Status Reporting](#status-reporting)
- [Reconciliation Lifecycle](#reconciliation-lifecycle)
- [Previewing Desired State](#previewing-desired-state)
- [Cluster-Scoped Resources](#cluster-scoped-resources)
- [Status Model](#status-model)
- [Alive Resources](#alive-resources-alive-interface)
Expand Down Expand Up @@ -276,6 +277,53 @@ See [Persisting Status with FlushStatus](#persisting-status-with-flushstatus).

**Phase 6: Resource deletion.** Resources registered for deletion are removed from the cluster.

## Previewing Desired State

`Component.Preview() ([]client.Object, error)` renders the desired state of every managed resource registered on the
component, in registration order, without contacting the cluster. Read-only resources (fetched, not applied) and delete
resources (removal markers) are excluded.

`Preview` does not evaluate guards. Reconciliation stops at the first resource whose guard is `Blocked` and skips that
resource and all later ones, but a guard's outcome typically depends on cluster state and data extracted from earlier
resources, neither of which is available in a cluster-free render. `Preview` therefore reports the full desired shape of
every managed resource, including ones a given reconcile might skip behind a blocked guard. This keeps the snapshot
deterministic and focused on baseline construction, mutation wiring, and registration order.

Each managed resource must implement `concepts.Previewable`. All built-in primitives satisfy this through
`generic.BaseResource`. `Preview` returns an error if any managed resource does not implement `concepts.Previewable` or
if rendering a resource fails.

`Component.Resource(identity string) (Resource, bool)` looks up a registered resource by its `Identity()` string. For
namespaced resources the identity is `<apiVersion>/<kind>/<namespace>/<name>` (for example
`apps/v1/Deployment/default/web`); cluster-scoped resources omit the namespace segment (for example
`v1/PersistentVolume/data` or `rbac.authorization.k8s.io/v1/ClusterRole/viewer`). The lookup covers all registered
resources: managed, read-only, and delete resources.

`Reconcile` remains the only path that performs cluster IO. `Preview` is the natural input for whole-component golden
snapshots via `golden.AssertComponentYAML`.

```go
comp, err := buildWebComponent(owner)
if err != nil {
return err
}

objs, err := comp.Preview()
if err != nil {
return err
}

for _, obj := range objs {
fmt.Printf("%s/%s\n", obj.GetNamespace(), obj.GetName())
}
```

If you need the concrete Kubernetes type rather than `client.Object`, type-assert the returned value:

```go
dep, ok := objs[0].(*appsv1.Deployment)
```

## Cluster-Scoped Resources

When a component manages cluster-scoped resources (e.g., `ClusterRole`, `PersistentVolume`) and the owner CRD is
Expand Down
25 changes: 23 additions & 2 deletions docs/guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ gated on the versions that need it, and will stop running entirely once those ve
### Verifying backward compatibility mutations

When you update the baseline, you need confidence that older versions still produce the same object they did before. The
framework provides a `golden` package for this. `AssertYAML` accepts any resource that implements `PreviewObject`,
renders it to YAML, and compares the result against a golden file.
framework provides a `golden` package for this. `AssertYAML` accepts any resource that implements `Preview`, renders it
to YAML, and compares the result against a golden file.

```go
import "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
Expand Down Expand Up @@ -252,6 +252,27 @@ snapshot diff shows exactly what shifted.
A reasonable heuristic for the boundary: if a field is always present regardless of feature flags or version, it belongs
in the baseline. If it is conditional, it belongs in a mutation.

To snapshot an entire component at once, use `AssertComponentYAML`. It calls `comp.Preview()`, serializes every managed
resource the component would apply into a single multi-document YAML file (documents joined by `---` separators, in
registration order), and compares the result against the golden file. This is useful when you want to verify that a
cross-component change does not accidentally alter any resource's shape.

```go
func TestComponentShape(t *testing.T) {
owner := &v1alpha1.MyApp{
Spec: v1alpha1.MyAppSpec{Version: "2.0.0"},
}

comp, err := buildWebComponent(owner)
require.NoError(t, err)

golden.AssertComponentYAML(t, "testdata/web-component.yaml", comp, golden.Update(*update))
}
```

Read-only and delete resources are excluded from the component preview; only resources the component would actively
apply appear in the golden file.

## One Component Per Logical Condition

Each component reports exactly one condition on the owner CRD's status. If your operator needs to report `DatabaseReady`
Expand Down
4 changes: 2 additions & 2 deletions examples/custom-resource/resources/certificate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ func TestCertificateGVK(t *testing.T) {
}

// Verify the Previewer interface conformance for the unstructured static resource.
func TestCertificatePreviewObject(t *testing.T) {
func TestCertificatePreview(t *testing.T) {
owner := testOwner()
res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)).Build()
require.NoError(t, err)

obj, err := res.PreviewObject()
obj, err := res.Preview()
require.NoError(t, err)
require.IsType(t, &uns.Unstructured{}, obj)
}
5 changes: 4 additions & 1 deletion examples/mutations-and-gating/features/debug_logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ func TestDebugLoggingMutation(t *testing.T) {
Build()
require.NoError(t, err)

obj, err := res.PreviewObject()
previewed, err := res.Preview()
require.NoError(t, err)

obj, ok := previewed.(*appsv1.Deployment)
require.True(t, ok)

containers := obj.Spec.Template.Spec.Containers
require.Len(t, containers, 1)
assert.Equal(t, "app", containers[0].Name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ func TestBackwardCompatV1Container(t *testing.T) {
Build()
require.NoError(t, err)

obj, err := res.PreviewObject()
previewed, err := res.Preview()
require.NoError(t, err)

obj, ok := previewed.(*appsv1.Deployment)
require.True(t, ok)

containers := obj.Spec.Template.Spec.Containers
require.Len(t, containers, 1)
assert.Equal(t, "server", containers[0].Name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ func TestMetricsConfigMutation(t *testing.T) {
Build()
require.NoError(t, err)

obj, err := res.PreviewObject()
previewed, err := res.Preview()
require.NoError(t, err)

obj, ok := previewed.(*corev1.ConfigMap)
require.True(t, ok)

yaml := obj.Data["app.yaml"]
assert.Contains(t, yaml, "metrics:")
assert.Contains(t, yaml, "port: 9090")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ func TestTracingSidecarMutation(t *testing.T) {
Build()
require.NoError(t, err)

obj, err := res.PreviewObject()
previewed, err := res.Preview()
require.NoError(t, err)

obj, ok := previewed.(*appsv1.Deployment)
require.True(t, ok)

containers := obj.Spec.Template.Spec.Containers
require.Len(t, containers, 2)

Expand Down
51 changes: 51 additions & 0 deletions pkg/component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics"

"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/feature"
)

Expand Down Expand Up @@ -137,6 +138,56 @@ func (c *Component) GetCondition(owner OperatorCRD) Condition {
return Condition(*cond)
}

// Preview renders the desired state of every managed resource registered on the
// component, in registration order, without contacting the cluster.
//
// Read-only resources (which are fetched, not applied) and delete resources
// (removal markers with no desired object) are excluded, leaving the desired
// shape of the managed resources, suitable for whole-component golden snapshots.
//
// Preview does not evaluate guards. Reconcile stops at the first resource whose
// guard is Blocked and skips it and all later resources, but a guard's outcome
// generally depends on cluster state and data extracted from earlier resources,
// none of which is available in a cluster-free render. Preview therefore returns
// the full desired set, including resources a given reconcile might skip behind a
// blocked guard, keeping the snapshot deterministic.
//
// Each managed reconcile resource must implement concepts.Previewable; all
// built-in primitives do. Preview returns an error if a managed resource does
// not implement it, renders a nil object, or fails to render.
func (c *Component) Preview() ([]client.Object, error) {
objs := make([]client.Object, 0, len(c.reconcileResources))
for _, entry := range c.reconcileResources {
if entry.Options.ReadOnly {
continue
}
previewable, ok := entry.Resource.(concepts.Previewable)
if !ok {
return nil, fmt.Errorf(
"resource %q does not implement concepts.Previewable and cannot be previewed",
entry.Resource.Identity(),
)
}
obj, err := previewable.Preview()
if err != nil {
return nil, fmt.Errorf("preview resource %q: %w", entry.Resource.Identity(), err)
}
if obj == nil {
return nil, fmt.Errorf("preview resource %q: returned a nil object", entry.Resource.Identity())
}
objs = append(objs, obj)
}
Comment thread
sourcehawk marked this conversation as resolved.
return objs, nil
}

// Resource returns the registered resource with the given Identity() and true,
// or nil and false if no such resource is registered. The lookup covers every
// registered resource, including read-only and delete resources.
func (c *Component) Resource(identity string) (Resource, bool) {
r, ok := c.resourceLookup[identity]
return r, ok
}

// Reconcile converges the component to the desired state.
//
// A component manages its own condition on the parent and updates it in-memory
Expand Down
135 changes: 135 additions & 0 deletions pkg/component/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ package component
import (
"context"
"fmt"
"testing"
"time"

"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

var _ = Describe("Component Reconciler", func() {
Expand Down Expand Up @@ -1560,3 +1564,134 @@ type testPrereqFunc struct {
func (p *testPrereqFunc) Check(_ ReconcileContext) (PrerequisiteResult, error) {
return p.checkFn()
}

func TestComponentPreview(t *testing.T) {
t.Run("renders managed reconcile resources in registration order", func(t *testing.T) {
r1 := &MockPreviewableResource{}
r1.On("Identity").Return("apps/v1/Deployment/default/a")
r1.On("Preview").Return(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "a"},
}, nil)

r2 := &MockPreviewableResource{}
r2.On("Identity").Return("v1/ConfigMap/default/b")
r2.On("Preview").Return(&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "b"},
}, nil)

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(r1, ResourceOptions{})
b.WithResource(r2, ResourceOptions{})
comp, err := b.Build()
require.NoError(t, err)

objs, err := comp.Preview()
require.NoError(t, err)
require.Len(t, objs, 2)
assert.Equal(t, "a", objs[0].GetName())
assert.Equal(t, "b", objs[1].GetName())
})

t.Run("excludes read-only and delete resources", func(t *testing.T) {
managed := &MockPreviewableResource{}
managed.On("Identity").Return("apps/v1/Deployment/default/m")
managed.On("Preview").Return(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "m"}}, nil)

readOnly := &MockPreviewableResource{}
readOnly.On("Identity").Return("v1/Secret/default/ro")

del := &MockPreviewableResource{}
del.On("Identity").Return("v1/ConfigMap/default/del")

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(managed, ResourceOptions{})
b.WithResource(readOnly, ResourceOptions{ReadOnly: true})
b.WithResource(del, ResourceOptions{Delete: true})
comp, err := b.Build()
require.NoError(t, err)

objs, err := comp.Preview()
require.NoError(t, err)
require.Len(t, objs, 1)
assert.Equal(t, "m", objs[0].GetName())
readOnly.AssertNotCalled(t, "Preview")
del.AssertNotCalled(t, "Preview")
})

t.Run("errors when a managed resource is not previewable", func(t *testing.T) {
plain := &MockResource{} // implements Resource but not Previewable
plain.On("Identity").Return("apps/v1/Deployment/default/p")

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(plain, ResourceOptions{})
comp, err := b.Build()
require.NoError(t, err)

_, err = comp.Preview()
require.Error(t, err)
assert.Contains(t, err.Error(), "apps/v1/Deployment/default/p")
})

t.Run("wraps a render error with the resource identity", func(t *testing.T) {
failing := &MockPreviewableResource{}
failing.On("Identity").Return("apps/v1/Deployment/default/f")
failing.On("Preview").Return(nil, assert.AnError)

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(failing, ResourceOptions{})
comp, err := b.Build()
require.NoError(t, err)

_, err = comp.Preview()
require.Error(t, err)
assert.Contains(t, err.Error(), "apps/v1/Deployment/default/f")
})

t.Run("errors when a managed resource previews a nil object", func(t *testing.T) {
nilRes := &MockPreviewableResource{}
nilRes.On("Identity").Return("apps/v1/Deployment/default/nilobj")
nilRes.On("Preview").Return(nil, nil)

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(nilRes, ResourceOptions{})
comp, err := b.Build()
require.NoError(t, err)

_, err = comp.Preview()
require.Error(t, err)
assert.Contains(t, err.Error(), "apps/v1/Deployment/default/nilobj")
})
}

func TestComponentResource(t *testing.T) {
managed := &MockResource{}
managed.On("Identity").Return("apps/v1/Deployment/default/m")
readOnly := &MockResource{}
readOnly.On("Identity").Return("v1/Secret/default/ro")
del := &MockResource{}
del.On("Identity").Return("v1/ConfigMap/default/del")

b := NewComponentBuilder()
b.WithName("t").WithConditionType("Ready")
b.WithResource(managed, ResourceOptions{})
b.WithResource(readOnly, ResourceOptions{ReadOnly: true})
b.WithResource(del, ResourceOptions{Delete: true})
comp, err := b.Build()
require.NoError(t, err)

got, ok := comp.Resource("v1/Secret/default/ro")
assert.True(t, ok)
assert.Equal(t, readOnly, got)

gotDel, ok := comp.Resource("v1/ConfigMap/default/del")
assert.True(t, ok)
assert.Equal(t, del, gotDel)

_, ok = comp.Resource("does/not/exist")
assert.False(t, ok)
}
Loading
Loading