-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement replicaset primitive #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
175450c
9b83617
7fdff1c
3870cc2
d8bc810
6ab1aa7
ed059bf
52c6661
9b6f00c
ade78f2
318fee5
d7c80a7
d706f93
8a6e73b
de71e8d
3715126
9774c7e
84003e5
f52bb3e
915b110
e7bfc2d
d06267f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
|
||
|
|
||
| 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. | ||
| 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
|
||
| 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. | ||
| 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) | ||
| } |
| 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 |
| 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 | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
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 addspkg/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.