Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
175450c
Add ReplicaSet primitive with editor, builder, mutator, handlers, and…
sourcehawk Mar 22, 2026
9b83617
Add ReplicaSet primitive documentation
sourcehawk Mar 22, 2026
7fdff1c
Add ReplicaSet primitive example
sourcehawk Mar 22, 2026
3870cc2
Fix DefaultFieldApplicator bug and docs from Copilot review
sourcehawk Mar 22, 2026
d8bc810
Add resource-level tests for ReplicaSet primitive
sourcehawk Mar 22, 2026
6ab1aa7
Preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
ed059bf
Update pkg/primitives/replicaset/resource.go
sourcehawk Mar 22, 2026
52c6661
Fix range variable pointer bug in findEnv test helper
sourcehawk Mar 23, 2026
9b6f00c
Replace t.Fatalf/t.Errorf with testify require/assert in replicaset m…
sourcehawk Mar 23, 2026
ade78f2
Export BeginFeature() in replicaset mutator to satisfy FeatureMutator…
sourcehawk Mar 23, 2026
318fee5
Merge remote-tracking branch 'origin/main' into feature/replicaset-pr…
sourcehawk Mar 24, 2026
d7c80a7
Add ObservedGeneration guard to ReplicaSet DefaultConvergingStatusHan…
sourcehawk Mar 24, 2026
d706f93
Fix BeginFeature docstring to remove reference to nonexistent EndFeat…
sourcehawk Mar 24, 2026
8a6e73b
Merge remote-tracking branch 'origin/main' into feature/replicaset-pr…
sourcehawk Mar 24, 2026
de71e8d
Fix ReplicaSet Mutator to not create initial feature plan on construc…
sourcehawk Mar 24, 2026
3715126
Merge remote-tracking branch 'origin/main' into feature/replicaset-pr…
sourcehawk Mar 25, 2026
9774c7e
Remove field applicators and flavors from ReplicaSet primitive for SS…
sourcehawk Mar 25, 2026
84003e5
Fix lint errors: gofmt formatting and missing package comment
sourcehawk Mar 25, 2026
f52bb3e
Address Copilot review: docs discoverability, accuracy, and Makefile …
sourcehawk Mar 25, 2026
915b110
fix readme
sourcehawk Mar 25, 2026
e7bfc2d
Check Build() errors in resource tests to prevent nil-resource panics
sourcehawk Mar 25, 2026
d06267f
Merge branch 'main' into feature/replicaset-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 @@ -122,6 +122,7 @@ build-examples: ## Build all example binaries.
run-examples: ## Run all examples to verify they execute without error.
go run ./examples/deployment-primitive/.
go run ./examples/configmap-primitive/.
go run ./examples/replicaset-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.

The PR checklist states “Does not modify shared files,” but this PR modifies shared repo files (e.g., Makefile, docs/primitives.md, and adds pkg/mutation/editors/replicasetspec.go). Update the PR description/checklist to reflect the actual scope, or adjust the change set if those shared modifications weren’t intended.

Copilot uses AI. Check for mistakes.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
go run ./examples/hpa-primitive/.
Expand Down
3 changes: 2 additions & 1 deletion docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource:
| `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 |
| `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 |
Expand Down Expand Up @@ -148,6 +149,7 @@ have been applied. This means a single mutation can safely add a container and t
| Primitive | Category | Documentation |
| ----------------------------------- | ----------- | --------------------------------------------------------- |
| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) |
| `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) |
| `pkg/primitives/cronjob` | Integration | [cronjob.md](primitives/cronjob.md) |
| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) |
| `pkg/primitives/clusterrole` | Static | [clusterrole.md](primitives/clusterrole.md) |
Expand All @@ -156,7 +158,6 @@ have been applied. This means a single mutation can safely add a container and t
| `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) |
| `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) |


## Usage Examples

### Creating a primitive
Expand Down
178 changes: 178 additions & 0 deletions docs/primitives/replicaset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# ReplicaSet Primitive

The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It
integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and
metadata.
Comment on lines +1 to +5
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.

The docs for this new primitive aren’t discoverable from the primitives index (docs/primitives.md currently has no mention of “replicaset”). Please add an entry/link there so users can find this page from the main primitives documentation.

Copilot uses AI. Check for mistakes.

ReplicaSets are rarely managed directly — operators typically use Deployments. This primitive is provided for operators
that own ReplicaSets explicitly (e.g. custom rollout controllers).

## Capabilities

| Capability | Detail |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` |
| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` |
| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers |

## Building a ReplicaSet Primitive

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

base := &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
Name: "worker",
Namespace: owner.Namespace,
},
Spec: appsv1.ReplicaSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "worker"},
},
// baseline spec
},
}

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

## Mutations

Mutations are the primary mechanism for modifying a `ReplicaSet` 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) replicaset.Mutation {
return replicaset.Mutation{
Name: "my-feature",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *replicaset.Mutator) error {
// record edits here
return nil
},
}
}
```

Mutations are applied in the order they are registered with the builder.

### Boolean-gated mutations

Use `When(bool)` to gate a mutation on a runtime condition:

```go
func TracingMutation(version string, enabled bool) replicaset.Mutation {
return replicaset.Mutation{
Name: "tracing",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *replicaset.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "jaeger-agent",
Image: "jaegertracing/jaeger-agent:1.28",
})
return nil
},
}
}
```

## Internal Mutation Ordering

Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
order they are recorded:

| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
| 1 | Object metadata edits | Labels and annotations on the `ReplicaSet` object |
| 2 | ReplicaSetSpec edits | Replicas, min ready seconds |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` |
| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) |
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |

Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.

## Editors

### ReplicaSetSpecEditor

Controls replicaset-level settings via `m.EditReplicaSetSpec`.

Available methods: `SetReplicas`, `SetMinReadySeconds`, `Raw`.

```go
m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error {
e.SetReplicas(3)
e.SetMinReadySeconds(10)
return nil
})
```

Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object
passed to `NewBuilder`.

### PodSpecEditor

Manages pod-level configuration via `m.EditPodSpec`.

Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`,
`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`,
`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`.

```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
e.SetServiceAccountName("my-service-account")
return nil
})
```

### ContainerEditor

Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
[selector](../primitives.md#container-selectors).

Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.

```go
m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
return nil
})
```

### ObjectMetaEditor

Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or
`m.EditPodTemplateMetadata` to target the pod template.

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

## Convenience Methods

| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureReplicas(n)` | `EditReplicaSetSpec` → `SetReplicas(n)` |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |

## Guidance

**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run.

**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.

**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
the same mutation resolve correctly and reconciliation remains idempotent.

**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
33 changes: 33 additions & 0 deletions examples/replicaset-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ReplicaSet Primitive Example

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

- **Base Construction**: Initializing a ReplicaSet with basic metadata and spec.
- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the
`Mutator`.
- **Data Extraction**: Harvesting information from the reconciled resource.

## Directory Structure

- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework.
- `features/`: Contains modular feature definitions:
- `mutations.go`: sidecar injection, env vars, and version-based image updates.
- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the
`replicaset.Builder`.
- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client.

## Running the Example

You can run this example directly using `go run`:

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

Comment on lines +20 to +27
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.

This repository’s make run-examples target runs a hard-coded list of examples (see Makefile:121-126) and currently won’t execute this new replicaset example. Please update the Makefile to include go run ./examples/replicaset-primitive/. so CI/dev workflows actually exercise the new example.

Copilot uses AI. Check for mistakes.
This will:

1. Initialize a fake Kubernetes client.
2. Create an `ExampleApp` owner object.
3. Reconcile the `ExampleApp` components.
4. Print the resulting status conditions.
54 changes: 54 additions & 0 deletions examples/replicaset-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 replicaset primitive.
package app

import (
"context"

"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

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

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

// 2. Build the component that manages the replicaset.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(rsResource, component.ResourceOptions{}).
Suspend(owner.Spec.Suspended).
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)
}
20 changes: 20 additions & 0 deletions examples/replicaset-primitive/app/owner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package app

import (
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
)

// ExampleApp re-exports the shared CRD type so callers in this package need no import alias.
type ExampleApp = sharedapp.ExampleApp

// ExampleAppSpec re-exports the shared spec type.
type ExampleAppSpec = sharedapp.ExampleAppSpec

// ExampleAppStatus re-exports the shared status type.
type ExampleAppStatus = sharedapp.ExampleAppStatus

// ExampleAppList re-exports the shared list type.
type ExampleAppList = sharedapp.ExampleAppList

// AddToScheme registers the ExampleApp types with the given scheme.
var AddToScheme = sharedapp.AddToScheme
76 changes: 76 additions & 0 deletions examples/replicaset-primitive/features/mutations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Package features provides feature plan mutations for the replicaset-primitive example.
package features

import (
"fmt"

"github.com/sourcehawk/operator-component-framework/pkg/feature"
"github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
"github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset"
corev1 "k8s.io/api/core/v1"
)

// TracingFeature adds a Jaeger sidecar to the replicaset.
func TracingFeature(enabled bool) replicaset.Mutation {
return replicaset.Mutation{
Name: "Tracing",
Feature: feature.NewResourceFeature("any", nil).When(enabled),
Mutate: func(m *replicaset.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "jaeger-agent",
Image: "jaegertracing/jaeger-agent:1.28",
})

m.EnsureContainerEnvVar(corev1.EnvVar{
Name: "JAEGER_AGENT_HOST",
Value: "localhost",
})

return nil
},
}
}

// MetricsFeature adds an exporter sidecar and some annotations.
func MetricsFeature(enabled bool, port int) replicaset.Mutation {
return replicaset.Mutation{
Name: "Metrics",
Feature: feature.NewResourceFeature("any", nil).When(enabled),
Mutate: func(m *replicaset.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "prometheus-exporter",
Image: "prom/node-exporter:v1.3.1",
})

m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error {
meta.EnsureAnnotation("prometheus.io/scrape", "true")
meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port))
return nil
})

return nil
},
}
}

// VersionFeature sets the image version and a label.
func VersionFeature(version string) replicaset.Mutation {
return replicaset.Mutation{
Name: "Version",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *replicaset.Mutator) error {
m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error {
ce.Raw().Image = fmt.Sprintf("my-app:%s", version)
return nil
})

m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error {
meta.EnsureLabel("app.kubernetes.io/version", version)
return nil
})

return nil
},
}
}
Loading
Loading