containerized-data-importer/pkg/apiserver/webhooks/datavolume-validate.go
Arnon Gilboa addf25b4f9
Support registry import using node docker cache (#1913)
* Support registry import using node docker cache

The new CRI (container runtime interface) importer pod is created with three containers and a shared emptyDir volume:
-Init container: copies static http server binary to empty dir
-Server container: container image container configured to run the http binary and serve up the image file in /data
-Client container: import.sh uses cdi-import to import from server container, and writes "done" file on emptydir
-Server container sees "done" file and exits

Thanks mhenriks for the PoC!

Done:
-added ImportMethod to DataVolumeSourceRegistry (DataVolume.Spec.Source.Registry, DataImportCron.Spec.Source.Registry).
Import method can be "skopeo" (default), or "cri" for container runtime interface based import
-added cdi-containerimage-server & import.sh to the cdi-importer container

ToDo:
-utests and func tests
-doc

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* Add tests, fix CR comments

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* CR fixes

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* Use deployment docker prefix and tag in func tests

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* Add OpenShift ImageStreams import support

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* Add importer pod lookup annotation for image streams

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>

* Add pullMethod and imageStream doc

Signed-off-by: Arnon Gilboa <agilboa@redhat.com>
2021-09-20 22:05:36 +02:00

554 lines
20 KiB
Go

/*
* This file is part of the CDI project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Copyright 2019 Red Hat, Inc.
*
*/
package webhooks
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"reflect"
admissionv1 "k8s.io/api/admission/v1"
v1 "k8s.io/api/core/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kvalidation "k8s.io/apimachinery/pkg/util/validation"
k8sfield "k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
cdiv1 "kubevirt.io/containerized-data-importer/pkg/apis/core/v1beta1"
cdiclient "kubevirt.io/containerized-data-importer/pkg/client/clientset/versioned"
"kubevirt.io/containerized-data-importer/pkg/controller"
)
type dataVolumeValidatingWebhook struct {
k8sClient kubernetes.Interface
cdiClient cdiclient.Interface
}
func validateSourceURL(sourceURL string) string {
if sourceURL == "" {
return "source URL is empty"
}
url, err := neturl.ParseRequestURI(sourceURL)
if err != nil {
return fmt.Sprintf("Invalid source URL: %s", sourceURL)
}
if url.Scheme != "http" && url.Scheme != "https" {
return fmt.Sprintf("Invalid source URL scheme: %s", sourceURL)
}
return ""
}
func validateDataVolumeName(name string) []metav1.StatusCause {
var causes []metav1.StatusCause
if len(name) > kvalidation.DNS1123SubdomainMaxLength {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Name of data volume cannot be more than %d characters", kvalidation.DNS1123SubdomainMaxLength),
Field: "",
})
}
return causes
}
func validateContentTypes(sourcePVC *v1.PersistentVolumeClaim, spec *cdiv1.DataVolumeSpec) (bool, cdiv1.DataVolumeContentType, cdiv1.DataVolumeContentType) {
sourceContentType := cdiv1.DataVolumeContentType(controller.GetContentType(sourcePVC))
targetContentType := spec.ContentType
if targetContentType == "" {
targetContentType = cdiv1.DataVolumeKubeVirt
}
return sourceContentType == targetContentType, sourceContentType, targetContentType
}
func (wh *dataVolumeValidatingWebhook) validateDataVolumeSpec(request *admissionv1.AdmissionRequest, field *k8sfield.Path, spec *cdiv1.DataVolumeSpec, namespace *string) []metav1.StatusCause {
var causes []metav1.StatusCause
var url string
var sourceType string
if spec.PVC == nil && spec.Storage == nil {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Missing Data volume PVC"),
Field: field.Child("PVC").String(),
})
return causes
}
if spec.PVC != nil && spec.Storage != nil {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Duplicate storage definition, both target storage and target pvc defined"),
Field: field.Child("PVC", "Storage").String(),
})
return causes
}
if spec.PVC != nil {
cause, valid := validateStorageSize(spec.PVC.Resources, field, "PVC")
if !valid {
causes = append(causes, *cause)
return causes
}
accessModes := spec.PVC.AccessModes
if len(accessModes) == 0 {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Required value: at least 1 access mode is required"),
Field: field.Child("PVC", "accessModes").String(),
})
return causes
}
if len(accessModes) > 1 {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("PVC multiple accessModes"),
Field: field.Child("PVC", "accessModes").String(),
})
return causes
}
// We know we have one access mode
if accessModes[0] != v1.ReadWriteOnce && accessModes[0] != v1.ReadOnlyMany && accessModes[0] != v1.ReadWriteMany {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Unsupported value: \"%s\": supported values: \"ReadOnlyMany\", \"ReadWriteMany\", \"ReadWriteOnce\"", string(accessModes[0])),
Field: field.Child("PVC", "accessModes").String(),
})
return causes
}
} else if spec.Storage != nil {
cause, valid := validateStorageSize(spec.Storage.Resources, field, "Storage")
if !valid {
causes = append(causes, *cause)
return causes
}
// here in storage spec we allow empty access mode and AccessModes with more than one entry
accessModes := spec.Storage.AccessModes
for _, mode := range accessModes {
if mode != v1.ReadWriteOnce && mode != v1.ReadOnlyMany && mode != v1.ReadWriteMany {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Unsupported value: \"%s\": supported values: \"ReadOnlyMany\", \"ReadWriteMany\", \"ReadWriteOnce\"", string(accessModes[0])),
Field: field.Child("PVC", "accessModes").String(),
})
return causes
}
}
}
if (spec.Source == nil && spec.SourceRef == nil) || (spec.Source != nil && spec.SourceRef != nil) {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Data volume should have either Source or SourceRef"),
Field: field.Child("source").String(),
})
return causes
}
if spec.SourceRef != nil {
cause := wh.validateSourceRef(request, spec, field, namespace)
if cause != nil {
causes = append(causes, *cause)
}
return causes
}
numberOfSources := 0
s := reflect.ValueOf(spec.Source).Elem()
for i := 0; i < s.NumField(); i++ {
if !reflect.ValueOf(s.Field(i).Interface()).IsNil() {
numberOfSources++
}
}
if numberOfSources == 0 {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Missing Data volume source"),
Field: field.Child("source").String(),
})
return causes
}
if numberOfSources > 1 {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Multiple Data volume sources"),
Field: field.Child("source").String(),
})
return causes
}
// if source types are HTTP, Imageio, S3 or VDDK, check if URL is valid
if spec.Source.HTTP != nil || spec.Source.S3 != nil || spec.Source.Imageio != nil || spec.Source.VDDK != nil {
if spec.Source.HTTP != nil {
url = spec.Source.HTTP.URL
sourceType = field.Child("source", "HTTP", "url").String()
} else if spec.Source.S3 != nil {
url = spec.Source.S3.URL
sourceType = field.Child("source", "S3", "url").String()
} else if spec.Source.Imageio != nil {
url = spec.Source.Imageio.URL
sourceType = field.Child("source", "Imageio", "url").String()
} else if spec.Source.VDDK != nil {
url = spec.Source.VDDK.URL
sourceType = field.Child("source", "VDDK", "url").String()
}
err := validateSourceURL(url)
if err != "" {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s %s", field.Child("source").String(), err),
Field: sourceType,
})
return causes
}
}
// Make sure contentType is either empty (kubevirt), or kubevirt or archive
if spec.ContentType != "" && string(spec.ContentType) != string(cdiv1.DataVolumeKubeVirt) && string(spec.ContentType) != string(cdiv1.DataVolumeArchive) {
sourceType = field.Child("contentType").String()
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("ContentType not one of: %s, %s", cdiv1.DataVolumeKubeVirt, cdiv1.DataVolumeArchive),
Field: sourceType,
})
return causes
}
if spec.Source.Blank != nil && string(spec.ContentType) == string(cdiv1.DataVolumeArchive) {
sourceType = field.Child("contentType").String()
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("SourceType cannot be blank and the contentType be archive"),
Field: sourceType,
})
return causes
}
if spec.Source.Registry != nil {
if spec.ContentType != "" && string(spec.ContentType) != string(cdiv1.DataVolumeKubeVirt) {
sourceType = field.Child("contentType").String()
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("ContentType must be %s when Source is Registry", cdiv1.DataVolumeKubeVirt),
Field: sourceType,
})
return causes
}
sourceURL := spec.Source.Registry.URL
sourceIS := spec.Source.Registry.ImageStream
if (sourceURL == nil && sourceIS == nil) || (sourceURL != nil && sourceIS != nil) {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Source registry should have either URL or ImageStream"),
Field: field.Child("source", "Registry").String(),
})
return causes
}
if sourceURL != nil {
url, err := neturl.Parse(*sourceURL)
if err != nil {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Illegal registry source URL %s", *sourceURL),
Field: field.Child("source", "Registry", "URL").String(),
})
return causes
}
scheme := url.Scheme
if scheme != cdiv1.RegistrySchemeDocker && scheme != cdiv1.RegistrySchemeOci {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Illegal registry source URL scheme %s", url),
Field: field.Child("source", "Registry", "URL").String(),
})
return causes
}
}
importMethod := spec.Source.Registry.PullMethod
if importMethod != nil && *importMethod != cdiv1.RegistryPullPod && *importMethod != cdiv1.RegistryPullNode {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("ImportMethod %s is neither %s, %s or \"\"", *importMethod, cdiv1.RegistryPullPod, cdiv1.RegistryPullNode),
Field: field.Child("source", "Registry", "importMethod").String(),
})
return causes
}
if sourceIS != nil && *sourceIS == "" {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Source registry ImageStream is not valid"),
Field: field.Child("source", "Registry", "importMethod").String(),
})
return causes
}
if sourceIS != nil && (importMethod == nil || *importMethod != cdiv1.RegistryPullNode) {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Source registry ImageStream is supported only with node pull import method"),
Field: field.Child("source", "Registry", "importMethod").String(),
})
return causes
}
}
if spec.Source.Imageio != nil {
if spec.Source.Imageio.SecretRef == "" || spec.Source.Imageio.CertConfigMap == "" || spec.Source.Imageio.DiskID == "" {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s source Imageio is not valid", field.Child("source", "Imageio").String()),
Field: field.Child("source", "Imageio").String(),
})
return causes
}
}
if spec.Source.VDDK != nil {
if spec.Source.VDDK.SecretRef == "" || spec.Source.VDDK.UUID == "" || spec.Source.VDDK.BackingFile == "" || spec.Source.VDDK.Thumbprint == "" {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s source VDDK is not valid", field.Child("source", "VDDK").String()),
Field: field.Child("source", "VDDK").String(),
})
return causes
}
}
if spec.Source.PVC != nil {
if spec.Source.PVC.Namespace == "" || spec.Source.PVC.Name == "" {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s source PVC is not valid", field.Child("source", "PVC").String()),
Field: field.Child("source", "PVC").String(),
})
return causes
}
if request.Operation == admissionv1.Create {
cause := wh.validateDataVolumeSourcePVC(spec.Source.PVC, field.Child("source", "PVC"), spec)
if cause != nil {
causes = append(causes, *cause)
}
}
}
return causes
}
func (wh *dataVolumeValidatingWebhook) validateSourceRef(request *admissionv1.AdmissionRequest, spec *cdiv1.DataVolumeSpec, field *k8sfield.Path, namespace *string) *metav1.StatusCause {
if spec.SourceRef.Kind == "" {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Missing sourceRef kind"),
Field: field.Child("sourceRef", "Kind").String(),
}
}
if spec.SourceRef.Kind != cdiv1.DataVolumeDataSource {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Unsupported sourceRef kind %s, currently only %s is supported", spec.SourceRef.Kind, cdiv1.DataVolumeDataSource),
Field: field.Child("sourceRef", "Kind").String(),
}
}
if spec.SourceRef.Name == "" {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Missing sourceRef name"),
Field: field.Child("sourceRef", "Name").String(),
}
}
if request.Operation != admissionv1.Create {
return nil
}
ns := namespace
if spec.SourceRef.Namespace != nil && *spec.SourceRef.Namespace != "" {
ns = spec.SourceRef.Namespace
}
dataSource, err := wh.cdiClient.CdiV1beta1().DataSources(*ns).Get(context.TODO(), spec.SourceRef.Name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueNotFound,
Message: fmt.Sprintf("SourceRef %s/%s/%s not found", spec.SourceRef.Kind, *ns, spec.SourceRef.Name),
Field: field.Child("sourceRef").String(),
}
}
return &metav1.StatusCause{
Message: err.Error(),
Field: field.Child("sourceRef").String(),
}
}
return wh.validateDataVolumeSourcePVC(dataSource.Spec.Source.PVC, field.Child("sourceRef"), spec)
}
func (wh *dataVolumeValidatingWebhook) validateDataVolumeSourcePVC(PVC *cdiv1.DataVolumeSourcePVC, field *k8sfield.Path, spec *cdiv1.DataVolumeSpec) *metav1.StatusCause {
sourcePVC, err := wh.k8sClient.CoreV1().PersistentVolumeClaims(PVC.Namespace).Get(context.TODO(), PVC.Name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueNotFound,
Message: fmt.Sprintf("Source PVC %s/%s not found", PVC.Namespace, PVC.Name),
Field: field.String(),
}
}
return &metav1.StatusCause{
Message: err.Error(),
Field: field.String(),
}
}
valid, sourceContentType, targetContentType := validateContentTypes(sourcePVC, spec)
if !valid {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("Source contentType (%s) and target contentType (%s) do not match", sourceContentType, targetContentType),
Field: field.String(),
}
}
var targetResources v1.ResourceRequirements
if spec.PVC != nil {
targetResources = spec.PVC.Resources
} else {
targetResources = spec.Storage.Resources
}
if err = controller.ValidateCloneSize(sourcePVC.Spec.Resources, targetResources); err != nil {
return &metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: err.Error(),
Field: field.String(),
}
}
return nil
}
func validateStorageSize(resources v1.ResourceRequirements, field *k8sfield.Path, name string) (*metav1.StatusCause, bool) {
if pvcSize, ok := resources.Requests["storage"]; ok {
if pvcSize.IsZero() || pvcSize.Value() < 0 {
cause := metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s size can't be equal or less than zero", name),
Field: field.Child(name, "resources", "requests", "size").String(),
}
return &cause, false
}
} else {
cause := metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s size is missing", name),
Field: field.Child(name, "resources", "requests", "size").String(),
}
return &cause, false
}
return nil, true
}
func (wh *dataVolumeValidatingWebhook) Admit(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse {
if err := validateDataVolumeResource(ar); err != nil {
return toAdmissionResponseError(err)
}
raw := ar.Request.Object.Raw
dv := cdiv1.DataVolume{}
err := json.Unmarshal(raw, &dv)
if err != nil {
return toAdmissionResponseError(err)
}
if ar.Request.Operation == admissionv1.Update {
oldDV := cdiv1.DataVolume{}
err = json.Unmarshal(ar.Request.OldObject.Raw, &oldDV)
if err != nil {
return toAdmissionResponseError(err)
}
// Always admit checkpoint updates for multi-stage migrations.
multiStageAdmitted := false
isMultiStage := dv.Spec.Source != nil && len(dv.Spec.Checkpoints) > 0 &&
(dv.Spec.Source.VDDK != nil || dv.Spec.Source.Imageio != nil)
if isMultiStage {
oldSpec := oldDV.Spec.DeepCopy()
oldSpec.FinalCheckpoint = false
oldSpec.Checkpoints = nil
newSpec := dv.Spec.DeepCopy()
newSpec.FinalCheckpoint = false
newSpec.Checkpoints = nil
multiStageAdmitted = apiequality.Semantic.DeepEqual(newSpec, oldSpec)
}
if !multiStageAdmitted && !apiequality.Semantic.DeepEqual(dv.Spec, oldDV.Spec) {
klog.Errorf("Cannot update spec for DataVolume %s/%s", dv.GetNamespace(), dv.GetName())
var causes []metav1.StatusCause
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueDuplicate,
Message: fmt.Sprintf("Cannot update DataVolume Spec"),
Field: k8sfield.NewPath("DataVolume").Child("Spec").String(),
})
return toRejectedAdmissionResponse(causes)
}
}
causes := validateDataVolumeName(dv.Name)
if len(causes) > 0 {
klog.Infof("rejected DataVolume admission")
return toRejectedAdmissionResponse(causes)
}
if ar.Request.Operation == admissionv1.Create {
pvc, err := wh.k8sClient.CoreV1().PersistentVolumeClaims(dv.GetNamespace()).Get(context.TODO(), dv.GetName(), metav1.GetOptions{})
if err != nil {
if !k8serrors.IsNotFound(err) {
return toAdmissionResponseError(err)
}
} else {
dvName, ok := pvc.Annotations[controller.AnnPopulatedFor]
if !ok || dvName != dv.GetName() {
pvcOwner := metav1.GetControllerOf(pvc)
// We should reject the DV if a PVC with the same name exists, and that PVC has no ownerRef, or that
// PVC has an ownerRef that is not a DataVolume. Because that means that PVC is not managed by the
// datavolume controller, and we can't use it.
if (pvcOwner == nil) || (pvcOwner.Kind != "DataVolume") {
klog.Errorf("destination PVC %s/%s already exists", pvc.GetNamespace(), pvc.GetName())
var causes []metav1.StatusCause
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueDuplicate,
Message: fmt.Sprintf("Destination PVC %s/%s already exists", pvc.GetNamespace(), pvc.GetName()),
Field: k8sfield.NewPath("DataVolume").Child("Name").String(),
})
return toRejectedAdmissionResponse(causes)
}
}
klog.Infof("Using initialized PVC %s for DataVolume %s", pvc.GetName(), dv.GetName())
}
}
causes = wh.validateDataVolumeSpec(ar.Request, k8sfield.NewPath("spec"), &dv.Spec, &dv.Namespace)
if len(causes) > 0 {
klog.Infof("rejected DataVolume admission %s", causes)
return toRejectedAdmissionResponse(causes)
}
reviewResponse := admissionv1.AdmissionResponse{}
reviewResponse.Allowed = true
return &reviewResponse
}