a controller implements a control loop, watching the shared state of the cluster through the API server and making changes in an attempt to move the current state toward the desired state
Kubernetes并不会根据当前的状态和预期的状态来计算达到预期状态所需要的命令序列,从而来实现所谓的声明式系统,相反Kubernetes仅仅会根据当前的状态计算出下一个命令,如果没有可用的命令,则Kubernetes就达到稳态了
* Edge-driven triggers
At the point in time the state change occurs, a handler is triggered—for example, from no pod to pod running.
* Level-driven triggers
The state is checked at regular intervals and if certain conditions are met (for example, pod running), then a handler is triggered.
// Object interface must be supported by all API types registered with Scheme. Since objects in a scheme are
// expected to be serialized to the wire, the interface an Object must provide to the Scheme allows
// serializers to set the kind, version, and group the object is represented as. An Object may choose
// to return a no-op ObjectKindAccessor in cases where it is not expected to be serialized.
type Object interface {
GetObjectKind() schema.ObjectKind
DeepCopyObject() Object
}
// All objects that are serialized from a Scheme encode their type information. This interface is used
// by serialization to set type information from the Scheme onto the serialized version of an object.
// For objects that cannot be serialized or have unique requirements, this interface may be a no-op.
type ObjectKind interface {
// SetGroupVersionKind sets or clears the intended serialized kind of an object. Passing kind nil
// should clear the current setting.
SetGroupVersionKind(kind GroupVersionKind)
// GroupVersionKind returns the stored group, version, and kind of an object, or nil if the object does
// not expose or provide these fields.
GroupVersionKind() GroupVersionKind
}
// TypeMeta describes an individual object in an API response or request
// with strings representing the type of the object and its API schema version.
// Structures that are versioned or persisted should inline TypeMeta.
//
// +k8s:deepcopy-gen=false
type TypeMeta struct {
// Kind is a string value representing the REST resource this object represents.
// Servers may infer this from the endpoint the client submits requests to.
// Cannot be updated.
// In CamelCase.
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds
// +optional
Kind string `json:"kind,omitempty" protobuf:"bytes,1,opt,name=kind"`
// APIVersion defines the versioned schema of this representation of an object.
// Servers should convert recognized schemas to the latest internal value, and
// may reject unrecognized values.
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#resources
// +optional
APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,2,opt,name=apiVersion"`
}
type ObjectMeta struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
UID types.UID `json:"uid,omitempty"`
ResourceVersion string `json:"resourceVersion,omitempty"`
CreationTimestamp Time `json:"creationTimestamp,omitempty"`
DeletionTimestamp *Time `json:"deletionTimestamp,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
...
}
// Pod is a collection of containers that can run on a host. This resource is created
// by clients and scheduled onto hosts.
type Pod struct {
metav1.TypeMeta `json:",inline"`
// Standard object's metadata.
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
// Specification of the desired behavior of the pod.
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#spec-and-status
// +optional
Spec PodSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
// Most recently observed status of the pod.
// This data may not be up to date.
// Populated by the system.
// Read-only.
// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#spec-and-status
// +optional
Status PodStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}
// Clientset contains the clients for groups. Each group has exactly one// version included in a Clientset.typeClientsetstruct{*discovery.DiscoveryClientadmissionregistrationV1*admissionregistrationv1.AdmissionregistrationV1ClientadmissionregistrationV1beta1*admissionregistrationv1beta1.AdmissionregistrationV1beta1ClientappsV1*appsv1.AppsV1ClientappsV1beta1*appsv1beta1.AppsV1beta1ClientappsV1beta2*appsv1beta2.AppsV1beta2ClientauditregistrationV1alpha1*auditregistrationv1alpha1.AuditregistrationV1alpha1ClientauthenticationV1*authenticationv1.AuthenticationV1ClientauthenticationV1beta1*authenticationv1beta1.AuthenticationV1beta1ClientauthorizationV1*authorizationv1.AuthorizationV1ClientauthorizationV1beta1*authorizationv1beta1.AuthorizationV1beta1ClientautoscalingV1*autoscalingv1.AutoscalingV1ClientautoscalingV2beta1*autoscalingv2beta1.AutoscalingV2beta1ClientautoscalingV2beta2*autoscalingv2beta2.AutoscalingV2beta2ClientbatchV1*batchv1.BatchV1ClientbatchV1beta1*batchv1beta1.BatchV1beta1ClientbatchV2alpha1*batchv2alpha1.BatchV2alpha1ClientcertificatesV1beta1*certificatesv1beta1.CertificatesV1beta1ClientcoordinationV1beta1*coordinationv1beta1.CoordinationV1beta1ClientcoordinationV1*coordinationv1.CoordinationV1ClientcoreV1*corev1.CoreV1ClientdiscoveryV1alpha1*discoveryv1alpha1.DiscoveryV1alpha1ClientdiscoveryV1beta1*discoveryv1beta1.DiscoveryV1beta1ClienteventsV1beta1*eventsv1beta1.EventsV1beta1ClientextensionsV1beta1*extensionsv1beta1.ExtensionsV1beta1ClientflowcontrolV1alpha1*flowcontrolv1alpha1.FlowcontrolV1alpha1ClientnetworkingV1*networkingv1.NetworkingV1ClientnetworkingV1beta1*networkingv1beta1.NetworkingV1beta1ClientnodeV1alpha1*nodev1alpha1.NodeV1alpha1ClientnodeV1beta1*nodev1beta1.NodeV1beta1ClientpolicyV1beta1*policyv1beta1.PolicyV1beta1ClientrbacV1*rbacv1.RbacV1ClientrbacV1beta1*rbacv1beta1.RbacV1beta1ClientrbacV1alpha1*rbacv1alpha1.RbacV1alpha1ClientschedulingV1alpha1*schedulingv1alpha1.SchedulingV1alpha1ClientschedulingV1beta1*schedulingv1beta1.SchedulingV1beta1ClientschedulingV1*schedulingv1.SchedulingV1ClientsettingsV1alpha1*settingsv1alpha1.SettingsV1alpha1ClientstorageV1beta1*storagev1beta1.StorageV1beta1ClientstorageV1*storagev1.StorageV1ClientstorageV1alpha1*storagev1alpha1.StorageV1alpha1Client}
// DeploymentsGetter has a method to return a DeploymentInterface.// A group's client should implement this interface.typeDeploymentsGetterinterface{Deployments(namespacestring)DeploymentInterface}typeAppsV1Interfaceinterface{RESTClient()rest.InterfaceControllerRevisionsGetterDaemonSetsGetterDeploymentsGetterReplicaSetsGetterStatefulSetsGetter}// AppsV1Client is used to interact with features provided by the apps group.typeAppsV1Clientstruct{restClientrest.Interface}// DeploymentInterface has methods to work with Deployment resources.typeDeploymentInterfaceinterface{Create(*v1.Deployment)(*v1.Deployment,error)Update(*v1.Deployment)(*v1.Deployment,error)UpdateStatus(*v1.Deployment)(*v1.Deployment,error)Delete(namestring,options*metav1.DeleteOptions)errorDeleteCollection(options*metav1.DeleteOptions,listOptionsmetav1.ListOptions)errorGet(namestring,optionsmetav1.GetOptions)(*v1.Deployment,error)List(optsmetav1.ListOptions)(*v1.DeploymentList,error)Watch(optsmetav1.ListOptions)(watch.Interface,error)Patch(namestring,pttypes.PatchType,data[]byte,subresources...string)(result*v1.Deployment,errerror)GetScale(deploymentNamestring,optionsmetav1.GetOptions)(*autoscalingv1.Scale,error)UpdateScale(deploymentNamestring,scale*autoscalingv1.Scale)(*autoscalingv1.Scale,error)DeploymentExpansion}// Get takes name of the deployment, and returns the corresponding deployment object, and an error if there is any.func(c*deployments)Get(namestring,optionsmetav1.GetOptions)(result*v1.Deployment,errerror){result=&v1.Deployment{}err=c.client.Get().Namespace(c.ns).Resource("deployments").Name(name).VersionedParams(&options,scheme.ParameterCodec).Do().Into(result)return}
// Interface can be implemented by anything that knows how to watch and report changes.typeInterfaceinterface{// Stops watching. Will close the channel returned by ResultChan(). Releases// any resources used by the watch.Stop()// Returns a chan which will receive all the events. If an error occurs// or Stop() is called, this channel will be closed, in which case the// watch should be completely cleaned up.ResultChan()<-chanEvent}// EventType defines the possible types of events.typeEventTypestringconst(AddedEventType="ADDED"ModifiedEventType="MODIFIED"DeletedEventType="DELETED"BookmarkEventType="BOOKMARK"ErrorEventType="ERROR"DefaultChanSizeint32=100)// Event represents a single event to a watched resource.// +k8s:deepcopy-gen=truetypeEventstruct{TypeEventType// Object is:// * If Type is Added or Modified: the new state of the object.// * If Type is Deleted: the state of the object immediately before deletion.// * If Type is Bookmark: the object (instance of a type being watched) where// only ResourceVersion field is set. On successful restart of watch from a// bookmark resourceVersion, client is guaranteed to not get repeat event// nor miss any events.// * If Type is Error: *api.Status is recommended; other types may make sense// depending on context.Objectruntime.Object}
It is very important to remember that any object passed from the listers to the event handlers is owned by the informers. If you mutate it in any way,
you risk introducing hard-to-debug cache coherency issues into your application. Always do a deep copy (see “Kubernetes Objects in Go”) before changing an object.
informerFactory:=informers.NewSharedInformerFactory(clientset,time.Second*30)podInformer:=informerFactory.Core().V1().Pods()podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{AddFunc:func(newinterface{}){fmt.Println("Create a pod")},UpdateFunc:func(old,newinterface{}){fmt.Println("Update a pod")},DeleteFunc:func(objinterface{}){fmt.Println("Delete a pod")},})informerFactory.Start(wait.NeverStop)informerFactory.WaitForCacheSync(wait.NeverStop)pod,_:=podInformer.Lister().Pods("default").Get("details-v1-5974b67c8-n7vdw")
// RESTMapping contains the information needed to deal with objects of a specific
// resource and kind in a RESTful manner.
type RESTMapping struct {
// Resource is the GroupVersionResource (location) for this endpoint
Resource schema.GroupVersionResource
// GroupVersionKind is the GroupVersionKind (data format) to submit to this endpoint
GroupVersionKind schema.GroupVersionKind
// Scope contains the information needed to deal with REST Resources that are in a resource hierarchy
Scope RESTScope
}
注意,这个CRD的名字需要资源名的复数形式,然后跟上API group name,上面的CRD中资源名为at,API Group的名字就是cnat.programming-kubernetes.info
定义完这个CRD后,我们就可以创建一个at资源了。然后通过kubectl get ats就可以列出所有创建的at资源。
KIND:CustomResourceDefinitionVERSION:apiextensions.k8s.io/v1beta1RESOURCE:subresources <Object>DESCRIPTION:Subresources describes the subresources for CustomResource Optional, theglobal subresources for all versions. Top-level and per-versionsubresources are mutually exclusive.CustomResourceSubresources defines the status and scale subresources forCustomResources.FIELDS:scale <Object>Scale denotes the scale subresource for CustomResourcesstatus <map[string]>Status denotes the status subresource for CustomResources
type Unstructured struct {
// Object is a JSON compatible map with string, float, int, bool, []interface{}, or
// map[string]interface{}
// children.
Object map[string]interface{}
}
// NestedFieldNoCopy returns a reference to a nested field.
// Returns false if value is not found and an error if unable
// to traverse obj.
func NestedFieldNoCopy(obj map[string]interface{}, fields ...string) (interface{}, bool, error) {
var val interface{} = obj
for i, field := range fields {
if m, ok := val.(map[string]interface{}); ok {
val, ok = m[field]
if !ok {
return nil, false, nil
}
} else {
return nil, false, fmt.Errorf("%v accessor error: %v is of the type %T, expected map[string]interface{}", jsonPath(fields[:i+1]), val, val)
}
}
return val, true, nil
}
// NestedString returns the string value of a nested field.
// Returns false if value is not found and an error if not a string.
func NestedString(obj map[string]interface{}, fields ...string) (string, bool, error) {
val, found, err := NestedFieldNoCopy(obj, fields...)
if !found || err != nil {
return "", found, err
}
s, ok := val.(string)
if !ok {
return "", false, fmt.Errorf("%v accessor error: %v is of the type %T, expected string", jsonPath(fields), val, val)
}
return s, true, nil
}
var (
// TODO: move SchemeBuilder with zz_generated.deepcopy.go to k8s.io/api.
// localSchemeBuilder and AddToScheme will stay in k8s.io/kubernetes.
SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
localSchemeBuilder = &SchemeBuilder
AddToScheme = localSchemeBuilder.AddToScheme
)
// Adds the list of known types to the given scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&Deployment{},
&DeploymentList{},
&StatefulSet{},
&StatefulSetList{},
&DaemonSet{},
&DaemonSetList{},
&ReplicaSet{},
&ReplicaSetList{},
&ControllerRevision{},
&ControllerRevisionList{},
)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
// AddKnownTypes registers all types passed in 'types' as being members of version 'version'.
// All objects passed to types should be pointers to structs. The name that go reports for
// the struct becomes the "kind" field when encoding. Version may not be empty - use the
// APIVersionInternal constant if you have a type that does not have a formal version.
func (s *Scheme) AddKnownTypes(gv schema.GroupVersion, types ...Object) {
s.addObservedVersion(gv)
for _, obj := range types {
t := reflect.TypeOf(obj)
if t.Kind() != reflect.Ptr {
panic("All types must be pointers to structs.")
}
t = t.Elem()
s.AddKnownTypeWithName(gv.WithKind(t.Name()), obj)
}
}
// AddKnownTypeWithName is like AddKnownTypes, but it lets you specify what this type should
// be encoded as. Useful for testing when you don't want to make multiple packages to define
// your structs. Version may not be empty - use the APIVersionInternal constant if you have a
// type that does not have a formal version.
func (s *Scheme) AddKnownTypeWithName(gvk schema.GroupVersionKind, obj Object) {
s.addObservedVersion(gvk.GroupVersion())
t := reflect.TypeOf(obj)
if len(gvk.Version) == 0 {
panic(fmt.Sprintf("version is required on all types: %s %v", gvk, t))
}
if t.Kind() != reflect.Ptr {
panic("All types must be pointers to structs.")
}
t = t.Elem()
if t.Kind() != reflect.Struct {
panic("All types must be pointers to structs.")
}
if oldT, found := s.gvkToType[gvk]; found && oldT != t {
panic(fmt.Sprintf("Double registration of different types for %v: old=%v.%v, new=%v.%v in scheme %q",
gvk, oldT.PkgPath(), oldT.Name(), t.PkgPath(), t.Name(), s.schemeName))
}
s.gvkToType[gvk] = t
for _, existingGvk := range s.typeToGVK[t] {
if existingGvk == gvk {
return
}
}
s.typeToGVK[t] = append(s.typeToGVK[t], gvk)
}
set -o pipefail
# 确保k8s.io/code-generator已经在vendor中了SCRIPT_ROOT=$(dirname "${BASH_SOURCE[0]}")/..
CODEGEN_PKG=${CODEGEN_PKG:-$(cd"${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-generator 2>/dev/null ||echo ../code-generator)}# generate the code with:# --output-base because this script should also be able to run inside the vendor dir of# k8s.io/kubernetes. The output-base is needed for the generators to output into the vendor dir# instead of the $GOPATH directly. For normal projects this can be dropped.# 调用k8s.io/code-generator中的generate-groups.sh脚本并指定参数# 1. 指定生成器的类型# 2. 生成的代码所属于的package name(client、informer、lister)# 3. API group的package name# 4. 要生成的 API group和Version,可以有多个,group:version格式。# --output-base 定于生成的代码的基目录# --go-header-file 生成的文件是否放入copyright内容# deepcoy-gen生成器是直接在API group package中生成的。默认生成的文件是zz_generated前缀。
bash "${CODEGEN_PKG}"/generate-groups.sh "deepcopy,client,informer,lister"\
k8s.io/sample-controller/pkg/generated k8s.io/sample-controller/pkg/apis \
samplecontroller:v1alpha1 \
--output-base "$(dirname "${BASH_SOURCE[0]}")/../../.."\
--go-header-file "${SCRIPT_ROOT}"/hack/boilerplate.go.txt
# To use your own boilerplate text append:# --go-header-file "${SCRIPT_ROOT}"/hack/custom-boilerplate.go.txt
pod := newPodForCR(instance)
// Set At instance as the owner and controller
owner := metav1.NewControllerRef(instance, cnatv1alpha1.SchemeGroupVersion.WithKind("At"))
pod.ObjectMeta.OwnerReferences = append(pod.ObjectMeta.OwnerReferences, *owner)
import "k8s.io/client-go/util/workqueue"
// k8s.io/client-go/util/workqueue/queue.go
type Interface interface {
Add(item interface{})
Len() int
Get() (item interface{}, shutdown bool)
Done(item interface{})
ShutDown()
ShuttingDown() bool
}
/*
Add:给队列添加元素(item),可以是任意类型元素。
Len:返回当前队列的长度。
Get:获取队列头部的一个元素。
Done:标记队列中该元素已被处理。
ShutDown:关闭队列。
ShuttingDown:查询队列是否正在关闭。
*/
// k8s.io/client-go/util/workqueue/rate_limiting_queue.go
// RateLimitingInterface is an interface that rate limits items being added to the queue.
type RateLimitingInterface interface {
DelayingInterface
// AddRateLimited adds an item to the workqueue after the rate limiter says it's ok
AddRateLimited(item interface{})
// Forget indicates that an item is finished being retried. Doesn't matter whether it's for perm failing
// or for success, we'll stop the rate limiter from tracking it. This only clears the `rateLimiter`, you
// still have to call `Done` on the queue.
Forget(item interface{})
// NumRequeues returns back how many times the item was requeued
NumRequeues(item interface{}) int
}
/*
AddRateLimited: 将元素重新放回队列并进行限速
Forget:释放指定元素,清空该元素的排队数。
NumRequeues:获取指定元素的排队数。
*/
import "k8s.io/apimachinery/pkg/runtime/schema"
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
// SchemeGroupVersion is group version used to register these objects
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"}
// Adds the list of known types to Scheme.
// AddKnownTypes方法中会通过反射获取到资源对象的名字,然后和GroupVersion组合成GVK,最后用GVK和对象建立映射关系。
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&At{},
&AtList{},
)
// 构建Scheme管理多version
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
func (s *Scheme) AddKnownTypes(gv schema.GroupVersion, types ...Object) {
s.addObservedVersion(gv)
for _, obj := range types {
t := reflect.TypeOf(obj)
if t.Kind() != reflect.Ptr {
panic("All types must be pointers to structs.")
}
t = t.Elem()
s.AddKnownTypeWithName(gv.WithKind(t.Name()), obj)
}
}
unc (r *CronJobReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
log := r.Log.WithValues("cronjob", req.NamespacedName)
var cronJob *batchv1.CronJob
if err := r.Get(ctx, req.NamespacedName, cronJob); err != nil {
log.Error(err, "unable to fetch CronJob")
// we'll ignore not-found errors, since they can't be fixed by an immediate
// requeue (we'll need to wait for a new notification), and we can get them
// on deleted requests.
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// name of our custom finalizer
myFinalizerName := "storage.finalizers.tutorial.kubebuilder.io"
// examine DeletionTimestamp to determine if object is under deletion
if cronJob.ObjectMeta.DeletionTimestamp.IsZero() {
// The object is not being deleted, so if it does not have our finalizer,
// then lets add the finalizer and update the object. This is equivalent
// registering our finalizer.
// 注册finalizer
if !containsString(cronJob.ObjectMeta.Finalizers, myFinalizerName) {
cronJob.ObjectMeta.Finalizers = append(cronJob.ObjectMeta.Finalizers, myFinalizerName)
if err := r.Update(context.Background(), cronJob); err != nil {
return ctrl.Result{}, err
}
}
} else {
// The object is being deleted
if containsString(cronJob.ObjectMeta.Finalizers, myFinalizerName) {
// our finalizer is present, so lets handle any external dependency
if err := r.deleteExternalResources(cronJob); err != nil {
// if fail to delete the external dependency here, return with error
// so that it can be retried
return ctrl.Result{}, err
}
// remove our finalizer from the list and update it.
cronJob.ObjectMeta.Finalizers = removeString(cronJob.ObjectMeta.Finalizers, myFinalizerName)
if err := r.Update(context.Background(), cronJob); err != nil {
return ctrl.Result{}, err
}
}
// Stop reconciliation as the item is being deleted
return ctrl.Result{}, nil
}
// Your reconcile logic
return ctrl.Result{}, nil
}
func (r *Reconciler) deleteExternalResources(cronJob *batch.CronJob) error {
//
// delete any external resources associated with the cronJob
//
// Ensure that delete implementation is idempotent and safe to invoke
// multiple types for same object.
}
// Helper functions to check and remove string from a slice of strings.
func containsString(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}
func removeString(slice []string, s string) (result []string) {
for _, item := range slice {
if item == s {
continue
}
result = append(result, item)
}
return
}
os=$(go env GOOS)arch=$(go env GOARCH)# download kubebuilder and extract it to tmp
curl -L https://go.kubebuilder.io/dl/2.3.1/${os}/${arch}| tar -xz -C /tmp/
# move to a long-term location and put it on your path# (you'll need to set the KUBEBUILDER_ASSETS env var if you put it somewhere else)
sudo mv /tmp/kubebuilder_2.3.1_${os}_${arch} /usr/local/kubebuilder
exportPATH=$PATH:/usr/local/kubebuilder/bin
# Also, you can install a master snapshot from
https://go.kubebuilder.io/dl/latest/${os}/${arch}.
Every API server serves a number of resources and versions
Some resources have multiple versions. To make multiple versions of a resource possible, the API server converts between versions.
To avoid quadratic growth of necessary conversions between versions, API servers use an internal version when implementing the actual API logic.
The internal version is also often called hub version because it is a kind of hub that every other version is converted to and from
API Server在内部给每一个资源都维护了一个内部版本,所有的版本都会转换成这个内部版本再去操作。
用户发送指定版本的请求给API server(比如v1)
API server解码请求,然后转换为内部版本
API server传递内部版本给admission 和 validation
API server在registry中实现的逻辑是根据内部版本来实现的
etcd读和写带有版本的对象(例如v2,存储版本),他将从内部版本进行转换。
最终结果会将转换为请求的版本,比如这里就是v1
Default和Conversion需要给内部版本和外部版本提供Conversion方法和默认值。
This trick of using a pointer works for primitive types like strings. For maps and arrays, it is often hard to reach roundtrippability without identifying nil maps/arrays and empty maps/arrays.
Most defaulters for maps and arrays in Kubernetes therefore apply the default in both cases, working around encoding and decoding bugs.
The client (e.g., our kubectl get pizza margherita) requests a version.
etcd has stored the object in some version.
If the versions do not match, the storage object is sent to the webhook server for conversion. The webhook returns a response with the converted object.