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.
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")
}
}
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.CustomResourceDefinitionobject.
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:
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.