Skip to main content

CRDs

cp.InstallCRDs(t, sources...) installs CustomResourceDefinitions. It returns once each CRD is Established and discovery serves its resources, so a RESTMapper or client built right after it already knows the new kinds.

install_test.go
package crds_test

import (
"embed"
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"

"github.com/yyewolf/petri"
)

//go:embed testdata/*.yaml
var crdManifests embed.FS

func widgets() schema.GroupVersionResource {
return schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"}
}

func TestWidgetSizeDefaultsToSmall(t *testing.T) {
t.Parallel()

cp := petri.Start(t)
// Returns once the CRD is Established and served, so the new kind is
// usable on the next line. Installing it again from another test is a
// no-op.
cp.InstallCRDs(t, crdManifests)

client, err := dynamic.NewForConfig(cp.Config())
if err != nil {
t.Fatal(err)
}
widget := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "example.com/v1",
"kind": "Widget",
"metadata": map[string]any{"name": "w"},
// Defaults apply inside objects that are present, so spec is sent
// empty rather than left out.
"spec": map[string]any{},
}}
created, err := client.Resource(widgets()).Namespace(cp.Namespace(t)).
Create(t.Context(), widget, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}

// The API server applies the CRD's structural defaults.
size, _, _ := unstructured.NestedString(created.Object, "spec", "size")
if size != "small" {
t.Fatalf("spec.size = %q, want the default %q", size, "small")
}
}
testdata/widgets.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.example.com
spec:
group: example.com
names:
plural: widgets
singular: widget
kind: Widget
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: string
default: small
status:
type: object
properties:
conditions:
type: array
items:
type: object
x-kubernetes-preserve-unknown-fields: true

Sources​

A source can be:

  • a path to a file, or to a directory that is read recursively;
  • an fs.FS, such as manifests embedded with //go:embed;
  • a *apiextensionsv1.CustomResourceDefinition object.

Files can be YAML or JSON, with several documents per file. Documents that are not CRDs are skipped, as in envtest, so you can point it at a directory of mixed manifests such as kubebuilder's config/crd/bases.

Installing from many tests​

Call InstallCRDs in each test that needs the CRDs, not once in TestMain. Parallel tests can install the same CRDs safely:

  • a CRD that does not exist yet is created;
  • a CRD that exists with the same spec, after the API server's defaulting, is left alone;
  • a CRD that exists with a different spec fails the test and shows the diff.

CRDs are never deleted, because other tests on the shared plane may still use them.

Waiting on custom resources​

Custom resources have no typed client unless you generate one. To use the wait helpers on them, wrap a dynamic client's resource with petri.DynamicResource:

status_test.go
package crds_test

import (
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"

"github.com/yyewolf/petri"
)

func TestWaitForAWidgetCondition(t *testing.T) {
t.Parallel()

cp := petri.Start(t)
cp.InstallCRDs(t, crdManifests)

client, err := dynamic.NewForConfig(cp.Config())
if err != nil {
t.Fatal(err)
}
resource := client.Resource(widgets()).Namespace(cp.Namespace(t))

widget, err := resource.Create(t.Context(), &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "example.com/v1",
"kind": "Widget",
"metadata": map[string]any{"name": "w"},
}}, metav1.CreateOptions{})
if err != nil {
t.Fatal(err)
}

// Your controller would set this; the test plays its part.
conditions := []any{map[string]any{"type": "Ready", "status": "True", "reason": "Built"}}
err = unstructured.SetNestedSlice(widget.Object, conditions, "status", "conditions")
if err != nil {
t.Fatal(err)
}
_, err = resource.UpdateStatus(t.Context(), widget, metav1.UpdateOptions{})
if err != nil {
t.Fatal(err)
}

// DynamicResource lets the wait helpers follow kinds with no typed client.
petri.WaitFor(t, petri.DynamicResource(resource), "w",
petri.StatusCondition[*unstructured.Unstructured]("Ready", metav1.ConditionTrue))
}

The namespace controller and the garbage collector handle custom resources too, even for CRDs installed after the plane started. Deleting a namespace removes them, and their owner references cascade.

Without a testing.T​

cp.InstallCRDsCtx(ctx, sources...) returns an error instead of failing a test.