feat(tenant): support gateway/class (#1463)

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

Co-authored-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

* feat(tenant): support gateway/class

Signed-off-by: Hristo Hristov <me@hhristov.info>

---------

Signed-off-by: Hristo Hristov <me@hhristov.info>
Co-authored-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Hristo Hristov
2025-05-20 19:53:42 +03:00
committed by GitHub
parent 7d0a4c58fd
commit a60ebfac5e
25 changed files with 895 additions and 293 deletions

View File

@@ -5,6 +5,8 @@ package defaults
import (
"fmt"
"reflect"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)
type StorageClassError struct {
@@ -39,6 +41,39 @@ func (e IngressClassError) Error() string {
return fmt.Sprintf("Failed to resolve Ingress Class %s: %s", e.ingressClass, e.msg)
}
type GatewayClassError struct {
gatewayClass string
msg error
}
func NewGatewayClassError(class string, msg error) error {
return &GatewayClassError{
gatewayClass: class,
msg: msg,
}
}
func (e GatewayClassError) Error() string {
return fmt.Sprintf("Failed to resolve Gateway Class %s: %s", e.gatewayClass, e.msg)
}
type GatewayError struct {
gateway string
msg error
}
func NewGatewayError(gateway gatewayv1.ObjectName, msg error) error {
return &GatewayError{
gateway: reflect.ValueOf(gateway).String(),
msg: msg,
}
}
func (e GatewayError) Error() string {
return fmt.Sprintf("Failed to resolve Gateway %s: %s", e.gateway, e.msg)
}
type PriorityClassError struct {
priorityClass string
msg error

View File

@@ -0,0 +1,84 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"net/http"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulegateway "github.com/projectcapsule/capsule/pkg/webhook/gateway"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
func mutateGatewayDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, recorder record.EventRecorder, namespce string) *admission.Response {
gatewayObj := &gatewayv1.Gateway{}
if err := decoder.Decode(req, gatewayObj); err != nil {
return utils.ErroredResponse(err)
}
gatewayObj.SetNamespace(namespce)
tnt, err := capsulegateway.TenantFromGateway(ctx, c, gatewayObj)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.GatewayOptions.AllowedClasses
if allowed == nil || allowed.Default == "" {
return nil
}
var mutate bool
gatewayClass, err := utils.GetGatewayClassClassByObjectName(ctx, c, gatewayObj.Spec.GatewayClassName)
if gatewayClass == nil {
if gatewayObj.Spec.GatewayClassName == ("") {
mutate = true
} else {
response := admission.Denied(NewGatewayError(gatewayObj.Spec.GatewayClassName, err).Error())
return &response
}
}
if gatewayClass != nil && gatewayClass.Name != allowed.Default {
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Denied(NewGatewayClassError(gatewayClass.Name, err).Error())
return &response
}
} else {
mutate = true
}
if mutate = mutate || (gatewayClass.Name == allowed.Default); !mutate {
return nil
}
gatewayObj.Spec.GatewayClassName = gatewayv1.ObjectName(allowed.Default)
marshaled, err := json.Marshal(gatewayObj)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
recorder.Eventf(tnt, corev1.EventTypeNormal, "TenantDefault", "Assigned Tenant default Gateway Class %s to %s/%s", allowed.Default, gatewayObj.Name, gatewayObj.Namespace)
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}

View File

@@ -56,6 +56,8 @@ func (h *handler) mutate(ctx context.Context, req admission.Request, c client.Cl
response = mutatePVCDefaults(ctx, req, c, decoder, recorder, req.Namespace)
case metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, metav1.GroupVersionResource{Group: "networking.k8s.io", Version: "v1beta1", Resource: "ingresses"}:
response = mutateIngressDefaults(ctx, req, h.version, c, decoder, recorder, req.Namespace)
case metav1.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"}:
response = mutateGatewayDefaults(ctx, req, c, decoder, recorder, req.Namespace)
}
if response == nil {

View File

@@ -0,0 +1,43 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"fmt"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type gatewayClassForbiddenError struct {
gatewayClassName string
spec api.SelectionListWithDefaultSpec
}
func NewGatewayClassForbidden(class string, spec api.SelectionListWithDefaultSpec) error {
return &gatewayClassForbiddenError{
gatewayClassName: class,
spec: spec,
}
}
func (i gatewayClassForbiddenError) Error() string {
err := fmt.Sprintf("Gateway Class %s is forbidden for the current Tenant: ", i.gatewayClassName)
return utils.SelectionListWithDefaultErrorMessage(i.spec, err)
}
type gatewayClassUndefinedError struct {
spec api.SelectionListWithDefaultSpec
}
func NewGatewayClassUndefined(spec api.SelectionListWithDefaultSpec) error {
return &gatewayClassUndefinedError{
spec: spec,
}
}
func (i gatewayClassUndefinedError) Error() string {
return utils.SelectionListWithDefaultErrorMessage(i.spec, "No gateway Class is forbidden for the current Tenant. Specify a gateway Class which is allowed within the Tenant: ")
}

View File

@@ -0,0 +1,29 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"context"
"k8s.io/apimachinery/pkg/fields"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
func TenantFromGateway(ctx context.Context, c client.Client, gateway *v1.Gateway) (*capsulev1beta2.Tenant, error) {
tenantList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tenantList, client.MatchingFieldsSelector{
Selector: fields.OneTermEqualSelector(".status.namespaces", gateway.Namespace),
}); err != nil {
return nil, err
}
if len(tenantList.Items) == 0 {
return nil, nil //nolint:nilnil
}
return &tenantList.Items[0], nil
}

View File

@@ -0,0 +1,115 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package gateway
import (
"context"
"net/http"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/configuration"
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
"github.com/projectcapsule/capsule/pkg/webhook/utils"
)
type class struct {
configuration configuration.Configuration
}
func Class(configuration configuration.Configuration) capsulewebhook.Handler {
return &class{
configuration: configuration,
}
}
func (r *class) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *class) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return r.validate(ctx, client, req, decoder, recorder)
}
}
func (r *class) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (r *class) validate(ctx context.Context, client client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
gatewayObj := &gatewayv1.Gateway{}
if err := decoder.Decode(req, gatewayObj); err != nil {
return utils.ErroredResponse(err)
}
var tnt *capsulev1beta2.Tenant
tnt, err := TenantFromGateway(ctx, client, gatewayObj)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
allowed := tnt.Spec.GatewayOptions.AllowedClasses
if allowed == nil {
return nil
}
gatewayClass, err := utils.GetGatewayClassClassByObjectName(ctx, client, gatewayObj.Spec.GatewayClassName)
if err != nil {
return utils.ErroredResponse(err)
}
if gatewayClass == nil {
recorder.Eventf(tnt, corev1.EventTypeWarning, "MissingGatewayClass", "Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name)
response := admission.Denied(NewGatewayClassUndefined(*allowed).Error())
return &response
}
selector := false
// Verify if the GatewayClass exists and matches the label selector/expression
if len(allowed.MatchExpressions) > 0 || len(allowed.MatchLabels) > 0 {
gatewayClassObj, err := utils.GetGatewayClassClassByObjectName(ctx, client, gatewayObj.Spec.GatewayClassName)
if err != nil && !k8serrors.IsNotFound(err) {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
// Gateway Class is present, check if it matches the selector
if gatewayClassObj != nil {
selector = allowed.SelectorMatch(gatewayClassObj)
}
}
switch {
case allowed.MatchDefault(gatewayClass.Name):
return nil
case selector:
return nil
default:
recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenGatewaClass", "Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &gatewayClass)
response := admission.Denied(NewGatewayClassForbidden(gatewayObj.Name, *allowed).Error())
return &response
}
}

View File

@@ -0,0 +1,24 @@
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package route
import (
capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
)
type gateway struct {
handlers []capsulewebhook.Handler
}
func Gateway(handler ...capsulewebhook.Handler) capsulewebhook.Webhook {
return &gateway{handlers: handler}
}
func (w *gateway) GetHandlers() []capsulewebhook.Handler {
return w.handlers
}
func (w *gateway) GetPath() string {
return "/gateways"
}

View File

@@ -37,3 +37,14 @@ func DefaultAllowedValuesErrorMessage(allowed api.DefaultAllowedListSpec, err st
return err
}
func SelectionListWithDefaultErrorMessage(allowed api.SelectionListWithDefaultSpec, err string) string {
var extra []string
if len(allowed.MatchLabels) > 0 || len(allowed.MatchExpressions) > 0 {
extra = append(extra, "matching the label selector defined in the Tenant")
}
err += strings.Join(extra, " or ")
return err
}

View File

@@ -5,6 +5,7 @@ package utils
import (
"context"
"reflect"
networkingv1 "k8s.io/api/networking/v1"
networkingv1beta1 "k8s.io/api/networking/v1beta1"
@@ -13,6 +14,7 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/version"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
)
const TRUE string = "true"
@@ -63,6 +65,18 @@ func GetIngressClassByName(ctx context.Context, version *version.Version, c clie
return obj, nil
}
// Get GatewayClassClass by name (Does not return error if not found).
func GetGatewayClassClassByObjectName(ctx context.Context, c client.Client, gatewayClassName gatewayv1.ObjectName) (*gatewayv1.GatewayClass, error) {
objName := reflect.ValueOf(gatewayClassName).String()
gatewayClass := &gatewayv1.GatewayClass{}
if err := c.Get(ctx, types.NamespacedName{Name: objName}, gatewayClass); err != nil {
return nil, err
}
return gatewayClass, nil
}
// IsDefaultPriorityClass checks if the given PriorityClass is cluster default.
func IsDefaultPriorityClass(class *schedulev1.PriorityClass) bool {
if class != nil {