Prerequisites
CRD conversion webhook support was introduced as alpha feature in Kubernetes 1.13release and has gone beta in Kubernetes 1.15 release. So ensure that you have aKubernetes cluster that supports CRD conversion feature enabled.Refer to instructions to enable CRD conversion feature in yourcluster.Refer to instructions to setup a local cluster withKind.
What are we building ?
In this tutorial, we will implement a simple Disk API. Disk API has a fieldcalled price that represents price per GB. We will go through threeiterations to evolve the price field specification.
- In v1 version of Disk API, price field is string with “AMOUNT CURRENCY” format.Example values could be “10 USD”, “100 USD”.
- In v2 version of Disk, price field is represented by a structure
Price
that hasamount
andcurrency
as separate fields. - In v3 version of Disk, we rename the price field to
pricePerGB
to make itmore explicit. Here are some sample manifests of the three versions representing same Diskobject.
apiVersion: infra.kubebuilder.io/v1
kind: Disk
metadata:
name: disk-sample
spec:
price: 10 USD <--- price as string
----------------------------------------
apiVersion: infra.kubebuilder.io/v2
kind: Disk
metadata:
name: disk-sample
spec:
price: <---- price as structured object
amount: 10
currency: USD
----------------------------------------
apiVersion: infra.kubebuilder.io/v3
kind: Disk
metadata:
name: disk-sample
spec:
pricePerGB: <--- price is renamed to pricePerGB
amount: 10
currency: USD
Tutorial
Now that we have covered the basics and the goal, we are all set to begin thistutorial. We will go through the following steps:
- Project Setup
- Adding API with versions v1, v2, v3 of Disk API
- Setting up Webhooks
- CRD Generation
- Configuring Kustomization
- Deploying and testing
Project Setup
Assuming you have created a new directory and cd in to it. Let’s initialize the project.
# Initialize Go module
go mod init infra.kubebuilder.io
# Initilize Kubebuilder project
kubebuilder init --domain kubebuilder.io
Version v1
Let’s create version v1
of our Disk
API.
# create v1 version with resource and controller
kubebuilder create api --group infra --kind Disk --version v1
Create Resource [y/n]
y
Create Controller [y/n]
y
Let’s take a look at file api/v1/disk_types.go
.
Apache License
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, softwaredistributed 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 andlimitations under the License. Imports
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// First version of our Spec has one field price of type string. We will evolve
// this field over the next two versions.
// DiskSpec defines the desired state of Disk
type DiskSpec struct {
// Price represents price per GB for a Disk. It is specified in the
// the format "<AMOUNT> <CURRENCY>". Example values will be "10 USD", "100 USD"
Price string `json:"price"`
}
We need to specify the version that is being used as storage version. In thiscase, we decided to use v1 version for storage, so we use crd marker +kubebuilder:storageversion
on v1.Disk type to indicate that.
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// Disk is the Schema for the disks API
type Disk struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DiskSpec `json:"spec,omitempty"`
Status DiskStatus `json:"status,omitempty"`
}
We need to define a Hub type to faciliate conversion. Storage and hub version don’t have to be same, but to keep things simple, we will specify v1 to be the Hub for the Disk kind. A version needs to implement conversion.Hub interface to indicate that it is a Hub type. Given that v1 is a Hub version, it doesn’t need to implement any conversion functions.
Next we define Hub() method to indicate that v1 is the hub type
// implements conversion.Hub interface.
func (disk *Disk) Hub() {}
// DiskStatus defines the observed state of Disk
type DiskStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// +kubebuilder:object:root=true
// DiskList contains a list of Disk
type DiskList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Disk `json:"items"`
}
func init() {
SchemeBuilder.Register(&Disk{}, &DiskList{})
}
Version v2
Let’s add version v2
to the Disk
API. We will not add any controller thistime because we already have a controller for Disk
API.
# create v2 version without controller
kubebuilder create api --group infra --kind Disk --version v2
Create Resource [y/n]
y
Create Controller [y/n]
n
Now, let’s take a look at file api/v2/disk_types.go
.
Imports
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, softwaredistributed 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 andlimitations under the License.
package v2
import (
"fmt"
"strconv"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/conversion"
diskapiv1 "infra.kubebuilder.io/api/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
We realized that the price field can be represented in a better way, so innext iteration v2 of our disk API, we changed the price to a structured field.
// DiskSpec defines the desired state of Disk
type DiskSpec struct {
// Price represents price per GB for the Disk.
Price Price `json:"price"`
}
// Price represents a generic price value that has amount and currency.
type Price struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
Type definitions
// DiskStatus defines the observed state of Disk
type DiskStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// +kubebuilder:object:root=true
// Disk is the Schema for the disks API
type Disk struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DiskSpec `json:"spec,omitempty"`
Status DiskStatus `json:"status,omitempty"`
}
The new structure of price field can be converted to and from the price string fieldof the v1 API. Since v2 is a spoke version, v2 type is required to implementconversion.Convertible
interface. Now, let’s take a look at the conversion methods.
// ConvertTo converts receiver (v2.Disk instance in this case) to provided Hub
// instance (v1.Disk in our case).
func (disk *Disk) ConvertTo(dst conversion.Hub) error {
switch t := dst.(type) {
case *diskapiv1.Disk:
diskv1 := dst.(*diskapiv1.Disk)
diskv1.ObjectMeta = disk.ObjectMeta
// conversion implementation goes here
// in our case, we convert the price in structured form to string form.
diskv1.Spec.Price = fmt.Sprintf("%d %s",
disk.Spec.Price.Amount, disk.Spec.Price.Currency)
return nil
default:
return fmt.Errorf("unsupported type %v", t)
}
}
// ConvertFrom converts provided Hub instance (v1.Disk in our case) to receiver
// (v2.Disk in our case).
func (disk *Disk) ConvertFrom(src conversion.Hub) error {
switch t := src.(type) {
case *diskapiv1.Disk:
diskv1 := src.(*diskapiv1.Disk)
disk.ObjectMeta = diskv1.ObjectMeta
// conversion implementation goes here
// We parse price amount and currency from the string form and
// convert it in structured form.
parts := strings.Fields(diskv1.Spec.Price)
if len(parts) != 2 {
return fmt.Errorf("invalid price")
}
amount, err := strconv.Atoi(parts[0])
if err != nil {
return err
}
disk.Spec.Price = Price{
Amount: int64(amount),
Currency: parts[1],
}
return nil
default:
return fmt.Errorf("unsupported type %v", t)
}
}
List definition
// +kubebuilder:object:root=true
// DiskList contains a list of Disk
type DiskList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Disk `json:"items"`
}
func init() {
SchemeBuilder.Register(&Disk{}, &DiskList{})
}
[
Version v3
](https://book.kubebuilder.io/multiversion-tutorial/#version-v3)
Let’s add version v3
to the Disk
API and once again, we will not add anycontroller since we already have controller for the Disk
API.
# create v3 version without controller
kubebuilder create api --group infra --kind Disk --version v3
Create Resource [y/n]
y
Create Controller [y/n]
n
Imports
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, softwaredistributed 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 andlimitations under the License.
package v3
import (
"fmt"
"strconv"
"strings"
diskapiv1 "infra.kubebuilder.io/api/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/conversion"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
In v2 iteration of our API, we evolved price field to a better structure. In v3 iterationwe decided to rename price
field to PricePerGB
to make it more explicit sincethe price field represents price/per/GB.
// DiskSpec defines the desired state of Disk
type DiskSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// PricePerGB represents price for the disk.
PricePerGB Price `json:"pricePerGB"`
}
// Price represents a generic price value that has amount and currency.
type Price struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
Type definitions
// DiskStatus defines the observed state of Disk
type DiskStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// +kubebuilder:object:root=true
// Disk is the Schema for the disks API
type Disk struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DiskSpec `json:"spec,omitempty"`
Status DiskStatus `json:"status,omitempty"`
}
The v3 version has two changes in price field
- New structure of price field (same as v2 version)
- Renaming of the field to
PricePerGB
Since v3 is a spoke version, v3 type is required to implementconversion.Convertible
interface. Now, let’s take a look at the conversion methods.
// ConvertTo converts receiver (v3.Disk instance in this case) to provided Hub
// instance (v1.Disk in our case).
func (disk *Disk) ConvertTo(dst conversion.Hub) error {
switch t := dst.(type) {
case *diskapiv1.Disk:
diskv1 := dst.(*diskapiv1.Disk)
diskv1.ObjectMeta = disk.ObjectMeta
// conversion implementation
// in our case, we convert the price in structured form to string form.
// Note that we use the value from PricePerGB field.
diskv1.Spec.Price = fmt.Sprintf("%d %s",
disk.Spec.PricePerGB.Amount, disk.Spec.PricePerGB.Currency)
return nil
default:
return fmt.Errorf("unsupported type %v", t)
}
}
// ConvertFrom converts provided Hub instance (v1.Disk in our case) to receiver
// (v3.Disk in our case).
func (disk *Disk) ConvertFrom(src conversion.Hub) error {
switch t := src.(type) {
case *diskapiv1.Disk:
diskv1 := src.(*diskapiv1.Disk)
disk.ObjectMeta = diskv1.ObjectMeta
// conversion implementation
// Note that the conversion logic is same as we implement for v2 except
// that we use PricePerGB instead of Price.
parts := strings.Fields(diskv1.Spec.Price)
if len(parts) != 2 {
return fmt.Errorf("invalid price")
}
amount, err := strconv.Atoi(parts[0])
if err != nil {
return err
}
disk.Spec.PricePerGB = Price{
Amount: int64(amount),
Currency: parts[1],
}
return nil
default:
return fmt.Errorf("unsupported type %v", t)
}
}
List definition
// +kubebuilder:object:root=true
// DiskList contains a list of Disk
type DiskList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Disk `json:"items"`
}
func init() {
SchemeBuilder.Register(&Disk{}, &DiskList{})
}
Now that we have all the API implementations in place, let’s take a look at whatis required to setup conversion webhook for our Disk
API.
Setting up Webhooks
In 2.0.0+
release, Kubebuilder introduced new command create webhook
to makeit easy to setup admission and conversion webhooks. Run the following command tosetup conversion webhook. Note that we can specify any version from v1, v2 or v3in this command because there is single conversion webhook for a Kind.
kubebuilder create webhook --group infra --kind Disk --version v1 --conversion
Writing scaffold for you to edit...
api/v1/disk_webhook.go
Above commands does the following:
- Scaffolds a new file
api/v1/disk_webhook.go
to implement webhook setup method. - Updates
main.go
to setup webhooks with the manager instance. Let’s take a quick look at theapi/v1/disk_webhook.go
file.
Imports
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, softwaredistributed 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 andlimitations under the License.
package v1
import (
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
)
// log is for logging in this package.
var disklog = logf.Log.WithName("disk-resource")
// SetupWebhookWithManager ensures that webhooks such as Admission or Conversion
// for the Disk API are registerd with the manager.
func (r *Disk) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}
If you look at main.go
, you will notice the following snippet that invokes theSetupWebhook method.
.....
if err = (&infrav1.Disk{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "Disk")
os.Exit(1)
}
....
CRD Generation
The controller-gen
tool that generates the CRD manifest takes a parameter to indicate if our API has multiple versions. We need to specify trivialVersions=false
in CRD_OPTIONS in your project’s Makefile to enable multi-version.
...
CRD_OPTIONS ?= "crd:trivialVersions=false"
...
Run make manifests
to ensure that CRD manifests gets generated under config/crd/bases/
directory.TODO embed a compressed form of the generated CRD testdata/project/config/crd
Manifests Generation
Kubebuilder generates Kubernetes manifests under ‘config’ directory with webhookbits disabled. Follow the steps below to enable conversion webhook in manifestsgeneration.
- Ensure that
patches/webhookin<kind>.yaml
andpatches/cainjectionin<kind>.yaml
are enabled inconfig/crds/kustomization.yaml
file. - Ensure that
../certmanager
and../webhook
directories are enabled underbases
section inconfig/default/kustomization.yaml
file. - Ensure that
manager_webhook_patch.yaml
is enabled underpatches
section inconfig/default/kustomization.yaml
file. - Enable all the vars under section
CERTMANAGER
inconfig/default/kustomization.yaml
file.