add cost budget, runtime cost estimator and metrics (#964)
Post / images (amd64) (push) Failing after 6m56s
Post / images (arm64) (push) Failing after 6m41s
Post / image manifest (push) Has been skipped
Post / trigger clusteradm e2e (push) Has been skipped
Post / coverage (push) Failing after 27m27s
Scorecard supply-chain security / Scorecard analysis (push) Failing after 4m7s
Close stale issues and PRs / stale (push) Successful in 34s

Signed-off-by: Qing Hao <qhao@redhat.com>
This commit is contained in:
Qing Hao
2025-04-30 08:15:22 +00:00
committed by GitHub
parent e6b51b1749
commit df87f528d7
13 changed files with 331 additions and 55 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ require (
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
open-cluster-management.io/addon-framework v0.12.1-0.20250407131028-9d436ffc2da7
open-cluster-management.io/api v0.16.2-0.20250425084048-6c5efe2ab15d
open-cluster-management.io/sdk-go v0.16.1-0.20250411154302-3a424961ead4
open-cluster-management.io/sdk-go v0.16.1-0.20250428032116-875454003818
sigs.k8s.io/cluster-inventory-api v0.0.0-20240730014211-ef0154379848
sigs.k8s.io/controller-runtime v0.20.2
sigs.k8s.io/kube-storage-version-migrator v0.0.6-0.20230721195810-5c8923c5ff96
+2 -2
View File
@@ -489,8 +489,8 @@ open-cluster-management.io/addon-framework v0.12.1-0.20250407131028-9d436ffc2da7
open-cluster-management.io/addon-framework v0.12.1-0.20250407131028-9d436ffc2da7/go.mod h1:7AEw1Sq9UEWpQGTU8zV1XPNkFRBYPbyBh8tfhISV++s=
open-cluster-management.io/api v0.16.2-0.20250425084048-6c5efe2ab15d h1:uowhuu3pDvGLMU9if/xdRr1IO+DPGlBUqcqd0SpMjqY=
open-cluster-management.io/api v0.16.2-0.20250425084048-6c5efe2ab15d/go.mod h1:/OeqXycNBZQoe3WG6ghuWsMgsKGuMZrK8ZpsU6gWL0Y=
open-cluster-management.io/sdk-go v0.16.1-0.20250411154302-3a424961ead4 h1:PT6kDaKjDi0EaQyNzIWzYAYeK4QpBHbm+/7VPrpJEkY=
open-cluster-management.io/sdk-go v0.16.1-0.20250411154302-3a424961ead4/go.mod h1:FtOYjn5dL8e9S1gzNb8cBNsFzHJ1F3cpmCo+qrltido=
open-cluster-management.io/sdk-go v0.16.1-0.20250428032116-875454003818 h1:b7HpdTpKPzLEoJ5UtrXCed1PjxaKOxEboJ+kG6FZudI=
open-cluster-management.io/sdk-go v0.16.1-0.20250428032116-875454003818/go.mod h1:n89YVVoi5zm3KVpOyVMmTdD4rGOVSsykUtu7Ol3do3M=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/cluster-inventory-api v0.0.0-20240730014211-ef0154379848 h1:WYPi2PdQyZwZkHG648v2jQl6deyCgyjJ0fkLYgUJ618=
+10 -1
View File
@@ -15,6 +15,7 @@ const (
SchedulingDurationKey = "scheduling_duration_seconds"
BindDurationKey = "bind_duration_seconds"
PluginDurationKey = "plugin_duration_seconds"
CelRuntimeDurationKey = "cel_runtime_duration_seconds"
)
// Metric histograms for tracking various durations.
@@ -43,8 +44,16 @@ var (
Buckets: k8smetrics.ExponentialBuckets(10e-7, 10, 10),
}, []string{"name", "plugin_type", "plugin_name"})
CelDuration = k8smetrics.NewHistogramVec(&k8smetrics.HistogramOpts{
Subsystem: SchedulingSubsystem,
Name: CelRuntimeDurationKey,
StabilityLevel: k8smetrics.ALPHA,
Help: "How long in seconds CEL expressions validation runs for a placement.",
Buckets: k8smetrics.ExponentialBuckets(10e-7, 10, 10),
}, []string{"name"})
metrics = []k8smetrics.Registerable{
schedulingDuration, bindDuration, PluginDuration,
schedulingDuration, bindDuration, PluginDuration, CelDuration,
}
)
@@ -785,7 +785,7 @@ func filterClustersBySelector(
) ([]clusterapiv1beta1.ClusterDecision, *framework.Status) {
var matched []clusterapiv1beta1.ClusterDecision
// set CEL env to nil since placement decision groups do not support CEL expressions.
clusterSelector, err := helpers.NewClusterSelector(selector, nil)
clusterSelector, err := helpers.NewClusterSelector(selector, nil, nil)
if err != nil {
status := framework.NewStatus("", framework.Misconfigured, err.Error())
return matched, status
+129 -26
View File
@@ -2,8 +2,12 @@ package helpers
import (
"context"
"math"
"time"
"github.com/google/cel-go/cel"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
celconfig "k8s.io/apiserver/pkg/apis/cel"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/klog/v2"
@@ -11,8 +15,12 @@ import (
clusterapiv1 "open-cluster-management.io/api/cluster/v1"
ocmcelcommon "open-cluster-management.io/sdk-go/pkg/cel/common"
ocmcellibrary "open-cluster-management.io/sdk-go/pkg/cel/library"
"open-cluster-management.io/ocm/pkg/placement/controllers/metrics"
)
var globalCostBudget = int64(celconfig.RuntimeCELCostBudget)
// CompilationResult represents the compilation result of a single CEL expression,
// containing either a valid program or an error.
type CompilationResult struct {
@@ -23,9 +31,10 @@ type CompilationResult struct {
// CELSelector handles CEL-based cluster selection by managing a set of CEL expressions
// and their compilation results.
type CELSelector struct {
env *cel.Env // CEL environment with registered libraries
celExpressions []string // Raw CEL expressions to evaluate
compilationResult []CompilationResult // Cached compilation results
env *cel.Env // CEL environment with registered libraries
metricsRecorder *metrics.ScheduleMetrics // Metrics recorder
celExpressions []string // Raw CEL expressions to evaluate
compilationResult []CompilationResult // Cached compilation results
}
// NewEnv creates a new CEL environment with managed cluster and JSON libraries.
@@ -38,10 +47,18 @@ func NewEnv(scoreLister clusterlisterv1alpha1.AddOnPlacementScoreLister) (*cel.E
return cel.NewEnv(envOpts...)
}
// newEstimator creates a new cost estimator for CEL expressions.
func newEstimator() *ocmcelcommon.BaseEnvCostEstimator {
return &ocmcelcommon.BaseEnvCostEstimator{
CostEstimator: &ocmcellibrary.CostEstimator{},
}
}
// NewCELSelector creates a new CEL selector with the given environment and expressions.
func NewCELSelector(env *cel.Env, expressions []string) *CELSelector {
func NewCELSelector(env *cel.Env, expressions []string, metricsRecorder *metrics.ScheduleMetrics) *CELSelector {
return &CELSelector{
env: env,
metricsRecorder: metricsRecorder,
celExpressions: expressions,
compilationResult: make([]CompilationResult, len(expressions)),
}
@@ -54,6 +71,7 @@ func (c *CELSelector) Compile() []CompilationResult {
return c.compilationResult
}
estimator := newEstimator()
for i, expr := range c.celExpressions {
ast, issues := c.env.Compile(expr)
if issues != nil {
@@ -64,7 +82,12 @@ func (c *CELSelector) Compile() []CompilationResult {
continue
}
prg, err := c.env.Program(ast)
prg, err := c.env.Program(ast,
cel.CostLimit(celconfig.PerCallLimit),
cel.CostTracking(estimator),
cel.InterruptCheckFrequency(celconfig.CheckFrequency),
)
if err != nil {
c.compilationResult[i].Error = &apiservercel.Error{
Type: apiservercel.ErrorTypeInvalid,
@@ -79,40 +102,120 @@ func (c *CELSelector) Compile() []CompilationResult {
}
// Validate evaluates all compiled CEL expressions against a managed cluster.
// Returns true only if all expressions evaluate to true.
// Note: Compile() must be called before calling this method to ensure expressions are properly compiled.
// If Compile() has not been called, this method will return false.
func (c *CELSelector) Validate(ctx context.Context, cluster *clusterapiv1.ManagedCluster) bool {
// Returns (true, cost) if all expressions evaluate to true and within cost budget.
// Returns (false, cost) if validation fails.
func (c *CELSelector) Validate(ctx context.Context, cluster *clusterapiv1.ManagedCluster) (bool, int64) {
logger := klog.FromContext(ctx)
// Convert cluster to format required by CEL
convertedCluster, err := ocmcelcommon.ConvertObjectToUnstructured(cluster)
if err != nil {
logger.Error(err, "Failed to convert cluster to unstructured format", "cluster", cluster.Name)
return false
return false, -1
}
startTime := time.Now()
ok, remainingBudget := c.evaluateAllExpressions(ctx, convertedCluster, globalCostBudget)
if c.metricsRecorder != nil {
metrics.CelDuration.WithLabelValues(metrics.SchedulingName).Observe(c.metricsRecorder.SinceInSeconds(startTime))
}
cost := globalCostBudget - remainingBudget
return ok, cost
}
// evaluateAllExpressions evaluates each CEL expression in sequence.
// Returns (true, remainingBudget) if all expressions succeed, otherwise (false, budget at failure).
func (c *CELSelector) evaluateAllExpressions(ctx context.Context, cluster *unstructured.Unstructured, budget int64) (bool, int64) {
logger := klog.FromContext(ctx)
remainingBudget := budget
for i, compiled := range c.compilationResult {
if !isValidProgram(compiled) {
// Validate program compilation
if !c.isProgramValid(compiled) {
logger.Info("Validation failed: invalid compiled program", "rule", c.celExpressions[i])
return false
return false, remainingBudget
}
result, _, err := compiled.Program.Eval(map[string]interface{}{
"managedCluster": convertedCluster.Object,
})
if err != nil {
logger.Error(err, "Evaluation failed", "rule", c.celExpressions[i])
return false
}
if value, ok := result.Value().(bool); !ok || !value {
return false
// Evaluate single expression
ok, newBudget := c.evaluateSingleExpression(ctx, compiled.Program, cluster, remainingBudget, i)
if !ok {
return false, newBudget
}
remainingBudget = newBudget
}
return true
return true, remainingBudget
}
// isValidProgram checks if a compilation result contains a valid program.
func isValidProgram(compiled CompilationResult) bool {
// isProgramValid checks if a compilation result contains a valid program
func (c *CELSelector) isProgramValid(compiled CompilationResult) bool {
return compiled.Program != nil && compiled.Error == nil
}
// evaluateSingleExpression evaluates one CEL expression and handles its cost accounting.
// Returns (true, newBudget) if evaluation succeeds, otherwise (false, -1 or remaining budget).
func (c *CELSelector) evaluateSingleExpression(
ctx context.Context,
program cel.Program,
cluster *unstructured.Unstructured,
budget int64,
index int,
) (bool, int64) {
logger := klog.FromContext(ctx)
// Evaluate the expression
evalResult, evalDetails, err := program.ContextEval(ctx, map[string]interface{}{
"managedCluster": cluster.Object,
})
// Cost calculation
ok, rtCost := c.costCalculation(ctx, evalDetails, budget, index)
if !ok {
return false, -1
}
remainingBudget := budget - rtCost
// Handle evaluation error
if err != nil {
logger.Info("Expression evaluation failed", "rule", c.celExpressions[index], "cluster", cluster.GetName(), "err", err)
return false, remainingBudget
}
// Check expression result
if value, ok := evalResult.Value().(bool); !ok || !value {
return false, remainingBudget
}
return true, remainingBudget
}
// costCalculation processes the cost details of an evaluation
func (c *CELSelector) costCalculation(ctx context.Context, evalDetails *cel.EvalDetails, budget int64, index int) (bool, int64) {
logger := klog.FromContext(ctx)
// Check if cost details are available
if evalDetails == nil {
logger.Info("Runtime cost calculation failed: no evaluation details",
"rule", c.celExpressions[index])
return false, -1
}
rtCost := evalDetails.ActualCost()
if rtCost == nil {
logger.Info("Runtime cost calculation failed: no cost information",
"rule", c.celExpressions[index])
return false, -1
}
// Validate cost against budget
if *rtCost > math.MaxInt64 || int64(*rtCost) > budget {
logger.Info("Cost budget exceeded",
"rule", c.celExpressions[index],
"cost", *rtCost,
"budget", budget)
return false, -1
}
// Safe to convert since we checked for overflow
return true, int64(*rtCost) //nolint:gosec
}
+36 -8
View File
@@ -20,6 +20,7 @@ func TestCELSelector(t *testing.T) {
expressions []string
cluster *clusterapiv1.ManagedCluster
expectedMatch bool
expectedCost int64
expectCompileError bool
}{
{
@@ -33,6 +34,7 @@ func TestCELSelector(t *testing.T) {
},
},
expectedMatch: true,
expectedCost: 5,
},
{
name: "valid expression no match",
@@ -45,6 +47,7 @@ func TestCELSelector(t *testing.T) {
},
},
expectedMatch: false,
expectedCost: 5,
},
{
name: "invalid expression",
@@ -54,38 +57,59 @@ func TestCELSelector(t *testing.T) {
cluster: &clusterapiv1.ManagedCluster{},
expectCompileError: true,
expectedMatch: false,
expectedCost: 5,
},
{
name: "multiple expressions all match",
expressions: []string{
`managedCluster.metadata.labels["env"] == "prod"`,
`managedCluster.metadata.labels["region"] == "us-east-1"`,
`semver(managedCluster.metadata.labels["version"]).isLessThan(semver("1.31.0"))`,
},
cluster: &clusterapiv1.ManagedCluster{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"env": "prod",
"region": "us-east-1",
"env": "prod",
"version": "1.30.0",
},
},
},
expectedMatch: true,
expectedCost: 12,
},
{
name: "multiple expressions one fails",
expressions: []string{
`managedCluster.metadata.labels["env"] == "prod"`,
`managedCluster.metadata.labels["region"] == "us-east-1"`,
`semver(managedCluster.metadata.labels["version"]).isGreaterThan(semver("1.31.0"))`,
},
cluster: &clusterapiv1.ManagedCluster{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"env": "prod",
"region": "us-west-1",
"env": "prod",
"version": "1.30.0",
},
},
},
expectedMatch: false,
expectedCost: 12,
},
{
name: "multiple expressions running out of cost budget",
expressions: []string{
`managedCluster.metadata.labels["env"] == "prod"`,
`semver(managedCluster.metadata.labels["version"]).isLessThan(semver("1.31.0"))`,
`semver(managedCluster.metadata.labels["version"]).isLessThan(semver("1.31.0"))`,
},
cluster: &clusterapiv1.ManagedCluster{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"env": "prod",
"version": "1.30.0",
},
},
},
expectedMatch: false,
expectedCost: 16,
},
{
name: "nil cluster",
@@ -95,6 +119,7 @@ func TestCELSelector(t *testing.T) {
cluster: nil,
expectedMatch: false,
expectCompileError: false,
expectedCost: 3,
},
{
name: "empty expressions",
@@ -102,12 +127,14 @@ func TestCELSelector(t *testing.T) {
cluster: &clusterapiv1.ManagedCluster{},
expectedMatch: true,
expectCompileError: false,
expectedCost: 0,
},
}
globalCostBudget = 15
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
selector := NewCELSelector(env, test.expressions)
selector := NewCELSelector(env, test.expressions, nil)
results := selector.Compile()
if test.expectCompileError {
@@ -121,8 +148,9 @@ func TestCELSelector(t *testing.T) {
assert.Nil(t, result.Error)
}
match := selector.Validate(context.TODO(), test.cluster)
match, cost := selector.Validate(context.TODO(), test.cluster)
assert.Equal(t, test.expectedMatch, match)
assert.Equal(t, test.expectedCost, cost)
})
}
}
+5 -3
View File
@@ -9,6 +9,8 @@ import (
clusterapiv1 "open-cluster-management.io/api/cluster/v1"
clusterapiv1beta1 "open-cluster-management.io/api/cluster/v1beta1"
"open-cluster-management.io/ocm/pkg/placement/controllers/metrics"
)
type ClusterSelector struct {
@@ -17,7 +19,7 @@ type ClusterSelector struct {
celSelector *CELSelector
}
func NewClusterSelector(selector clusterapiv1beta1.ClusterSelector, env *cel.Env) (*ClusterSelector, error) {
func NewClusterSelector(selector clusterapiv1beta1.ClusterSelector, env *cel.Env, metricsRecorder *metrics.ScheduleMetrics) (*ClusterSelector, error) {
// build label selector
labelSelector, err := convertLabelSelector(&selector.LabelSelector)
if err != nil {
@@ -29,7 +31,7 @@ func NewClusterSelector(selector clusterapiv1beta1.ClusterSelector, env *cel.Env
return nil, err
}
// build cel selector
celSelector := NewCELSelector(env, selector.CelSelector.CelExpressions)
celSelector := NewCELSelector(env, selector.CelSelector.CelExpressions, metricsRecorder)
return &ClusterSelector{
labelSelector: labelSelector,
claimSelector: claimSelector,
@@ -63,7 +65,7 @@ func (c *ClusterSelector) Matches(ctx context.Context, cluster *clusterapiv1.Man
// match with cel selector if exists
if c.celSelector != nil {
if ok := c.celSelector.Validate(ctx, cluster); !ok {
if ok, _ := c.celSelector.Validate(ctx, cluster); !ok {
return false
}
}
+1 -1
View File
@@ -196,7 +196,7 @@ func TestMatches(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
clusterSelector, err := NewClusterSelector(c.clusterselector, env)
clusterSelector, err := NewClusterSelector(c.clusterselector, env, nil)
if err != nil {
t.Errorf("unexpected err: %v", err)
}
+1 -1
View File
@@ -63,7 +63,7 @@ func (p *Predicate) Filter(
// prebuild label/claim/cel selectors for each predicate
clusterSelectors := []*helpers.ClusterSelector{}
for _, predicate := range placement.Spec.Predicates {
clusterSelector, err := helpers.NewClusterSelector(predicate.RequiredClusterSelector, env)
clusterSelector, err := helpers.NewClusterSelector(predicate.RequiredClusterSelector, env, p.handle.MetricsRecorder())
if err != nil {
return plugins.PluginFilterResult{}, framework.NewStatus(
p.Name(),
+1 -1
View File
@@ -1782,7 +1782,7 @@ open-cluster-management.io/api/operator/v1
open-cluster-management.io/api/utils/work/v1/workapplier
open-cluster-management.io/api/work/v1
open-cluster-management.io/api/work/v1alpha1
# open-cluster-management.io/sdk-go v0.16.1-0.20250411154302-3a424961ead4
# open-cluster-management.io/sdk-go v0.16.1-0.20250428032116-875454003818
## explicit; go 1.23.6
open-cluster-management.io/sdk-go/pkg/apis/cluster/v1alpha1
open-cluster-management.io/sdk-go/pkg/apis/cluster/v1beta1
+20 -1
View File
@@ -4,12 +4,15 @@ import (
"reflect"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/ext"
"github.com/google/cel-go/interpreter"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/cel/library"
)
// BaseEnvOpts defines the base CEL environment options
var BaseEnvOpts = []cel.EnvOption{
cel.OptionalTypes(),
ext.Strings(),
@@ -20,9 +23,25 @@ var BaseEnvOpts = []cel.EnvOption{
library.IP(),
library.CIDR(),
library.Format(),
library.SemverLib(),
}
// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object.
// BaseEnvCostEstimator implements CEL's interpretable.ActualCostEstimator
type BaseEnvCostEstimator struct {
// Wraps a CEL cost estimator with additional functionality
CostEstimator interpreter.ActualCostEstimator
}
// CallCost implements runtime cost estimation for CEL function calls
func (b *BaseEnvCostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
if b.CostEstimator != nil {
return b.CostEstimator.CallCost(function, overloadId, args, result)
}
k8sEstimator := &library.CostEstimator{}
return k8sEstimator.CallCost(function, overloadId, args, result)
}
// ConvertObjectToUnstructured converts any object to an unstructured.Unstructured object
func ConvertObjectToUnstructured(obj interface{}) (*unstructured.Unstructured, error) {
if obj == nil || reflect.ValueOf(obj).IsNil() {
return &unstructured.Unstructured{Object: nil}, nil
+94
View File
@@ -0,0 +1,94 @@
package library
import (
"math"
"github.com/google/cel-go/common"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
)
// CostEstimator implements CEL's interpretable.ActualCostEstimator for runtime cost estimation
type CostEstimator struct{}
func actualSize(value ref.Val) uint64 {
if sz, ok := value.(traits.Sizer); ok {
return uint64(sz.Size().(types.Int))
}
return 1
}
// CallCost calculates the runtime cost for CEL function calls
func (l *CostEstimator) CallCost(function, overloadId string, args []ref.Val, result ref.Val) *uint64 {
switch function {
case "scores":
// each scores returns a list
var totalCost uint64 = common.ListCreateBaseCost
if result != nil {
if lister, ok := result.(traits.Lister); ok {
// each item is a map
size := uint64(lister.Size().(types.Int))
totalCost += size * (common.SelectAndIdentCost + common.MapCreateBaseCost)
}
}
return &totalCost
case "parseJSON":
var totalCost uint64 = common.MapCreateBaseCost
if len(args) >= 1 {
// Calculate the traversal cost of input string
inputSize := actualSize(args[0])
traversalCost := uint64(math.Ceil(float64(inputSize) * common.StringTraversalCostFactor))
// Recursively calculate the cost of result structure
totalCost = traversalCost + calculateStructCost(result)
}
return &totalCost
default:
return nil
}
}
// calculateStructCost recursively calculates the cost of data structures
func calculateStructCost(val ref.Val) uint64 {
if val == nil {
return 0
}
switch v := val.(type) {
case traits.Mapper:
return calculateMapCost(v)
case traits.Lister:
return calculateListCost(v)
default:
return common.ConstCost
}
}
// calculateMapCost computes cost for map structures
func calculateMapCost(v traits.Mapper) uint64 {
cost := uint64(common.MapCreateBaseCost)
it := v.Iterator()
for it.HasNext() == types.True {
key := it.Next()
if value := v.Get(key); value != nil {
cost += calculateStructCost(value)
}
}
return cost
}
// calculateListCost computes cost for list structures
func calculateListCost(v traits.Lister) uint64 {
cost := uint64(common.ListCreateBaseCost)
size := v.Size().(types.Int)
for i := types.Int(0); i < size; i++ {
if item := v.Get(types.Int(i)); item != nil {
cost += calculateStructCost(item)
}
}
return cost
}
@@ -16,6 +16,7 @@ import (
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/strategicpatch"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"
@@ -24,9 +25,25 @@ import (
"open-cluster-management.io/sdk-go/pkg/cloudevents/generic"
)
// Patch applies the patch to a resource with the patch type.
func Patch[T generic.ResourceObject](patchType types.PatchType, work T, patchData []byte) (resource T, err error) {
workData, err := json.Marshal(work)
// Patch applies the given patch to a `generic.ResourceObject` using the specified patch type.
//
// Parameters:
// - patchType: The type of patch to apply (JSONPatchType, MergePatchType and StrategicMergePatchType are supported).
// - original: The resource object to be patched.
// - patchData: The raw patch data.
//
// Returns:
// - The patched resource object.
// - An error if the patching fails at any step.
//
// Notes on StrategicMergePatch:
// - Strategic Merge Patch (SMP) is a Kubernetes-specific patch type.
// - It relies on **struct tags** (e.g., `patchStrategy` and `patchMergeKey`) defined in the Go types
// of Kubernetes API objects to determine how to merge lists and maps (e.g., merge by key instead of replacing).
// - SMP **only works** on known Kubernetes built-in API types (e.g., corev1.Pod) that have these metadata tags.
// - It will **fail or behave incorrectly** if used on CRDs or custom types that dont have the necessary tags.
func Patch[T generic.ResourceObject](patchType types.PatchType, original T, patchData []byte) (resource T, err error) {
originalData, err := json.Marshal(original)
if err != nil {
return resource, err
}
@@ -39,13 +56,17 @@ func Patch[T generic.ResourceObject](patchType types.PatchType, work T, patchDat
if err != nil {
return resource, err
}
patchedData, err = patchObj.Apply(workData)
patchedData, err = patchObj.Apply(originalData)
if err != nil {
return resource, err
}
case types.MergePatchType:
patchedData, err = jsonpatch.MergePatch(workData, patchData)
patchedData, err = jsonpatch.MergePatch(originalData, patchData)
if err != nil {
return resource, err
}
case types.StrategicMergePatchType:
patchedData, err = strategicpatch.StrategicMergePatch(originalData, patchData, original)
if err != nil {
return resource, err
}
@@ -53,12 +74,12 @@ func Patch[T generic.ResourceObject](patchType types.PatchType, work T, patchDat
return resource, fmt.Errorf("unsupported patch type: %s", patchType)
}
patchedWork := new(T)
if err := json.Unmarshal(patchedData, patchedWork); err != nil {
patchedResource := new(T)
if err := json.Unmarshal(patchedData, patchedResource); err != nil {
return resource, err
}
return *patchedWork, nil
return *patchedResource, nil
}
// ListResourcesWithOptions retrieves the resources from store which matches the options.