Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e465185
Add HPASpecEditor for HorizontalPodAutoscaler spec mutations
sourcehawk Mar 22, 2026
b149934
Add HPA primitive package with builder, resource, mutator, flavors, a…
sourcehawk Mar 22, 2026
b923032
Add HPA primitive documentation
sourcehawk Mar 22, 2026
06cb44b
Add HPA primitive example
sourcehawk Mar 22, 2026
1a072c3
Fix HPA documentation per Copilot review feedback
sourcehawk Mar 22, 2026
8f98c11
Add HPA primitive to built-in primitives index
sourcehawk Mar 22, 2026
577021d
Fix HPA handlers per Copilot review: explicit ConditionUnknown case, …
sourcehawk Mar 22, 2026
53f9a7b
preserve server-managed metadata in default field applicator
sourcehawk Mar 22, 2026
ffd3051
Fix misleading HPA pending reason and strengthen metric identity matc…
sourcehawk Mar 23, 2026
df84e70
default suspension behavior is noop
sourcehawk Mar 23, 2026
14482d4
Fix docs and comments to match default no-op HPA suspension behavior
sourcehawk Mar 23, 2026
117dd67
fix: remove beginFeature call from HPA mutator constructor
sourcehawk Mar 23, 2026
1854325
fix: preserve HPA status in DefaultFieldApplicator and add test coverage
sourcehawk Mar 23, 2026
b47709d
fix: export BeginFeature to match updated FeatureMutator interface
sourcehawk Mar 23, 2026
43adab2
Merge main into feature/hpa-primitive, resolve primitives.md table co…
sourcehawk Mar 24, 2026
f5682d7
style: apply markdown formatting from fmt-md
sourcehawk Mar 24, 2026
96ae39b
fix: preserve server-managed metadata in WithCustomFieldApplicator ex…
sourcehawk Mar 24, 2026
25d6c3c
fix: address Copilot review feedback for HPA primitive
sourcehawk Mar 24, 2026
e0ae521
fix: delete HPA on suspend to prevent scaling interference
sourcehawk Mar 24, 2026
33d718f
Merge remote-tracking branch 'origin/main' into feature/hpa-primitive
sourcehawk Mar 24, 2026
4c32cd7
fix: do not construct HPA mutator with initial feature plan
sourcehawk Mar 24, 2026
9af9be3
fix: address Copilot review feedback for docs and Makefile
sourcehawk Mar 24, 2026
f94e37e
fix: make default suspension reason deletion-agnostic
sourcehawk Mar 24, 2026
3bdb9e5
fix: use semantic label selector comparison in metric identity matching
sourcehawk Mar 24, 2026
433c8cb
docs: reflow line wrapping in HPA suspension section
sourcehawk Mar 24, 2026
cac2e39
Merge remote-tracking branch 'origin/main' into feature/hpa-primitive
sourcehawk Mar 25, 2026
abf2c74
refactor: remove field applicators and flavors from HPA primitive
sourcehawk Mar 25, 2026
17fcdda
fix: remove references to deleted field applicators and flavors in ex…
sourcehawk Mar 25, 2026
7fa5c3b
docs: remove remaining flavor references from HPA example README
sourcehawk Mar 25, 2026
e5e79e0
fix: address PR review comments for HPA primitive
sourcehawk Mar 25, 2026
9e42ab4
fix docs
sourcehawk Mar 25, 2026
a58b3c0
Merge branch 'main' into feature/hpa-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 @@ -124,6 +124,7 @@ run-examples: ## Run all examples to verify they execute without error.
go run ./examples/configmap-primitive/.
go run ./examples/rolebinding-primitive/.
go run ./examples/custom-resource-implementation/.
go run ./examples/hpa-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.

PR description checklist says this PR "does not modify shared files", but this change modifies shared repo files (e.g. Makefile, docs, and shared mutation editors). Please update the PR description/checklist to reflect the actual scope (or split changes if the intent really was to avoid shared-file modifications).

Copilot uses AI. Check for mistakes.
go run ./examples/clusterrolebinding-primitive/.

##@ E2E Testing
Expand Down
1 change: 1 addition & 0 deletions docs/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ have been applied. This means a single mutation can safely add a container and t
| `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) |
| `pkg/primitives/hpa` | Integration | [hpa.md](primitives/hpa.md) |

## Usage Examples

Expand Down
347 changes: 347 additions & 0 deletions docs/primitives/hpa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
# HorizontalPodAutoscaler (HPA) Primitive

The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes
`HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational,
Suspendable resource and provides a structured mutation API for configuring autoscaling behavior.

## Capabilities

| Capability | Detail |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` |
| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume |
| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata |
| **Data extraction** | Allows custom extraction from the reconciled HPA object via a registered data extractor (`WithDataExtractor`) |

## Building an HPA Primitive

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

base := &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
Name: "web-hpa",
Namespace: owner.Namespace,
},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
Name: "web",
},
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
},
}

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

## Mutations

Mutations are the primary mechanism for modifying an HPA 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 CPUMetricMutation(version string) hpa.Mutation {
return hpa.Mutation{
Name: "cpu-metric",
Feature: feature.NewResourceFeature(version, nil), // always enabled
Mutate: func(m *hpa.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

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

```go
func CustomMetricsMutation(version string, enabled bool) hpa.Mutation {
return hpa.Mutation{
Name: "custom-metrics",
Feature: feature.NewResourceFeature(version, nil).When(enabled),
Mutate: func(m *hpa.Mutator) error {
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.PodsMetricSourceType,
Pods: &autoscalingv2.PodsMetricSource{
Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"},
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.AverageValueMetricType,
AverageValue: ptr.To(resource.MustParse("100")),
},
},
})
return nil
})
return nil
},
}
}
```

### Version-gated mutations

Pass a `[]feature.VersionConstraint` to gate on a semver range:

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

func LegacyScalingMutation(version string) hpa.Mutation {
return hpa.Mutation{
Name: "legacy-scaling",
Feature: feature.NewResourceFeature(
version,
[]feature.VersionConstraint{legacyConstraint},
),
Mutate: func(m *hpa.Mutator) error {
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.SetMaxReplicas(5) // legacy apps limited to 5 replicas
return nil
})
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 grouped into categories and applied in a fixed sequence regardless of the
order they are recorded:

| Step | Category | What it affects |
| ---- | -------------- | -------------------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object |
| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior |

## Editors

### HPASpecEditor

Controls HPA-level settings via `m.EditHPASpec`.

Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`,
`SetBehavior`, `Raw`.

```go
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.SetMinReplicas(ptr.To(int32(2)))
e.SetMaxReplicas(10)
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceCPU,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: ptr.To(int32(80)),
},
},
})
return nil
})
```

#### EnsureMetric

`EnsureMetric` upserts a metric based on its full metric identity, not just type and name. Matching rules:

| Metric type | Match key |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| Resource | `Resource.Name` (e.g. `cpu`, `memory`) |
| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label selector; `nil` is a distinct identity) |
| Object | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` |
| ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` |
| External | `External.Metric.Name` + `External.Metric.Selector` (label selector; `nil` is a distinct identity) |

If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or
described objects result in different metric identities, even if the metric names are the same.

#### RemoveMetric

`RemoveMetric(type, name)` removes all metrics matching the given type and name. For ContainerResource metrics, all
container variants of the named resource are removed.

#### SetBehavior

`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom
behavior and use Kubernetes defaults.

```go
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleDown: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To(int32(300)),
},
})
return nil
})
```

For fields not covered by the typed API, use `Raw()`:

```go
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.Raw().MinReplicas = ptr.To(int32(1))
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/managed-by", "my-operator")
e.EnsureAnnotation("autoscaling.example.io/policy", "aggressive")
return nil
})
```

### Raw Escape Hatch

All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is
insufficient.

## Operational Status

The default operational status handler inspects `Status.Conditions`:

| Status | Condition |
| ------------- | ------------------------------------------------------- |
| `Operational` | `ScalingActive` is `True` |
| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` |
| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` |

`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not
operationally healthy regardless of what the scaling-active condition reports.

Override with `WithCustomOperationalStatus`:

```go
hpa.NewBuilder(base).
WithCustomOperationalStatus(func(op concepts.ConvergingOperation, h *autoscalingv2.HorizontalPodAutoscaler) (concepts.OperationalStatusWithReason, error) {
status, err := hpa.DefaultOperationalStatusHandler(op, h)
if err != nil {
return status, err
}
// Add custom logic
return status, nil
})
```

## Suspension

HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component is
suspended (`DefaultDeleteOnSuspendHandler` returns `true`). A retained HPA would conflict with the suspension of its
scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA controller continuously enforces
`minReplicas` and would scale the target back up. Deleting the HPA prevents this interference. On resume the framework
recreates the HPA with the desired spec.

The default suspension status handler reports `Suspended` immediately with the reason
`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. Override this handler with
`WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour.

Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale
target is managed externally and will not be present during suspension):

```go
hpa.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool {
return false // keep HPA during suspension
})
```

## Full Example: CPU and Memory Autoscaling

```go
func AutoscalingMutation(version string) hpa.Mutation {
return hpa.Mutation{
Name: "autoscaling-config",
Feature: feature.NewResourceFeature(version, nil),
Mutate: func(m *hpa.Mutator) error {
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
e.SetMinReplicas(ptr.To(int32(2)))
e.SetMaxReplicas(10)

// CPU-based scaling
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceCPU,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: ptr.To(int32(70)),
},
},
})

// Memory-based scaling
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceMemory,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: ptr.To(int32(80)),
},
},
})

// Conservative scale-down
e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleDown: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To(int32(300)),
},
})

return nil
})

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

return nil
},
}
}
```

Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per
the internal ordering. Order your source calls for readability — the framework handles execution order.

## 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.

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

**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name,
selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate.

**HPA deletion on suspend is the default.** The primitive's default `DeleteOnSuspend` decision removes the HPA during
component suspension (matching the "Suspension (delete)" capability). This prevents the Kubernetes HPA controller from
scaling the target back up while it is suspended. On resume the framework recreates the HPA with the desired spec. If
you need the HPA to be retained during suspension — for example, when the scale target is managed externally and will
not be present — override `WithCustomSuspendDeletionDecision` to return `false`.
35 changes: 35 additions & 0 deletions examples/hpa-primitive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# HPA Primitive Example

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

- **Base Construction**: Initializing an HPA with a scale target ref, min/max replicas, and labels.
- **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior)
using the `Mutator`.
- **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions.
- **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed during suspension to prevent it
from scaling the target back up.
- **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`: CPU metric, memory metric, and scale behavior feature mutations.
- `resources/`: Contains the central `NewHPAResource` factory that assembles all features using the `hpa.Builder`.
- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client.

## Running the Example

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

```bash
go run examples/hpa-primitive/main.go
```
Comment on lines +22 to +28
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This PR adds a new runnable example, but the repository’s make run-examples target runs a hard-coded list of examples (currently deployment/configmap/custom-resource-implementation) and won’t execute hpa-primitive. Please update the Makefile so CI/local make run-examples covers this new example as well (see Makefile:114-119).

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 through multiple spec changes.
4. Print the resulting status conditions and HPA state.
Loading
Loading