mirror of
https://github.com/open-cluster-management-io/ocm.git
synced 2026-08-19 04:06:35 +00:00
Map GET/POST debug requests to get/create SAR verbs and return 403 on denial. Reject unsupported HTTP methods with 405 instead of treating them as GET. Update the GET RBAC integration test Role to grant get. Also free unused disk on e2e runners before image builds to reduce "no space left on device" flakes. Fixes open-cluster-management-io/ocm#1622 Signed-off-by: Roke Jung <roke@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1009 lines
33 KiB
Go
1009 lines
33 KiB
Go
package debugger
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
authorizationv1 "k8s.io/api/authorization/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/apiserver/pkg/endpoints/request"
|
|
kubefake "k8s.io/client-go/kubernetes/fake"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
|
|
clusterfake "open-cluster-management.io/api/client/cluster/clientset/versioned/fake"
|
|
clusterlisterv1beta1 "open-cluster-management.io/api/client/cluster/listers/cluster/v1beta1"
|
|
clusterlisterv1beta2 "open-cluster-management.io/api/client/cluster/listers/cluster/v1beta2"
|
|
clusterapiv1 "open-cluster-management.io/api/cluster/v1"
|
|
clusterapiv1beta1 "open-cluster-management.io/api/cluster/v1beta1"
|
|
clusterapiv1beta2 "open-cluster-management.io/api/cluster/v1beta2"
|
|
|
|
"open-cluster-management.io/ocm/pkg/placement/controllers/framework"
|
|
"open-cluster-management.io/ocm/pkg/placement/controllers/scheduling"
|
|
testinghelpers "open-cluster-management.io/ocm/pkg/placement/helpers/testing"
|
|
)
|
|
|
|
type testScheduler struct {
|
|
result *testResult
|
|
}
|
|
|
|
type testResult struct {
|
|
filterResults []scheduling.FilterResult
|
|
prioritizeResults []scheduling.PrioritizerResult
|
|
prioritizerScore scheduling.PrioritizerScore
|
|
}
|
|
|
|
func (r *testResult) FilterResults() []scheduling.FilterResult {
|
|
return r.filterResults
|
|
}
|
|
|
|
func (r *testResult) PrioritizerResults() []scheduling.PrioritizerResult {
|
|
return r.prioritizeResults
|
|
}
|
|
|
|
func (r *testResult) PrioritizerScores() scheduling.PrioritizerScore {
|
|
return r.prioritizerScore
|
|
}
|
|
|
|
func (r *testResult) Decisions() []*clusterapiv1.ManagedCluster {
|
|
return []*clusterapiv1.ManagedCluster{}
|
|
}
|
|
|
|
func (r *testResult) NumOfUnscheduled() int {
|
|
return 0
|
|
}
|
|
|
|
func (s *testScheduler) Schedule(ctx context.Context,
|
|
placement *clusterapiv1beta1.Placement,
|
|
clusters []*clusterapiv1.ManagedCluster,
|
|
) (scheduling.ScheduleResult, *framework.Status) {
|
|
return s.result, nil
|
|
}
|
|
|
|
func (r *testResult) RequeueAfter() *time.Duration {
|
|
return nil
|
|
}
|
|
|
|
func assertErrorResponse(t *testing.T, res *http.Response, expectStatus int, errorContains string) {
|
|
t.Helper()
|
|
|
|
if res.StatusCode != expectStatus {
|
|
t.Errorf("Expected HTTP status %d, got %d", expectStatus, res.StatusCode)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Expected Content-Type application/json, got %q", ct)
|
|
}
|
|
|
|
var result DebugResult
|
|
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
if result.Error == "" {
|
|
t.Fatal("Expected error in response body")
|
|
}
|
|
if errorContains != "" && !strings.Contains(result.Error, errorContains) {
|
|
t.Errorf("Expected error to contain %q, got: %s", errorContains, result.Error)
|
|
}
|
|
}
|
|
|
|
func newAuthenticatedDebuggerServer(t *testing.T, initObjs []runtime.Object, mutate func(*Debugger)) (*httptest.Server, func()) {
|
|
t.Helper()
|
|
|
|
clusterClient := clusterfake.NewSimpleClientset(initObjs...)
|
|
clusterInformerFactory := testinghelpers.NewClusterInformerFactory(clusterClient, initObjs...)
|
|
kubeClient := kubefake.NewClientset()
|
|
kubeClient.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
sar := action.(k8stesting.CreateAction).GetObject().(*authorizationv1.SubjectAccessReview)
|
|
sar.Status.Allowed = true
|
|
return true, sar, nil
|
|
})
|
|
|
|
debugger := NewDebugger(
|
|
&testScheduler{result: &testResult{}},
|
|
kubeClient,
|
|
clusterInformerFactory.Cluster().V1beta1().Placements(),
|
|
clusterInformerFactory.Cluster().V1().ManagedClusters(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSets(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSetBindings(),
|
|
)
|
|
if mutate != nil {
|
|
mutate(debugger)
|
|
}
|
|
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userInfo := &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated"},
|
|
}
|
|
ctx := request.WithUser(r.Context(), userInfo)
|
|
debugger.Handler(w, r.WithContext(ctx))
|
|
})
|
|
|
|
server := httptest.NewServer(handler)
|
|
return server, server.Close
|
|
}
|
|
|
|
type fakePlacementLister struct {
|
|
getErr error
|
|
}
|
|
|
|
func (f *fakePlacementLister) List(labels.Selector) ([]*clusterapiv1beta1.Placement, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakePlacementLister) Placements(string) clusterlisterv1beta1.PlacementNamespaceLister {
|
|
return fakePlacementNamespaceLister{getErr: f.getErr}
|
|
}
|
|
|
|
type fakePlacementNamespaceLister struct {
|
|
getErr error
|
|
}
|
|
|
|
func (f fakePlacementNamespaceLister) List(labels.Selector) ([]*clusterapiv1beta1.Placement, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f fakePlacementNamespaceLister) Get(string) (*clusterapiv1beta1.Placement, error) {
|
|
return nil, f.getErr
|
|
}
|
|
|
|
type fakeClusterSetBindingLister struct {
|
|
listErr error
|
|
}
|
|
|
|
func (f *fakeClusterSetBindingLister) List(labels.Selector) ([]*clusterapiv1beta2.ManagedClusterSetBinding, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeClusterSetBindingLister) ManagedClusterSetBindings(string) clusterlisterv1beta2.ManagedClusterSetBindingNamespaceLister {
|
|
return fakeClusterSetBindingNamespaceLister{listErr: f.listErr}
|
|
}
|
|
|
|
type fakeClusterSetBindingNamespaceLister struct {
|
|
listErr error
|
|
}
|
|
|
|
func (f fakeClusterSetBindingNamespaceLister) List(labels.Selector) ([]*clusterapiv1beta2.ManagedClusterSetBinding, error) {
|
|
return nil, f.listErr
|
|
}
|
|
|
|
func (f fakeClusterSetBindingNamespaceLister) Get(string) (*clusterapiv1beta2.ManagedClusterSetBinding, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type fakeManagedClusterLister struct {
|
|
listErr error
|
|
}
|
|
|
|
func (f *fakeManagedClusterLister) List(labels.Selector) ([]*clusterapiv1.ManagedCluster, error) {
|
|
return nil, f.listErr
|
|
}
|
|
|
|
func (f *fakeManagedClusterLister) Get(string) (*clusterapiv1.ManagedCluster, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestReportErr(t *testing.T) {
|
|
debugger := &Debugger{}
|
|
rec := httptest.NewRecorder()
|
|
|
|
debugger.reportErr(rec, http.StatusBadRequest, fmt.Errorf("test error"))
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Expected HTTP status %d, got %d", http.StatusBadRequest, rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Expected Content-Type application/json, got %q", ct)
|
|
}
|
|
|
|
var result DebugResult
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
if result.Error != "test error" {
|
|
t.Errorf("Expected error %q, got %q", "test error", result.Error)
|
|
}
|
|
}
|
|
|
|
func TestReportPermissionErr(t *testing.T) {
|
|
debugger := &Debugger{}
|
|
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
expectStatus int
|
|
}{
|
|
{
|
|
name: "missing user",
|
|
err: fmt.Errorf("user information not found in request context"),
|
|
expectStatus: http.StatusUnauthorized,
|
|
},
|
|
{
|
|
name: "forbidden create",
|
|
err: fmt.Errorf("user does not have permission to create placements in namespace test: denied"),
|
|
expectStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "forbidden get",
|
|
err: fmt.Errorf("user does not have permission to get placements in namespace test: denied"),
|
|
expectStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "unsupported method",
|
|
err: fmt.Errorf("unsupported method DELETE"),
|
|
expectStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "internal",
|
|
err: fmt.Errorf("failed to check permissions: boom"),
|
|
expectStatus: http.StatusInternalServerError,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
debugger.reportPermissionErr(rec, c.err)
|
|
if rec.Code != c.expectStatus {
|
|
t.Errorf("Expected HTTP status %d, got %d", c.expectStatus, rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDebuggerHandlerReportErr(t *testing.T) {
|
|
const (
|
|
placementNamespace = "test-ns"
|
|
placementName = "test-placement"
|
|
)
|
|
|
|
validObjs := []runtime.Object{
|
|
testinghelpers.NewPlacement(placementNamespace, placementName).WithClusterSets("test-set").Build(),
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewClusterSet("test-set").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set"),
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
initObjs []runtime.Object
|
|
method string
|
|
pathSuffix string
|
|
body []byte
|
|
mutate func(*Debugger)
|
|
expectStatus int
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "POST invalid JSON",
|
|
method: http.MethodPost,
|
|
body: []byte("{"),
|
|
expectStatus: http.StatusBadRequest,
|
|
errorContains: "failed to unmarshal placement JSON",
|
|
},
|
|
{
|
|
name: "GET incomplete path",
|
|
method: http.MethodGet,
|
|
pathSuffix: "placement-only",
|
|
expectStatus: http.StatusBadRequest,
|
|
errorContains: "namespace and name required",
|
|
},
|
|
{
|
|
name: "GET placement not found",
|
|
method: http.MethodGet,
|
|
pathSuffix: placementNamespace + "/does-not-exist",
|
|
expectStatus: http.StatusNotFound,
|
|
errorContains: "does-not-exist",
|
|
},
|
|
{
|
|
name: "DELETE method not allowed",
|
|
method: http.MethodDelete,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
expectStatus: http.StatusMethodNotAllowed,
|
|
errorContains: "method DELETE not allowed",
|
|
},
|
|
{
|
|
name: "PUT method not allowed",
|
|
method: http.MethodPut,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
expectStatus: http.StatusMethodNotAllowed,
|
|
errorContains: "method PUT not allowed",
|
|
},
|
|
{
|
|
name: "PATCH method not allowed",
|
|
method: http.MethodPatch,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
expectStatus: http.StatusMethodNotAllowed,
|
|
errorContains: "method PATCH not allowed",
|
|
},
|
|
{
|
|
name: "GET placement lister failure",
|
|
initObjs: validObjs,
|
|
method: http.MethodGet,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
mutate: func(d *Debugger) {
|
|
d.placementLister = &fakePlacementLister{getErr: fmt.Errorf("lister unavailable")}
|
|
},
|
|
expectStatus: http.StatusInternalServerError,
|
|
errorContains: "lister unavailable",
|
|
},
|
|
{
|
|
name: "GET clusterset binding list failure",
|
|
initObjs: validObjs,
|
|
method: http.MethodGet,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
mutate: func(d *Debugger) {
|
|
d.clusterSetBindingLister = &fakeClusterSetBindingLister{listErr: fmt.Errorf("binding lister unavailable")}
|
|
},
|
|
expectStatus: http.StatusInternalServerError,
|
|
errorContains: "binding lister unavailable",
|
|
},
|
|
{
|
|
name: "GET managed cluster list failure",
|
|
initObjs: validObjs,
|
|
method: http.MethodGet,
|
|
pathSuffix: placementNamespace + "/" + placementName,
|
|
mutate: func(d *Debugger) {
|
|
d.clusterLister = &fakeManagedClusterLister{listErr: fmt.Errorf("cluster lister unavailable")}
|
|
},
|
|
expectStatus: http.StatusInternalServerError,
|
|
errorContains: "cluster lister unavailable",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
server, closeServer := newAuthenticatedDebuggerServer(t, c.initObjs, c.mutate)
|
|
defer closeServer()
|
|
|
|
method := c.method
|
|
if method == "" {
|
|
method = http.MethodGet
|
|
}
|
|
|
|
var body io.Reader
|
|
if len(c.body) > 0 {
|
|
body = bytes.NewBuffer(c.body)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, fmt.Sprintf("%s%s%s", server.URL, DebugPath, c.pathSuffix), body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
if method == http.MethodPost {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
assertErrorResponse(t, res, c.expectStatus, c.errorContains)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDebuggerGETMethod(t *testing.T) {
|
|
placementNamespace := "test"
|
|
|
|
placementName := "test"
|
|
|
|
cases := []struct {
|
|
name string
|
|
initObjs []runtime.Object
|
|
filterResults []scheduling.FilterResult
|
|
prioritizeResults []scheduling.PrioritizerResult
|
|
prioritizerScore scheduling.PrioritizerScore
|
|
key string
|
|
}{
|
|
{
|
|
name: "A valid placement",
|
|
initObjs: []runtime.Object{
|
|
testinghelpers.NewPlacement(placementNamespace, placementName).Build(),
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewManagedCluster("cluster2").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewClusterSet("test-set").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set"),
|
|
},
|
|
filterResults: []scheduling.FilterResult{{Name: "filter1", FilteredClusters: []string{"cluster1", "cluster2"}}},
|
|
prioritizeResults: []scheduling.PrioritizerResult{{Name: "prioritize1", Scores: map[string]int64{"cluster1": 0, "cluster2": 100}}},
|
|
prioritizerScore: scheduling.PrioritizerScore{"cluster1": 0, "cluster2": 100},
|
|
key: placementNamespace + "/" + placementName,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
clusterClient := clusterfake.NewSimpleClientset(c.initObjs...)
|
|
clusterInformerFactory := testinghelpers.NewClusterInformerFactory(clusterClient, c.initObjs...)
|
|
kubeClient := kubefake.NewClientset()
|
|
|
|
// Mock SAR for authorization
|
|
kubeClient.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
createAction := action.(k8stesting.CreateAction)
|
|
sar := createAction.GetObject().(*authorizationv1.SubjectAccessReview)
|
|
sar.Status.Allowed = true
|
|
return true, sar, nil
|
|
})
|
|
|
|
s := &testScheduler{result: &testResult{filterResults: c.filterResults, prioritizeResults: c.prioritizeResults, prioritizerScore: c.prioritizerScore}}
|
|
debugger := NewDebugger(
|
|
s,
|
|
kubeClient,
|
|
clusterInformerFactory.Cluster().V1beta1().Placements(),
|
|
clusterInformerFactory.Cluster().V1().ManagedClusters(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSets(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSetBindings(),
|
|
)
|
|
|
|
// Wrap handler to inject user context (simulating GenericAPIServer authentication)
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userInfo := &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated"},
|
|
}
|
|
ctx := request.WithUser(r.Context(), userInfo)
|
|
r = r.WithContext(ctx)
|
|
debugger.Handler(w, r)
|
|
})
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", server.URL, DebugPath, c.key), nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Errorf("Expect no error but get %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Errorf("Unexpected error reading response body: %v", err)
|
|
}
|
|
|
|
result := &DebugResult{}
|
|
err = json.Unmarshal(responseBody, result)
|
|
if err != nil {
|
|
t.Errorf("Unexpected error unmarshaling reulst: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(result.FilterResults, c.filterResults) {
|
|
t.Errorf("Expect filter result to be: %v. but got: %v", c.filterResults, result.FilterResults)
|
|
}
|
|
|
|
if !reflect.DeepEqual(result.PrioritizeResults, c.prioritizeResults) {
|
|
t.Errorf("Expect prioritize result to be: %v. but got: %v", c.prioritizeResults, result.PrioritizeResults)
|
|
}
|
|
|
|
// Verify aggregatedScores are sorted
|
|
expectedScores := []ClusterScore{
|
|
{ClusterName: "cluster2", Score: 100},
|
|
{ClusterName: "cluster1", Score: 0},
|
|
}
|
|
if !reflect.DeepEqual(result.AggregatedScores, expectedScores) {
|
|
t.Errorf("Expect aggregatedScores to be: %v, but got: %v", expectedScores, result.AggregatedScores)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDebuggerPOSTMethod(t *testing.T) {
|
|
placementNamespace := "test-ns"
|
|
placementName := "test-placement"
|
|
|
|
initObjs := []runtime.Object{
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewManagedCluster("cluster2").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewClusterSet("test-set").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set"),
|
|
}
|
|
|
|
clusterClient := clusterfake.NewSimpleClientset(initObjs...)
|
|
clusterInformerFactory := testinghelpers.NewClusterInformerFactory(clusterClient, initObjs...)
|
|
kubeClient := kubefake.NewClientset()
|
|
|
|
// Mock SAR for authorization
|
|
kubeClient.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
createAction := action.(k8stesting.CreateAction)
|
|
sar := createAction.GetObject().(*authorizationv1.SubjectAccessReview)
|
|
sar.Status.Allowed = true
|
|
return true, sar, nil
|
|
})
|
|
|
|
scheduler := &testScheduler{
|
|
result: &testResult{
|
|
filterResults: []scheduling.FilterResult{{Name: "filter1", FilteredClusters: []string{"cluster1", "cluster2"}}},
|
|
prioritizeResults: []scheduling.PrioritizerResult{{Name: "prioritize1", Scores: map[string]int64{"cluster1": 100, "cluster2": 50}}},
|
|
prioritizerScore: scheduling.PrioritizerScore{"cluster1": 100, "cluster2": 50},
|
|
},
|
|
}
|
|
|
|
debugger := NewDebugger(
|
|
scheduler,
|
|
kubeClient,
|
|
clusterInformerFactory.Cluster().V1beta1().Placements(),
|
|
clusterInformerFactory.Cluster().V1().ManagedClusters(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSets(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSetBindings(),
|
|
)
|
|
|
|
// Wrap handler to inject user context (simulating GenericAPIServer authentication)
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userInfo := &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated"},
|
|
}
|
|
ctx := request.WithUser(r.Context(), userInfo)
|
|
r = r.WithContext(ctx)
|
|
debugger.Handler(w, r)
|
|
})
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
// Create placement JSON
|
|
placement := &clusterapiv1beta1.Placement{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: placementName,
|
|
Namespace: placementNamespace,
|
|
},
|
|
Spec: clusterapiv1beta1.PlacementSpec{
|
|
ClusterSets: []string{"test-set"},
|
|
},
|
|
}
|
|
placementJSON, _ := json.Marshal(placement)
|
|
|
|
req, err := http.NewRequest("POST", server.URL+DebugPath, bytes.NewBuffer(placementJSON))
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read response body: %v", err)
|
|
}
|
|
result := &DebugResult{}
|
|
if err := json.Unmarshal(responseBody, result); err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if result.Error != "" {
|
|
t.Errorf("Expected no error but got: %s", result.Error)
|
|
}
|
|
|
|
if result.Placement == nil {
|
|
t.Errorf("Expected placement in result")
|
|
}
|
|
|
|
if result.Placement.Name != placementName {
|
|
t.Errorf("Expected placement name %s, got %s", placementName, result.Placement.Name)
|
|
}
|
|
|
|
if result.Placement.Namespace != placementNamespace {
|
|
t.Errorf("Expected placement namespace %s, got %s", placementNamespace, result.Placement.Namespace)
|
|
}
|
|
|
|
if len(result.FilterResults) != 1 {
|
|
t.Errorf("Expected 1 filter result, got %d", len(result.FilterResults))
|
|
}
|
|
|
|
// Verify aggregatedScores are sorted
|
|
expectedScores := []ClusterScore{
|
|
{ClusterName: "cluster1", Score: 100},
|
|
{ClusterName: "cluster2", Score: 50},
|
|
}
|
|
if !reflect.DeepEqual(result.AggregatedScores, expectedScores) {
|
|
t.Errorf("Expect aggregatedScores to be: %v, but got: %v", expectedScores, result.AggregatedScores)
|
|
}
|
|
}
|
|
|
|
type testSchedulerWithCapture struct {
|
|
result *testResult
|
|
capturedClusters []*clusterapiv1.ManagedCluster
|
|
}
|
|
|
|
func (s *testSchedulerWithCapture) Schedule(ctx context.Context,
|
|
placement *clusterapiv1beta1.Placement,
|
|
clusters []*clusterapiv1.ManagedCluster,
|
|
) (scheduling.ScheduleResult, *framework.Status) {
|
|
s.capturedClusters = clusters
|
|
return s.result, nil
|
|
}
|
|
|
|
func TestDebuggerWithClusterSetValidation(t *testing.T) {
|
|
placementNamespace := "test"
|
|
placementName := "test"
|
|
|
|
cases := []struct {
|
|
name string
|
|
initObjs []runtime.Object
|
|
expectedClusters int
|
|
expectError bool
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "No ClusterSetBinding - should return empty clusters",
|
|
initObjs: []runtime.Object{
|
|
testinghelpers.NewPlacement(placementNamespace, placementName).Build(),
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewManagedCluster("cluster2").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewClusterSet("test-set").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set"},
|
|
},
|
|
}).Build(),
|
|
// No ClusterSetBinding
|
|
},
|
|
expectedClusters: 0,
|
|
},
|
|
{
|
|
name: "Placement specifies ClusterSets - should filter clusters",
|
|
initObjs: []runtime.Object{
|
|
testinghelpers.NewPlacement(placementNamespace, placementName).WithClusterSets("test-set-1").Build(),
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set-1").Build(),
|
|
testinghelpers.NewManagedCluster("cluster2").WithLabel("clusterset", "test-set-2").Build(),
|
|
testinghelpers.NewClusterSet("test-set-1").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set-1"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSet("test-set-2").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set-2"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set-1"),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set-2"),
|
|
},
|
|
expectedClusters: 1, // Only cluster1 from test-set-1
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
clusterClient := clusterfake.NewSimpleClientset(c.initObjs...)
|
|
clusterInformerFactory := testinghelpers.NewClusterInformerFactory(clusterClient, c.initObjs...)
|
|
kubeClient := kubefake.NewClientset()
|
|
|
|
// Mock SAR for authorization
|
|
kubeClient.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
createAction := action.(k8stesting.CreateAction)
|
|
sar := createAction.GetObject().(*authorizationv1.SubjectAccessReview)
|
|
sar.Status.Allowed = true
|
|
return true, sar, nil
|
|
})
|
|
|
|
// Track which clusters were passed to scheduler
|
|
scheduler := &testSchedulerWithCapture{
|
|
result: &testResult{
|
|
filterResults: []scheduling.FilterResult{},
|
|
prioritizeResults: []scheduling.PrioritizerResult{},
|
|
},
|
|
}
|
|
|
|
debugger := NewDebugger(
|
|
scheduler,
|
|
kubeClient,
|
|
clusterInformerFactory.Cluster().V1beta1().Placements(),
|
|
clusterInformerFactory.Cluster().V1().ManagedClusters(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSets(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSetBindings(),
|
|
)
|
|
|
|
// Wrap handler to inject user context (simulating GenericAPIServer authentication)
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userInfo := &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated"},
|
|
}
|
|
ctx := request.WithUser(r.Context(), userInfo)
|
|
r = r.WithContext(ctx)
|
|
debugger.Handler(w, r)
|
|
})
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s", server.URL, DebugPath, placementNamespace+"/"+placementName), nil)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read response body: %v", err)
|
|
}
|
|
result := &DebugResult{}
|
|
if err := json.Unmarshal(responseBody, result); err != nil {
|
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if c.expectError {
|
|
if result.Error == "" {
|
|
t.Errorf("Expected error but got none")
|
|
}
|
|
if c.errorContains != "" && !strings.Contains(result.Error, c.errorContains) {
|
|
t.Errorf("Expected error to contain %q, but got: %s", c.errorContains, result.Error)
|
|
}
|
|
} else {
|
|
if result.Error != "" {
|
|
t.Errorf("Expected no error but got: %s", result.Error)
|
|
}
|
|
if len(scheduler.capturedClusters) != c.expectedClusters {
|
|
t.Errorf("Expected %d clusters, got %d", c.expectedClusters, len(scheduler.capturedClusters))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDebuggerPermissionCheck(t *testing.T) {
|
|
const (
|
|
placementNamespace = "test-ns"
|
|
placementName = "test-placement"
|
|
)
|
|
|
|
// Default user info (can be overridden in test cases)
|
|
defaultUserInfo := &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated"},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
body []byte
|
|
injectUser bool
|
|
userInfo *user.DefaultInfo
|
|
sarAllowed bool
|
|
sarError error
|
|
expectError bool
|
|
expectStatusCode int
|
|
errorContains string
|
|
verifySARRequest func(t *testing.T, sar *authorizationv1.SubjectAccessReview)
|
|
}{
|
|
{
|
|
name: "No user in context - should reject",
|
|
injectUser: false,
|
|
expectError: true,
|
|
expectStatusCode: http.StatusUnauthorized,
|
|
errorContains: "user information not found in request context",
|
|
},
|
|
{
|
|
name: "User with permission - should allow",
|
|
injectUser: true,
|
|
sarAllowed: true,
|
|
expectError: false,
|
|
expectStatusCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "GET user without permission - should reject with get verb",
|
|
method: http.MethodGet,
|
|
injectUser: true,
|
|
sarAllowed: false,
|
|
expectError: true,
|
|
expectStatusCode: http.StatusForbidden,
|
|
errorContains: "does not have permission to get placements",
|
|
verifySARRequest: func(t *testing.T, sar *authorizationv1.SubjectAccessReview) {
|
|
if sar.Spec.ResourceAttributes.Verb != "get" {
|
|
t.Errorf("Expected SAR verb get, got %s", sar.Spec.ResourceAttributes.Verb)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "POST user without permission - should reject with create verb",
|
|
method: http.MethodPost,
|
|
body: []byte(`{"apiVersion":"cluster.open-cluster-management.io/v1beta1","kind":"Placement","metadata":{"name":"test-placement","namespace":"test-ns"},"spec":{"numberOfClusters":1}}`),
|
|
injectUser: true,
|
|
sarAllowed: false,
|
|
expectError: true,
|
|
expectStatusCode: http.StatusForbidden,
|
|
errorContains: "does not have permission to create placements",
|
|
verifySARRequest: func(t *testing.T, sar *authorizationv1.SubjectAccessReview) {
|
|
if sar.Spec.ResourceAttributes.Verb != "create" {
|
|
t.Errorf("Expected SAR verb create, got %s", sar.Spec.ResourceAttributes.Verb)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "SAR API call fails",
|
|
injectUser: true,
|
|
sarError: fmt.Errorf("API server error"),
|
|
expectError: true,
|
|
expectStatusCode: http.StatusInternalServerError,
|
|
errorContains: "failed to check permissions",
|
|
},
|
|
{
|
|
name: "User with Extra fields - should pass all fields to SAR",
|
|
injectUser: true,
|
|
userInfo: &user.DefaultInfo{
|
|
Name: "test-user",
|
|
Groups: []string{"system:authenticated", "developers"},
|
|
Extra: map[string][]string{
|
|
"authentication.kubernetes.io/scopes": {"read", "write"},
|
|
"custom.example.com/department": {"engineering"},
|
|
},
|
|
},
|
|
sarAllowed: true,
|
|
expectError: false,
|
|
expectStatusCode: http.StatusOK,
|
|
verifySARRequest: func(t *testing.T, sar *authorizationv1.SubjectAccessReview) {
|
|
expected := map[string]authorizationv1.ExtraValue{
|
|
"authentication.kubernetes.io/scopes": {"read", "write"},
|
|
"custom.example.com/department": {"engineering"},
|
|
}
|
|
if !reflect.DeepEqual(sar.Spec.Extra, expected) {
|
|
t.Errorf("Expected Extra %v, got %v", expected, sar.Spec.Extra)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
// Setup common test objects (reused across all test cases)
|
|
initObjs := []runtime.Object{
|
|
testinghelpers.NewPlacement(placementNamespace, placementName).Build(),
|
|
testinghelpers.NewManagedCluster("cluster1").WithLabel("clusterset", "test-set").Build(),
|
|
testinghelpers.NewClusterSet("test-set").WithClusterSelector(clusterapiv1beta2.ManagedClusterSelector{
|
|
SelectorType: clusterapiv1beta2.LabelSelector,
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"clusterset": "test-set"},
|
|
},
|
|
}).Build(),
|
|
testinghelpers.NewClusterSetBinding(placementNamespace, "test-set"),
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
clusterClient := clusterfake.NewSimpleClientset(initObjs...)
|
|
clusterInformerFactory := testinghelpers.NewClusterInformerFactory(clusterClient, initObjs...)
|
|
kubeClient := kubefake.NewClientset()
|
|
|
|
// Mock SAR
|
|
kubeClient.PrependReactor("create", "subjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
|
if c.sarError != nil {
|
|
return true, nil, c.sarError
|
|
}
|
|
sar := action.(k8stesting.CreateAction).GetObject().(*authorizationv1.SubjectAccessReview)
|
|
|
|
if c.verifySARRequest != nil {
|
|
c.verifySARRequest(t, sar)
|
|
}
|
|
|
|
sar.Status.Allowed = c.sarAllowed
|
|
if !c.sarAllowed {
|
|
sar.Status.Reason = "user lacks permission"
|
|
}
|
|
return true, sar, nil
|
|
})
|
|
|
|
debugger := NewDebugger(
|
|
&testScheduler{result: &testResult{}},
|
|
kubeClient,
|
|
clusterInformerFactory.Cluster().V1beta1().Placements(),
|
|
clusterInformerFactory.Cluster().V1().ManagedClusters(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSets(),
|
|
clusterInformerFactory.Cluster().V1beta2().ManagedClusterSetBindings(),
|
|
)
|
|
|
|
// Setup HTTP handler
|
|
var handler http.Handler
|
|
if c.injectUser {
|
|
userInfo := c.userInfo
|
|
if userInfo == nil {
|
|
userInfo = defaultUserInfo
|
|
}
|
|
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := request.WithUser(r.Context(), userInfo)
|
|
debugger.Handler(w, r.WithContext(ctx))
|
|
})
|
|
} else {
|
|
handler = http.HandlerFunc(debugger.Handler)
|
|
}
|
|
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
|
|
// Send request
|
|
method := c.method
|
|
if method == "" {
|
|
method = http.MethodGet
|
|
}
|
|
|
|
var url string
|
|
var body io.Reader
|
|
if method == http.MethodPost {
|
|
url = fmt.Sprintf("%s%s", server.URL, DebugPath)
|
|
if len(c.body) > 0 {
|
|
body = bytes.NewBuffer(c.body)
|
|
}
|
|
} else {
|
|
url = fmt.Sprintf("%s%s%s/%s", server.URL, DebugPath, placementNamespace, placementName)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create request: %v", err)
|
|
}
|
|
if method == http.MethodPost {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Unexpected error: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
// Parse response
|
|
var result DebugResult
|
|
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
|
t.Fatalf("Failed to decode response: %v", err)
|
|
}
|
|
|
|
// Verify result
|
|
if res.StatusCode != c.expectStatusCode {
|
|
t.Errorf("Expected HTTP status %d, got %d", c.expectStatusCode, res.StatusCode)
|
|
}
|
|
if c.expectError {
|
|
if result.Error == "" {
|
|
t.Errorf("Expected error but got none")
|
|
}
|
|
if c.errorContains != "" && !strings.Contains(result.Error, c.errorContains) {
|
|
t.Errorf("Expected error to contain %q, but got: %s", c.errorContains, result.Error)
|
|
}
|
|
} else if result.Error != "" {
|
|
t.Errorf("Expected no error but got: %s", result.Error)
|
|
}
|
|
})
|
|
}
|
|
}
|