Files
open-cluster-management/pkg/placement/debugger/debugger.go
T
3da28e4bf7 🐛 fix(placement): correct debug permission checks and reject bad methods (#1623)
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>
2026-07-20 07:27:40 +00:00

302 lines
9.9 KiB
Go

package debugger
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
authorizationv1 "k8s.io/api/authorization/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
clusterinformerv1 "open-cluster-management.io/api/client/cluster/informers/externalversions/cluster/v1"
clusterinformerv1beta1 "open-cluster-management.io/api/client/cluster/informers/externalversions/cluster/v1beta1"
clusterinformerv1beta2 "open-cluster-management.io/api/client/cluster/informers/externalversions/cluster/v1beta2"
clusterlisterv1 "open-cluster-management.io/api/client/cluster/listers/cluster/v1"
clusterlisterv1beta1 "open-cluster-management.io/api/client/cluster/listers/cluster/v1beta1"
clusterlisterv1beta2 "open-cluster-management.io/api/client/cluster/listers/cluster/v1beta2"
clusterv1beta1 "open-cluster-management.io/api/cluster/v1beta1"
"open-cluster-management.io/ocm/pkg/placement/controllers/scheduling"
)
const (
DebugPath = "/debug/placements/"
// maxRequestBodyBytes is the maximum size for a POST request body (1MB)
maxRequestBodyBytes = 1 * 1024 * 1024
)
// Debugger provides a debug http endpoint for scheduler
type Debugger struct {
scheduler scheduling.Scheduler
kubeClient kubernetes.Interface
clusterLister clusterlisterv1.ManagedClusterLister
clusterSetLister clusterlisterv1beta2.ManagedClusterSetLister
clusterSetBindingLister clusterlisterv1beta2.ManagedClusterSetBindingLister
placementLister clusterlisterv1beta1.PlacementLister
}
// ClusterScore represents a cluster with its score
type ClusterScore struct {
ClusterName string `json:"clusterName"`
Score int64 `json:"score"`
}
// DebugResult is the result returned by debugger
type DebugResult struct {
Placement *clusterv1beta1.Placement `json:"placement,omitempty"`
FilterResults []scheduling.FilterResult `json:"filteredPipelineResults,omitempty"`
PrioritizeResults []scheduling.PrioritizerResult `json:"prioritizeResults,omitempty"`
AggregatedScores []ClusterScore `json:"aggregatedScores,omitempty"`
Error string `json:"error,omitempty"`
}
func NewDebugger(
scheduler scheduling.Scheduler,
kubeClient kubernetes.Interface,
placementInformer clusterinformerv1beta1.PlacementInformer,
clusterInformer clusterinformerv1.ManagedClusterInformer,
clusterSetInformer clusterinformerv1beta2.ManagedClusterSetInformer,
clusterSetBindingInformer clusterinformerv1beta2.ManagedClusterSetBindingInformer,
) *Debugger {
return &Debugger{
scheduler: scheduler,
kubeClient: kubeClient,
clusterLister: clusterInformer.Lister(),
clusterSetLister: clusterSetInformer.Lister(),
clusterSetBindingLister: clusterSetBindingInformer.Lister(),
placementLister: placementInformer.Lister(),
}
}
func (d *Debugger) Handler(w http.ResponseWriter, r *http.Request) {
var placement *clusterv1beta1.Placement
var err error
switch r.Method {
case http.MethodPost:
// POST: Parse Placement from request body
placement, err = d.parsePlacementFromBody(r)
if err != nil {
d.reportErr(w, http.StatusBadRequest, err)
return
}
case http.MethodGet:
namespace, name, err := d.parsePath(r.URL.Path)
if err != nil {
d.reportErr(w, http.StatusBadRequest, err)
return
}
if namespace == "" {
d.reportErr(w, http.StatusBadRequest, fmt.Errorf("invalid debug path: namespace and name required"))
return
}
placement, err = d.placementLister.Placements(namespace).Get(name)
if err != nil {
if apierrors.IsNotFound(err) {
d.reportErr(w, http.StatusNotFound, fmt.Errorf("placement '%s' not found", name))
} else {
d.reportErr(w, http.StatusInternalServerError, err)
}
return
}
default:
d.reportErr(w, http.StatusMethodNotAllowed, fmt.Errorf("method %s not allowed", r.Method))
return
}
// Check if user has permission to access placements in this namespace
if err := d.checkPermission(r, placement.Namespace); err != nil {
d.reportPermissionErr(w, err)
return
}
// Get valid clustersetbindings in the placement namespace
bindings, err := scheduling.GetValidManagedClusterSetBindings(placement.Namespace, d.clusterSetBindingLister, d.clusterSetLister)
if err != nil {
d.reportErr(w, http.StatusInternalServerError, err)
return
}
// Get eligible clustersets for the placement
clusterSetNames := scheduling.GetEligibleClusterSets(placement, bindings)
// Get available clusters for the placement
clusters, err := scheduling.GetAvailableClusters(clusterSetNames, d.clusterSetLister, d.clusterLister)
if err != nil {
d.reportErr(w, http.StatusInternalServerError, err)
return
}
scheduleResults, _ := d.scheduler.Schedule(r.Context(), placement, clusters)
// Create placement without status and runtime metadata
placementCopy := placement.DeepCopy()
placementCopy.ObjectMeta = metav1.ObjectMeta{
Name: placement.Name,
Namespace: placement.Namespace,
Labels: placement.Labels,
Annotations: placement.Annotations,
}
placementCopy.Status = clusterv1beta1.PlacementStatus{}
result := DebugResult{
Placement: placementCopy,
FilterResults: scheduleResults.FilterResults(),
PrioritizeResults: scheduleResults.PrioritizerResults(),
AggregatedScores: convertAndSortScores(scheduleResults.PrioritizerScores()),
}
resultByte, _ := json.Marshal(result)
_, _ = w.Write(resultByte)
}
func (d *Debugger) parsePath(path string) (string, string, error) {
metaNamespaceKey := strings.TrimPrefix(path, DebugPath)
return cache.SplitMetaNamespaceKey(metaNamespaceKey)
}
func (d *Debugger) parsePlacementFromBody(r *http.Request) (*clusterv1beta1.Placement, error) {
defer r.Body.Close()
// Check Content-Length header if present
if r.ContentLength > maxRequestBodyBytes {
return nil, fmt.Errorf("request body too large: %d bytes exceeds maximum of %d bytes", r.ContentLength, maxRequestBodyBytes)
}
// Use LimitReader to enforce size limit during reading
limitedReader := io.LimitReader(r.Body, maxRequestBodyBytes+1)
body, err := io.ReadAll(limitedReader)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
// Check if we read more than the limit
if len(body) > maxRequestBodyBytes {
return nil, fmt.Errorf("request body too large: exceeds maximum of %d bytes", maxRequestBodyBytes)
}
var placement clusterv1beta1.Placement
if err := json.Unmarshal(body, &placement); err != nil {
return nil, fmt.Errorf("failed to unmarshal placement JSON: %w", err)
}
return &placement, nil
}
func (d *Debugger) reportErr(w http.ResponseWriter, statusCode int, err error) {
result := &DebugResult{Error: err.Error()}
resultByte, _ := json.Marshal(result)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, _ = w.Write(resultByte)
}
func (d *Debugger) reportPermissionErr(w http.ResponseWriter, err error) {
statusCode := http.StatusInternalServerError
msg := err.Error()
switch {
case strings.Contains(msg, "user information not found"):
statusCode = http.StatusUnauthorized
case strings.Contains(msg, "does not have permission"):
statusCode = http.StatusForbidden
case strings.Contains(msg, "unsupported method"):
statusCode = http.StatusMethodNotAllowed
}
d.reportErr(w, statusCode, err)
}
func placementPermissionVerb(r *http.Request) (string, error) {
switch r.Method {
case http.MethodPost:
return "create", nil
case http.MethodGet:
return "get", nil
default:
return "", fmt.Errorf("unsupported method %s", r.Method)
}
}
// checkPermission checks if the user has permission to access placements in the namespace using SAR
func (d *Debugger) checkPermission(r *http.Request, namespace string) error {
// Get user from request context (authenticated by GenericAPIServer)
ctx := r.Context()
userInfo, ok := request.UserFrom(ctx)
if !ok {
return fmt.Errorf("user information not found in request context")
}
username := userInfo.GetName()
groups := userInfo.GetGroups()
extra := make(map[string]authorizationv1.ExtraValue)
for k, v := range userInfo.GetExtra() {
extra[k] = authorizationv1.ExtraValue(v)
}
verb, err := placementPermissionVerb(r)
if err != nil {
return err
}
// Create SubjectAccessReview to check if user can access placements
sar := &authorizationv1.SubjectAccessReview{
Spec: authorizationv1.SubjectAccessReviewSpec{
User: username,
Groups: groups,
Extra: extra,
ResourceAttributes: &authorizationv1.ResourceAttributes{
Namespace: namespace,
Verb: verb,
Group: "cluster.open-cluster-management.io",
Version: "v1beta1",
Resource: "placements",
},
},
}
// Perform the SAR check
// TODO: Cache successful permission checks to avoid SAR API calls on every request.
result, err := d.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(
context.TODO(), sar, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to check permissions: %w", err)
}
if !result.Status.Allowed {
return fmt.Errorf("user does not have permission to %s placements in namespace %s: %s",
verb, namespace, result.Status.Reason)
}
return nil
}
// convertAndSortScores converts a map of cluster scores to a sorted slice.
// Scores are sorted in descending order, with cluster names as tiebreaker (ascending).
func convertAndSortScores(scoreMap scheduling.PrioritizerScore) []ClusterScore {
clusterScores := make([]ClusterScore, 0, len(scoreMap))
for name, score := range scoreMap {
clusterScores = append(clusterScores, ClusterScore{
ClusterName: name,
Score: score,
})
}
sort.Slice(clusterScores, func(i, j int) bool {
if clusterScores[i].Score == clusterScores[j].Score {
return clusterScores[i].ClusterName < clusterScores[j].ClusterName
}
return clusterScores[i].Score > clusterScores[j].Score
})
return clusterScores
}