Building a k8s CRD Go (golang) Client
UPDATE: I’ve redone this work with kubebuilder and documented that here. That process is much smoother.
This document is mainly intended for myself to reference in the future the next time I have to build a CRD. The code can be found in the jasonhancock/examplecrd repo on Github.
I was recently building something and decided that I was going to store some data as a Custom Resource inside the k8s cluster. It’s 2023 and it seems like this should be an easy task. My application that will be creating the custom resources is written in Go (golang), so I’ll need to be able to generate a Go client for my custom resource type. This turned out to be harder than it really should be. There were some other articles covering things, but they haven’t aged well:
- https://www.martin-helmich.de/en/blog/kubernetes-crd-client.html
- https://www.velotio.com/engineering-blog/extending-kubernetes-apis-with-custom-resource-definitions-crds
- https://itnext.io/how-to-generate-client-codes-for-kubernetes-custom-resource-definitions-crd-b4b9907769ba
The tooling also leaves a lot to be desired like not supporting Go modules/having to have your code inside the $GOPATH
is just one example.
Let’s start by working through the CustomResourceDefinition. First, I needed to pick some unique name for the API group. This should be unique but informative. I’m going to select examplecrd.jasonhancock.com
. Now we should decide on a Kind
. I’m going to call it ExampleResource
, which translates into a name of exampleresource.examplecrd.jasonhancock.com
. You can see those in the CRD below.
We then need to decide how to scope our resources…should they be namespaced, or cluster-wide? I’m going to go with namespaced.
Now we decide what data we want on the resource. Keeping this example simple and generic, I’m going to say each resource has a color and a size, both of which are strings and both of which are required. Our CRD looks like this:
apiVersion: "apiextensions.k8s.io/v1"
kind: "CustomResourceDefinition"
metadata:
name: "exampleresources.examplecrd.jasonhancock.com"
spec:
group: "examplecrd.jasonhancock.com"
scope: "Namespaced"
names:
plural: "exampleresources"
singular: "exampleresource"
kind: "ExampleResource"
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required:
- spec
properties:
spec:
type: object
required:
- color
- size
properties:
color:
type: string
size:
type: string
I’m going to set up a workspace to contain this work:
cd ~/development
mkdir examplecrd
cd examplecrd
go mod init github.com/jasonhancock/examplecrd
I’m going to write the CRD from above into a file crd.yaml
, then apply it with kubectl:
kubectl apply -f crd.yaml
Let’s manually create an ExampleResource
with kubectl. example.yaml
:
apiVersion: "examplecrd.jasonhancock.com/v1"
kind: "ExampleResource"
metadata:
name: "testing"
spec:
color: red
size: large
kubectl apply -f example.yaml
And we can list our resource:
$ kubectl get exampleresources
NAME AGE
testing 39s
$ kubectl get exampleresource testing -o yaml
apiVersion: examplecrd.jasonhancock.com/v1
kind: ExampleResource
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"examplecrd.jasonhancock.com/v1","kind":"ExampleResource","metadata":{"annotations":{},"name":"testing","namespace":"default"},"spec":{"color":"red","size":"large"}}
creationTimestamp: "2023-07-26T22:11:39Z"
generation: 1
name: testing
namespace: default
resourceVersion: "6950"
uid: be066c5d-7cd2-4a2e-916e-3b6f17f37879
spec:
color: red
size: large
So that’s great. But now I want to do the same thing from some Go code, so let’s get started generating our client. Let’s create a types.go
file in the path from the root of our repo of pkg/apis/examplecrd.jasonhancock.com/v1/types.go
:
package v1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
//go:generate controller-gen object paths=$GOFILE
// ExampleResourceSpec is the spec of the ExampleResource object.
// +kubebuilder:object:generate=true
type ExampleResourceSpec struct {
Color string `json:"color"`
Size string `json:"size"`
}
// +genclient
// ExampleResource is our example resource.
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ExampleResource struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ExampleResourceSpec `json:"spec"`
}
// ExampleResourceList is a list of ExampleResource objects.
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ExampleResourceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ExampleResource `json:"items"`
}
Create a doc.go
in that same directory:
// +k8s:deepcopy-gen=package
// +k8s:defaulter-gen=TypeMeta
// +groupName=examplecrd.jasonhancock.com
package v1
And a register.go:
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// SchemeGroupVersion defines the group info of the API.
var SchemeGroupVersion = schema.GroupVersion{
Group: "examplecrd.jasonhancock.com",
Version: "v1",
}
var (
schemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &schemeBuilder
// AddToScheme adds to the scheme.
AddToScheme = localSchemeBuilder.AddToScheme
)
func init() {
// We only register manually written functions here. The registration of the
// generated functions takes place in the generated files. The separation
// makes the code compile even when the generated files are missing.
localSchemeBuilder.Register(addKnownTypes)
}
// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}
// Adds the list of known types to the given scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(
SchemeGroupVersion,
&ExampleResource{},
&ExampleResourceList{},
)
scheme.AddKnownTypes(
SchemeGroupVersion,
&metav1.Status{},
)
metav1.AddToGroupVersion(
scheme,
SchemeGroupVersion,
)
return nil
}
Let’s update go.mod by running a tidy:
go mod tidy
Let’s set up some tooling….
cd /tmp
git clone git@github.com:kubernetes/code-generator.git
Configure our shell:
export CODEGEN_PKG=/tmp/code-generator
Fake the tooling to think we’re in a $GOPATH:
mkdir /tmp/go/src/github.com/jasonhancock
cd /tmp/go/src/github.com/jasonhancock
ln -s ~/development/examplecrd
cd /tmp/go/src/github.com/jasonhancock/examplecrd
export GOPATH=/tmp/go
./hack/update_codegen.sh
The hack/update_codegen.sh
can be found in the examplecrd repo. I had some trouble getting the binaries from the code-generator to build.
Generate the deepcopy stuff:
cd ~/development/examplecrd/pkg/apis/examplecrd.jasonhancock.com/v1
go generate
unset GOPATH
go mod tidy
You should then be able to build/run the main.go:
export KUBECONFIG=~/.kube/config
cd ~/development/examplecrd
go run main.go
testing red large
This entire process seems like it’s harder than it needs to be. It would be great if the k8s maintainers would at least drop the $GOPATH requirement and work with Go modules as that would likely make things a lot simpler/user friendly.