diff --git a/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering.go b/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering.go new file mode 100644 index 000000000..767293244 --- /dev/null +++ b/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering.go @@ -0,0 +1,29 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package sameplacementaffinity + +import ( + "context" + + fleetv1beta1 "go.goms.io/fleet/apis/placement/v1beta1" + "go.goms.io/fleet/pkg/scheduler/framework" +) + +// Filter allows the plugin to connect to the Filter extension point in the scheduling framework. +func (p *Plugin) Filter( + _ context.Context, + state framework.CycleStatePluginReadWriter, + _ *fleetv1beta1.ClusterSchedulingPolicySnapshot, + cluster *fleetv1beta1.MemberCluster, +) (status *framework.Status) { + if !state.HasScheduledOrBoundBindingFor(cluster.Name) { + // all done. + return nil + } + + reason := "resource placement has already been scheduled or bounded on the cluster" + return framework.NewNonErrorStatus(framework.ClusterUnschedulable, p.Name(), reason) +} diff --git a/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering_test.go b/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering_test.go new file mode 100644 index 000000000..3a93bb58a --- /dev/null +++ b/pkg/scheduler/framework/plugins/sameplacementaffinity/filtering_test.go @@ -0,0 +1,113 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +package sameplacementaffinity + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + fleetv1beta1 "go.goms.io/fleet/apis/placement/v1beta1" + "go.goms.io/fleet/pkg/scheduler/framework" +) + +const ( + clusterName = "member-1" +) + +var ( + cmpStatusOptions = cmp.Options{ + cmpopts.IgnoreFields(framework.Status{}, "reasons", "err"), + cmp.AllowUnexported(framework.Status{}), + } + defaultPluginName = defaultPluginOptions.name +) + +func TestFilter(t *testing.T) { + tests := []struct { + name string + scheduledOrBoundBindings []*fleetv1beta1.ClusterResourceBinding + want *framework.Status + }{ + { + name: "placement has already been scheduled", + scheduledOrBoundBindings: []*fleetv1beta1.ClusterResourceBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "binding1", + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: clusterName, + State: fleetv1beta1.BindingStateScheduled, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "binding2", + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: "another-cluster", + State: fleetv1beta1.BindingStateScheduled, + }, + }, + }, + want: framework.NewNonErrorStatus(framework.ClusterUnschedulable, defaultPluginName), + }, + { + name: "placement has already been bounded", + scheduledOrBoundBindings: []*fleetv1beta1.ClusterResourceBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "binding1", + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: clusterName, + State: fleetv1beta1.BindingStateBound, + }, + }, + }, + want: framework.NewNonErrorStatus(framework.ClusterUnschedulable, defaultPluginName), + }, + { + name: "no bindings", + scheduledOrBoundBindings: []*fleetv1beta1.ClusterResourceBinding{}, + want: nil, + }, + { + name: "placement has been bounded/scheduled on other cluster", + scheduledOrBoundBindings: []*fleetv1beta1.ClusterResourceBinding{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "binding1", + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: "another-cluster", + State: fleetv1beta1.BindingStateBound, + }, + }, + }, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := New() + state := framework.NewCycleState(nil, nil, tc.scheduledOrBoundBindings) + cluster := fleetv1beta1.MemberCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + }, + } + got := p.Filter(context.Background(), state, nil, &cluster) + if diff := cmp.Diff(tc.want, got, cmpStatusOptions); diff != "" { + t.Errorf("Filter() status mismatch (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/pkg/scheduler/framework/plugins/sameplacementaffinity/plugin.go b/pkg/scheduler/framework/plugins/sameplacementaffinity/plugin.go new file mode 100644 index 000000000..84d0b7b3a --- /dev/null +++ b/pkg/scheduler/framework/plugins/sameplacementaffinity/plugin.go @@ -0,0 +1,77 @@ +/* +Copyright (c) Microsoft Corporation. +Licensed under the MIT license. +*/ + +// Package sameplacementaffinity features a scheduler plugin that filters out any cluster that has been already scheduled/bounded +// to the resource placement and prefers the same cluster which has an obsolete binding. +package sameplacementaffinity + +import "go.goms.io/fleet/pkg/scheduler/framework" + +// Plugin is the scheduler plugin that enforces the same placement affinity and anti-affinity. +// "Affinity" means a scheduler prefers the cluster which has an obsolete binding of the same placement in order to +// minimize interruption between different scheduling runs. +// "Anti-Affinity" means a scheduler filters out the cluster which has been already scheduled/bounded as two placements +// cannot be scheduled on a cluster. +type Plugin struct { + // The name of the plugin. + name string + + // The framework handle. + handle framework.Handle +} + +var ( + // Verify that Plugin can connect to relevant extension points at compile time. + // + // This plugin leverages the following the extension points: + // * Filter + // * Score + // + // Note that successful connection to any of the extension points implies that the + // plugin already implements the Plugin interface. + _ framework.FilterPlugin = &Plugin{} + // TODO + // _ framework.ScorePlugin = &Plugin{} +) + +type samePlacementAntiAffinityPluginOptions struct { + // The name of the plugin. + name string +} + +type Option func(*samePlacementAntiAffinityPluginOptions) + +var defaultPluginOptions = samePlacementAntiAffinityPluginOptions{ + name: "SamePlacementAntiAffinity", +} + +// WithName sets the name of the plugin. +func WithName(name string) Option { + return func(o *samePlacementAntiAffinityPluginOptions) { + o.name = name + } +} + +// New returns a new Plugin. +func New(opts ...Option) Plugin { + options := defaultPluginOptions + for _, opt := range opts { + opt(&options) + } + + return Plugin{ + name: options.name, + } +} + +// Name returns the name of the plugin. +func (p *Plugin) Name() string { + return p.name +} + +// SetUpWithFramework sets up this plugin with a scheduler framework. +func (p *Plugin) SetUpWithFramework(handle framework.Handle) { + p.handle = handle +}