appuio/appuio-cloud-agent

View on GitHub
webhooks/namespace_quota_validator.go

Summary

Maintainability
A
3 hrs
Test Coverage
C
74%
package webhooks

import (
    "context"
    "fmt"
    "net/http"
    "strconv"

    userv1 "github.com/openshift/api/user/v1"
    corev1 "k8s.io/api/core/v1"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    "k8s.io/apimachinery/pkg/types"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/webhook/admission"

    cloudagentv1 "github.com/appuio/appuio-cloud-agent/api/v1"
    "github.com/appuio/appuio-cloud-agent/skipper"
)

// +kubebuilder:webhook:path=/validate-namespace-quota,name=validate-namespace-quota.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=false,failurePolicy=Fail,groups="",resources=namespaces,verbs=create,versions=v1,matchPolicy=equivalent
// +kubebuilder:webhook:path=/validate-namespace-quota,name=validate-namespace-quota-projectrequests.appuio.io,admissionReviewVersions=v1,sideEffects=none,mutating=false,failurePolicy=Fail,groups=project.openshift.io,resources=projectrequests,verbs=create,versions=v1,matchPolicy=equivalent
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch
// +kubebuilder:rbac:groups=user.openshift.io,resources=users,verbs=get;list;watch

// NamespaceQuotaValidator checks if a user is allowed to create a namespace.
// The user or the namespace must have a label with the organization name.
// The organization name is used to count the number of namespaces for the organization.
type NamespaceQuotaValidator struct {
    Decoder *admission.Decoder

    // Client is used to fetch namespace counts
    Client client.Reader

    Skipper skipper.Skipper

    // SkipValidateQuota allows skipping the quota validation.
    // If the validation is skipped only the organization label is checked.
    SkipValidateQuota bool

    OrganizationLabel                 string
    UserDefaultOrganizationAnnotation string

    // SelectedProfile is the name of the ZoneUsageProfile to use for the quota
    SelectedProfile string

    // QuotaOverrideNamespace is the namespace in which the quota overrides are stored
    QuotaOverrideNamespace string
}

// Handle handles the admission requests
func (v *NamespaceQuotaValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
    ctx = log.IntoContext(ctx, log.FromContext(ctx).
        WithName("webhook.validate-namespace-quota.appuio.io").
        WithValues("id", req.UID, "user", req.UserInfo.Username).
        WithValues("namespace", req.Namespace, "name", req.Name,
            "group", req.Kind.Group, "version", req.Kind.Version, "kind", req.Kind.Kind))

    return logAdmissionResponse(ctx, v.handle(ctx, req))
}

func (v *NamespaceQuotaValidator) handle(ctx context.Context, req admission.Request) admission.Response {
    l := log.FromContext(ctx)

    skip, err := v.Skipper.Skip(ctx, req)
    if err != nil {
        l.Error(err, "error while checking skipper")
        return admission.Errored(http.StatusInternalServerError, err)
    }
    if skip {
        return admission.Allowed("skipped")
    }

    var rawObject unstructured.Unstructured
    if err := v.Decoder.Decode(req, &rawObject); err != nil {
        l.Error(err, "failed to decode request")
        return admission.Errored(http.StatusBadRequest, err)
    }

    // try to get the organization name from a namespace object.
    // Note: ProjectRequest labels are ignored by the API server so only the user default organization can be used.
    var organizationName string
    if rawObject.GetKind() == "Namespace" {
        on, _, err := unstructured.NestedString(rawObject.Object, "metadata", "labels", v.OrganizationLabel)
        if err != nil {
            l.Error(err, "error while fetching organization label")
            return admission.Errored(http.StatusInternalServerError, err)
        }
        organizationName = on
    }

    // get the organization name from the user if it is not set on the namespace/projectrequest
    if organizationName == "" {
        var user userv1.User
        if err := v.Client.Get(ctx, client.ObjectKey{Name: req.UserInfo.Username}, &user); err != nil {
            l.Error(err, "error while fetching user")
            return admission.Errored(http.StatusInternalServerError, err)
        }
        don := user.Annotations[v.UserDefaultOrganizationAnnotation]
        if don == "" {
            return admission.Denied("There is no organization label and the user has no default organization set.")
        }
        organizationName = don
        l.Info("got default organization from user", "user", req.UserInfo.Username, "organization", organizationName)
    }

    if v.SkipValidateQuota {
        return admission.Allowed("skipped quota validation")
    }

    if v.SelectedProfile == "" {
        return admission.Denied("No ZoneUsageProfile selected")
    }

    var profile cloudagentv1.ZoneUsageProfile
    if err := v.Client.Get(ctx, types.NamespacedName{Name: v.SelectedProfile}, &profile); err != nil {
        l.Error(err, "error while fetching zone usage profile")
        return admission.Errored(http.StatusInternalServerError, err)
    }
    nsCountLimit := profile.Spec.UpstreamSpec.NamespaceCount

    var overrideCM corev1.ConfigMap
    if err := v.Client.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("override-%s", organizationName), Namespace: v.QuotaOverrideNamespace}, &overrideCM); err == nil {
        if overrideCM.Data["namespaceQuota"] != "" {
            nsCountLimit, err = strconv.Atoi(overrideCM.Data["namespaceQuota"])
            if err != nil {
                l.Error(err, "error while parsing namespace quota")
                return admission.Errored(http.StatusInternalServerError, err)
            }
        }
    } else if !apierrors.IsNotFound(err) {
        l.Error(err, "error while fetching override configmap")
        return admission.Errored(http.StatusInternalServerError, err)
    }

    // count namespaces for organization
    var nsList corev1.NamespaceList
    if err := v.Client.List(ctx, &nsList, client.MatchingLabels{
        v.OrganizationLabel: organizationName,
    }); err != nil {
        l.Error(err, "error while listing namespaces")
        return admission.Errored(http.StatusInternalServerError, err)
    }
    if len(nsList.Items) >= nsCountLimit {
        return admission.Denied(fmt.Sprintf(
            "You cannot create more than %d namespaces for organization %q. Please contact support to have your quota raised.",
            nsCountLimit, organizationName))
    }

    return admission.Allowed("allowed")
}

// logAdmissionResponse logs the admission response to the logger derived from the given context and returns it unchanged.
func logAdmissionResponse(ctx context.Context, res admission.Response) admission.Response {
    l := log.FromContext(ctx)

    rmsg := "<not given>"
    if res.Result != nil {
        rmsg = res.Result.Message
    }
    msg := "denied"
    if res.Allowed {
        msg = "allowed"
    }

    l.Info(msg, "admission_message", rmsg)

    return res
}