Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
04fbe14
Add PodDisruptionBudget primitive and PDB spec editor
sourcehawk Mar 22, 2026
918cf61
Add PDB primitive example
sourcehawk Mar 22, 2026
ca8baf0
Add PDB primitive documentation
sourcehawk Mar 22, 2026
6f9a13f
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
20fc3e8
fix: rename shadowed variable in PDB builder identity func
sourcehawk Mar 23, 2026
0d96de1
fix: pdb mutator constructor should not call beginFeature
sourcehawk Mar 23, 2026
fa0bdb9
docs: update pdb DefaultFieldApplicator comment and docs to reflect P…
sourcehawk Mar 23, 2026
4ad9bac
fix: export BeginFeature to satisfy FeatureMutator interface
sourcehawk Mar 23, 2026
acde48c
Merge remote-tracking branch 'origin/main' into feature/pdb-primitive
sourcehawk Mar 24, 2026
e28f283
style: format markdown files with prettier
sourcehawk Mar 24, 2026
785838f
test: strengthen PDB test coverage for status preservation and operat…
sourcehawk Mar 24, 2026
5e04901
style: fix gofmt alignment in PDB test
sourcehawk Mar 24, 2026
8c55158
build: add pdb-primitive example to run-examples Makefile target
sourcehawk Mar 24, 2026
ef48685
Merge remote-tracking branch 'origin/main' into feature/pdb-primitive
sourcehawk Mar 24, 2026
aef4f43
fix: do not initialize an empty plan on PDB mutator construction
sourcehawk Mar 24, 2026
74ebb1b
Merge remote-tracking branch 'origin/main' into feature/pdb-primitive
sourcehawk Mar 25, 2026
0b8f836
refactor: remove field applicators and flavors from PDB primitive
sourcehawk Mar 25, 2026
c2d4c7d
fix: remove references to deleted field application flavor API in exa…
sourcehawk Mar 25, 2026
13c07f3
docs: remove stale PreserveCurrentLabels reference from PDB example R…
sourcehawk Mar 25, 2026
b6a8aa1
fix: add defensive nil-active checks and selector to PDB test helper
sourcehawk Mar 25, 2026
bb8c0e2
fix: propagate errors from EditSpec and EditObjectMetadata in tests a…
sourcehawk Mar 25, 2026
c5e5149
fix: check error returns and fix import grouping in PDB tests
sourcehawk Mar 25, 2026
7a66032
merge: resolve conflicts from main into feature/pdb-primitive
sourcehawk Mar 25, 2026
a012192
docs: clarify nil-edit behavior in PDB mutator type comment
sourcehawk Mar 25, 2026
0bf0e07
Merge branch 'main' into feature/pdb-primitive
sourcehawk Mar 25, 2026
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ run-examples: ## Run all examples to verify they execute without error.
go run ./examples/replicaset-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
go run ./examples/pdb-primitive/.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run-examples now runs ./examples/pdb-primitive/., but the new pdb example currently references non-existent APIs (WithFieldApplicationFlavor / PreserveCurrentLabels) and will fail to build. Please either fix the example so it compiles before adding it to run-examples, or defer adding it to this target until the API is implemented.

Suggested change
go run ./examples/pdb-primitive/.

Copilot uses AI. Check for mistakes.
go run ./examples/daemonset-primitive/.
go run ./examples/hpa-primitive/.
go run ./examples/clusterrolebinding-primitive/.
Expand Down
28 changes: 15 additions & 13 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,20 @@ This design:

Editors provide scoped, typed APIs for modifying specific parts of a resource:

| Editor | Scope |
| ----------------------- | --------------------------------------------------------------------------------- |
| `ContainerEditor` | Environment variables, arguments, resource limits, ports |
| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context |
| `DeploymentSpecEditor` | Replicas, update strategy, label selectors |
| `ReplicaSetSpecEditor` | Replicas, min ready seconds |
| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit |
| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access |
| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access |
| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw |
| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests |
| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration |
| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object |
| Editor | Scope |
| ------------------------------- | --------------------------------------------------------------------------------- |
| `ContainerEditor` | Environment variables, arguments, resource limits, ports |
| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context |
| `DeploymentSpecEditor` | Replicas, update strategy, label selectors |
| `ReplicaSetSpecEditor` | Replicas, min ready seconds |
| `DaemonSetSpecEditor` | Update strategy, min ready seconds, revision history limit |
| `PodDisruptionBudgetSpecEditor` | MinAvailable, MaxUnavailable, selector, eviction policy |
| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access |
| `PolicyRulesEditor` | `.rules` entries on Role and ClusterRole objects — add, remove, clear, raw access |
| `BindingSubjectsEditor` | Subjects on RoleBinding or ClusterRoleBinding — ensure, remove, raw |
| `PVCSpecEditor` | Access modes, storage class, volume mode, storage requests |
| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration |
| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object |

Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the
underlying Kubernetes struct while keeping the mutation scoped to that editor's target.
Expand Down Expand Up @@ -154,6 +155,7 @@ have been applied. This means a single mutation can safely add a container and t
| `pkg/primitives/daemonset` | Workload | [daemonset.md](primitives/daemonset.md) |
| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) |
| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) |
| `pkg/primitives/pdb` | Static | [pdb.md](primitives/pdb.md) |
| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) |
| `pkg/primitives/clusterrolebinding` | Static | [clusterrolebinding.md](primitives/clusterrolebinding.md) |
| `pkg/primitives/pvc` | Integration | [pvc.md](primitives/pvc.md) |
Expand Down
251 changes: 251 additions & 0 deletions docs/primitives/pdb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# PodDisruptionBudget Primitive

The `pdb` primitive is the framework's built-in static abstraction for managing Kubernetes `PodDisruptionBudget`
resources. It integrates with the component lifecycle and provides a structured mutation API for managing disruption
policies and object metadata.

## Capabilities

| Capability | Detail |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state |
| **Mutation pipeline** | Typed editors for PDB spec and object metadata, with a raw escape hatch for free-form access |
| **Data extraction** | Reads generated or updated values back from the reconciled PDB after each sync cycle |

## Building a PDB Primitive

```go
import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb"

minAvailable := intstr.FromString("50%")
base := &policyv1.PodDisruptionBudget{
ObjectMeta: metav1.ObjectMeta{
Name: "web-server-pdb",
Namespace: owner.Namespace,
},
Spec: policyv1.PodDisruptionBudgetSpec{
MinAvailable: &minAvailable,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "web-server"},
},
},
}

resource, err := pdb.NewBuilder(base).
WithMutation(MyFeatureMutation(owner.Spec.Version)).
Build()
```

## Mutations

Mutations are the primary mechanism for modifying a `PodDisruptionBudget` beyond its baseline. Each mutation is a named
function that receives a `*Mutator` and records edit intent through typed editors.

The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
with no version constraints and no `When()` conditions is also always enabled:

```go
func MyFeatureMutation(version string) pdb.Mutation {
return pdb.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *pdb.Mutator) error {
// record edits here
return nil
},
}
}
```

Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
another, register the dependency first.

### Boolean-gated mutations

```go
func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
return pdb.Mutation{
Name: "strict-availability",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *pdb.Mutator) error {
return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.ClearMinAvailable()
e.SetMaxUnavailable(intstr.FromInt32(1))
return nil
})
},
}
}
```

### Version-gated mutations

```go
var legacyConstraint = mustSemverConstraint("< 2.0.0")

func LegacyPDBMutation(version string) pdb.Mutation {
return pdb.Mutation{
Name: "legacy-pdb",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *pdb.Mutator) error {
return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.SetMinAvailable(intstr.FromInt32(1))
return nil
})
},
}
}
```

All version constraints and `When()` conditions must be satisfied for a mutation to apply.

## Internal Mutation Ordering

Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
recorded:

| Step | Category | What it affects |
| ---- | -------------- | ------------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `PodDisruptionBudget` |
| 2 | Spec edits | MinAvailable, MaxUnavailable, selector, eviction policy |

Within each category, edits are applied in their registration order. Later features observe the PodDisruptionBudget as
modified by all previous features.

## Editors

### PodDisruptionBudgetSpecEditor

The primary API for modifying the PDB spec. Use `m.EditSpec` for full control:

```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.SetMinAvailable(intstr.FromString("50%"))
e.SetSelector(&metav1.LabelSelector{
MatchLabels: map[string]string{"app": "web"},
})
return nil
})
```

#### SetMinAvailable and SetMaxUnavailable

`SetMinAvailable` sets the minimum number of pods that must remain available during a disruption. `SetMaxUnavailable`
sets the maximum number of pods that can be unavailable. Both accept `intstr.IntOrString` — either an integer count or a
percentage string (e.g. `"50%"`).

These fields are mutually exclusive in the Kubernetes API. Use `ClearMinAvailable` or `ClearMaxUnavailable` to remove
the opposing constraint when switching between them:

```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.ClearMinAvailable()
e.SetMaxUnavailable(intstr.FromInt32(1))
return nil
})
```

#### SetSelector

`SetSelector` replaces the pod selector used by the PDB:

```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.SetSelector(&metav1.LabelSelector{
MatchLabels: map[string]string{"app": "web", "tier": "frontend"},
})
return nil
})
```

#### SetUnhealthyPodEvictionPolicy

`SetUnhealthyPodEvictionPolicy` controls how unhealthy pods are handled during eviction. Valid values are
`policyv1.IfHealthyBudget` and `policyv1.AlwaysAllow`. Use `ClearUnhealthyPodEvictionPolicy` to revert to the cluster
default:

```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.SetUnhealthyPodEvictionPolicy(policyv1.AlwaysAllow)
return nil
})
```

#### Raw Escape Hatch

`Raw()` returns the underlying `*policyv1.PodDisruptionBudgetSpec` for direct access when the typed API is insufficient:

```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.Raw().MinAvailable = &customValue
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations via `m.EditObjectMetadata`.

Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.

```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
e.EnsureAnnotation("pdb.example.io/policy", "strict")
return nil
})
```

## Full Example: Feature-Gated Disruption Policy

```go
func BasePDBMutation(version string) pdb.Mutation {
return pdb.Mutation{
Name: "base-pdb",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *pdb.Mutator) error {
return m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
},
}
}

func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
return pdb.Mutation{
Name: "strict-availability",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *pdb.Mutator) error {
return m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.ClearMinAvailable()
e.SetMaxUnavailable(intstr.FromInt32(1))
return nil
})
},
}
}

resource, err := pdb.NewBuilder(base).
WithMutation(BasePDBMutation(owner.Spec.Version)).
WithMutation(StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.StrictMode)).
Build()
```

When `StrictMode` is true, the PDB switches from percentage-based `MinAvailable` to an absolute `MaxUnavailable` of 1.
When false, only the base mutation runs and the original `MinAvailable` from the baseline is preserved. Neither mutation
needs to know about the other.

## Guidance

**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for
boolean conditions.

**`MinAvailable` and `MaxUnavailable` are mutually exclusive.** When switching between them, always clear the opposing
field first. The typed API makes this explicit with `ClearMinAvailable` and `ClearMaxUnavailable`.

**Register mutations in dependency order.** If mutation B relies on state set by mutation A, register A first.
31 changes: 31 additions & 0 deletions examples/pdb-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# PodDisruptionBudget Primitive Example

This example demonstrates the usage of the `pdb` primitive within the operator component framework. It shows how to
manage a Kubernetes PodDisruptionBudget as a component of a larger application, utilising features like:

- **Base Construction**: Initializing a PDB with a percentage-based `MinAvailable` and a label selector.
- **Feature Mutations**: Switching between `MinAvailable` and `MaxUnavailable` based on a feature toggle via `EditSpec`.
- **Metadata Mutations**: Setting version labels on the PDB via `EditObjectMetadata`.
- **Data Extraction**: Inspecting the reconciled PDB's disruption policy after each sync cycle.

## Directory Structure

- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from
`examples/shared/app`.
- `features/`: Contains modular feature definitions:
- `mutations.go`: version labelling and feature-gated strict availability.
- `resources/`: Contains the central `NewPDBResource` factory that assembles all features using `pdb.Builder`.
- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client.

## Running the Example

```bash
go run examples/pdb-primitive/main.go
```

This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile through four spec variations, printing the PDB disruption policy after each cycle.
4. Print the resulting status conditions.
54 changes: 54 additions & 0 deletions examples/pdb-primitive/app/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Package app provides a sample controller using the PDB primitive.
package app

import (
"context"

sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
"github.com/sourcehawk/operator-component-framework/pkg/component"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// ExampleController reconciles an ExampleApp object using the component framework.
type ExampleController struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
Metrics component.Recorder

// NewPDBResource is a factory function to create the PDB resource.
// This allows us to inject the resource construction logic.
NewPDBResource func(*sharedapp.ExampleApp) (component.Resource, error)
}

// Reconcile performs the reconciliation for a single ExampleApp.
func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error {
// 1. Build the PDB resource for this owner.
pdbResource, err := r.NewPDBResource(owner)
if err != nil {
return err
}

// 2. Build the component that manages the PDB.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(pdbResource, component.ResourceOptions{}).
Build()
if err != nil {
return err
}

// 3. Execute the component reconciliation.
resCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}

return comp.Reconcile(ctx, resCtx)
}
Loading
Loading