cozystack/internal/lineagecontrollerwebhook/webhook.go
Timofei Larkin bdeb3a7e8c feat(lineage-webhook): import scheduling constants from cozystack-scheduler
Import SchedulingClassAnnotation, SchedulingClassLabel, SchedulerName,
and GVR components from the cozystack-scheduler/pkg/apis sub-module
instead of defining them locally. This ensures the webhook and scheduler
stay in sync on label/annotation keys.

Also standardize the namespace label from scheduling.cozystack.io/class
to scheduler.cozystack.io/scheduling-class for consistency with the
scheduler, and resolve scheduling class from the owner Application CR
(via a new SchedulingClass() stub method) before falling back to the
namespace label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
2026-03-18 14:08:29 +03:00

286 lines
9.3 KiB
Go

package lineagecontrollerwebhook
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/cozystack/cozystack/pkg/lineage"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
schedulerapi "github.com/cozystack/cozystack-scheduler/pkg/apis/v1alpha1"
)
var (
NoAncestors = errors.New("no managed apps found in lineage")
AncestryAmbiguous = errors.New("object ancestry is ambiguous")
)
const (
ManagedObjectKey = "internal.cozystack.io/managed-by-cozystack"
ManagerGroupKey = "apps.cozystack.io/application.group"
ManagerKindKey = "apps.cozystack.io/application.kind"
ManagerNameKey = "apps.cozystack.io/application.name"
)
// getResourceSelectors returns the appropriate ApplicationDefinitionResources for a given GroupKind
func (h *LineageControllerWebhook) getResourceSelectors(gk schema.GroupKind, crd *cozyv1alpha1.ApplicationDefinition) *cozyv1alpha1.ApplicationDefinitionResources {
switch {
case gk.Group == "" && gk.Kind == "Secret":
return &crd.Spec.Secrets
case gk.Group == "" && gk.Kind == "Service":
return &crd.Spec.Services
case gk.Group == "networking.k8s.io" && gk.Kind == "Ingress":
return &crd.Spec.Ingresses
default:
return nil
}
}
// SetupWithManager registers the handler with the webhook server.
func (h *LineageControllerWebhook) SetupWithManagerAsWebhook(mgr ctrl.Manager) error {
cfg := rest.CopyConfig(mgr.GetConfig())
var err error
h.dynClient, err = dynamic.NewForConfig(cfg)
if err != nil {
return err
}
httpClient, err := rest.HTTPClientFor(cfg)
if err != nil {
return err
}
h.mapper, err = apiutil.NewDynamicRESTMapper(cfg, httpClient)
if err != nil {
return err
}
h.initConfig()
// Register HTTP path -> handler.
mgr.GetWebhookServer().Register("/mutate-lineage", &admission.Webhook{Handler: h})
return nil
}
// InjectDecoder lets controller-runtime give us a decoder for AdmissionReview requests.
func (h *LineageControllerWebhook) InjectDecoder(d admission.Decoder) error {
h.decoder = d
return nil
}
// Handle is called for each AdmissionReview that matches the webhook config.
func (h *LineageControllerWebhook) Handle(ctx context.Context, req admission.Request) admission.Response {
logger := log.FromContext(ctx).WithValues(
"gvk", req.Kind.String(),
"namespace", req.Namespace,
"name", req.Name,
"operation", req.Operation,
)
warn := make(admission.Warnings, 0)
obj := &unstructured.Unstructured{}
if err := h.decodeUnstructured(req, obj); err != nil {
return admission.Errored(400, fmt.Errorf("decode object: %w", err))
}
owner, err := h.getOwner(ctx, obj)
switch {
case err != nil && errors.Is(err, AncestryAmbiguous):
warn = append(warn, "object ancestry ambiguous, using first ancestor found")
case err != nil && errors.Is(err, NoAncestors):
// not a problem, mark object as unmanaged
case err != nil:
logger.Error(err, "error computing lineage labels")
return admission.Errored(500, fmt.Errorf("error computing lineage labels: %w", err))
}
labels, err := h.computeLabels(ctx, obj, owner)
if err != nil {
logger.Error(err, "error computing lineage labels")
return admission.Errored(500, fmt.Errorf("error computing lineage labels: %w", err))
}
h.applyLabels(obj, labels)
if err := h.applySchedulingClass(ctx, obj, owner, req.Namespace); err != nil {
logger.Error(err, "error applying scheduling class")
return admission.Errored(500, fmt.Errorf("error applying scheduling class: %w", err))
}
mutated, err := json.Marshal(obj)
if err != nil {
return admission.Errored(500, fmt.Errorf("marshal mutated pod: %w", err))
}
logger.V(1).Info("mutated pod", "namespace", obj.GetNamespace(), "name", obj.GetName())
return admission.PatchResponseFromRaw(req.Object.Raw, mutated).WithWarnings(warn...)
}
func (h *LineageControllerWebhook) getOwner(ctx context.Context, o *unstructured.Unstructured) (*unstructured.Unstructured, error) {
owners := lineage.WalkOwnershipGraph(ctx, h.dynClient, h.mapper, h, o)
if len(owners) == 0 {
return nil, NoAncestors
}
obj, err := owners[0].GetUnstructured(ctx, h.dynClient, h.mapper)
if err != nil {
return nil, err
}
if len(owners) > 1 {
err = AncestryAmbiguous
}
return obj, err
}
func (h *LineageControllerWebhook) computeLabels(ctx context.Context, obj *unstructured.Unstructured, owner *unstructured.Unstructured) (map[string]string, error) {
if owner == nil {
return nil, nil
}
gv, err := schema.ParseGroupVersion(owner.GetAPIVersion())
if err != nil {
// should never happen, we got an APIVersion right from the API
return nil, fmt.Errorf("could not parse APIVersion %s to a group and version: %w", owner.GetAPIVersion(), err)
}
labels := map[string]string{
// truncate apigroup to first 63 chars
ManagedObjectKey: "true",
ManagerGroupKey: func(s string) string {
if len(s) < 63 {
return s
}
s = s[:63]
for b := s[62]; !((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')); s = s[:len(s)-1] {
b = s[len(s)-1]
}
return s
}(gv.Group),
ManagerKindKey: owner.GetKind(),
ManagerNameKey: owner.GetName(),
}
templateLabels := map[string]string{
"kind": strings.ToLower(owner.GetKind()),
"name": owner.GetName(),
"namespace": obj.GetNamespace(),
}
cfg := h.config.Load().(*runtimeConfig)
crd := cfg.appCRDMap[appRef{gv.Group, owner.GetKind()}]
resourceSelectors := h.getResourceSelectors(obj.GroupVersionKind().GroupKind(), crd)
labels[corev1alpha1.TenantResourceLabelKey] = func(b bool) string {
if b {
return corev1alpha1.TenantResourceLabelValue
}
return "false"
}(matchResourceToExcludeInclude(ctx, obj.GetName(), templateLabels, obj.GetLabels(), resourceSelectors))
return labels, nil
}
func (h *LineageControllerWebhook) applyLabels(o *unstructured.Unstructured, labels map[string]string) {
existing := o.GetLabels()
if existing == nil {
existing = make(map[string]string)
}
for k, v := range labels {
existing[k] = v
}
o.SetLabels(existing)
}
// applySchedulingClass injects schedulerName and scheduling-class annotation
// into Pods whose namespace carries the scheduler.cozystack.io/scheduling-class label.
// If the referenced SchedulingClass CR does not exist (e.g. the scheduler
// package is not installed), the injection is silently skipped so that pods
// are not left Pending.
func (h *LineageControllerWebhook) applySchedulingClass(ctx context.Context, obj, owner *unstructured.Unstructured, namespace string) error {
if obj.GetKind() != "Pod" {
return nil
}
// Determine scheduling class: owner Application field takes priority,
// then fall back to namespace label.
var schedulingClass string
if owner != nil {
var app appsv1alpha1.Application
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(owner.Object, &app); err == nil {
schedulingClass = app.SchedulingClass()
}
}
if schedulingClass == "" {
ns := &corev1.Namespace{}
if err := h.Get(ctx, client.ObjectKey{Name: namespace}, ns); err != nil {
return fmt.Errorf("getting namespace %s: %w", namespace, err)
}
schedulingClass = ns.Labels[schedulerapi.SchedulingClassLabel]
}
if schedulingClass == "" {
return nil
}
// Verify that the referenced SchedulingClass CR exists.
// If the CRD is not installed or the CR is missing, skip injection
// so that pods are not stuck Pending on a non-existent scheduler.
_, err := h.dynClient.Resource(schedulingClassGVR).Get(ctx, schedulingClass, metav1.GetOptions{})
if err != nil {
logger := log.FromContext(ctx)
logger.Info("SchedulingClass not found, skipping scheduler injection",
"schedulingClass", schedulingClass, "namespace", namespace)
return nil
}
if err := unstructured.SetNestedField(obj.Object, schedulerapi.SchedulerName, "spec", "schedulerName"); err != nil {
return fmt.Errorf("setting schedulerName: %w", err)
}
annotations := obj.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations[schedulerapi.SchedulingClassAnnotation] = schedulingClass
obj.SetAnnotations(annotations)
return nil
}
var schedulingClassGVR = schema.GroupVersionResource{
Group: schedulerapi.Group,
Version: schedulerapi.Version,
Resource: schedulerapi.Resource,
}
func (h *LineageControllerWebhook) decodeUnstructured(req admission.Request, out *unstructured.Unstructured) error {
if h.decoder != nil {
if err := h.decoder.Decode(req, out); err == nil {
return nil
}
if req.Kind.Group != "" || req.Kind.Kind != "" || req.Kind.Version != "" {
out.SetGroupVersionKind(schema.GroupVersionKind{
Group: req.Kind.Group,
Version: req.Kind.Version,
Kind: req.Kind.Kind,
})
if err := h.decoder.Decode(req, out); err == nil {
return nil
}
}
}
if len(req.Object.Raw) == 0 {
return errors.New("empty admission object")
}
return json.Unmarshal(req.Object.Raw, &out.Object)
}