Implements a new Calico API resource by plumbing it through all layers of the codebase. Use after API design is complete (see design-kubernetes-api skill).
Before using this skill, ensure you have:
design-kubernetes-api skill first.Adding a new Calico API resource touches these layers (in dependency order):
api/pkg/apis/projectcalico/v3/)libcalico-go/lib/apis/crd.projectcalico.org/v1/)libcalico-go/lib/apis/crd.projectcalico.org/v1/scheme/scheme.go)libcalico-go/lib/backend/model/resource.golibcalico-go/lib/backend/k8s/resources/)libcalico-go/lib/backend/k8s/client.go)libcalico-go/lib/namespace/resource.go)libcalico-go/lib/validator/v3/validator.go)libcalico-go/lib/clientv3/)apiserver/pkg/registry/projectcalico/)apiserver/pkg/storage/calico/)apiserver/pkg/storage/calico/storage_interface.go)apiserver/pkg/storage/calico/converter.go)apiserver/pkg/registry/projectcalico/rest/storage_calico.go)libcalico-go/lib/backend/syncersv1/felixsyncer/)Work through the following steps in order. Each step references specific files and patterns to follow.
Create the Go type file in api/pkg/apis/projectcalico/v3/.
File: api/pkg/apis/projectcalico/v3/<resourcename>.go
Follow this pattern (using BGPFilter as a clean example):
// Copyright (c) <YEAR> Tigera, Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 ...
package v3
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
const (
KindMyResource = "MyResource"
KindMyResourceList = "MyResourceList"
)
// +genclient:nonNamespaced (cluster-scoped only)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// MyResourceList contains a list of MyResource resources.
type MyResourceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata" protobuf:"bytes,1,opt,name=metadata"`
Items []MyResource `json:"items" protobuf:"bytes,2,rep,name=items"`
}
// For cluster-scoped:
// +genclient
// +genclient:nonNamespaced
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:resource:scope=Cluster,shortName={myres}
// For namespaced:
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:resource:scope=Namespaced,shortName={myres}
// MyResource represents <description suitable for CRD schema docs>.
type MyResource struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata" protobuf:"bytes,1,opt,name=metadata"`
Spec MyResourceSpec `json:"spec" protobuf:"bytes,2,opt,name=spec"`
}
// MyResourceSpec contains the specification for a MyResource resource.
type MyResourceSpec struct {
// ... fields with kubebuilder validation annotations
}
// NewMyResource creates a new (zeroed) MyResource struct with TypeMetadata initialised.
func NewMyResource() *MyResource {
return &MyResource{
TypeMeta: metav1.TypeMeta{
Kind: KindMyResource,
APIVersion: GroupVersionCurrent,
},
}
}
Key annotations:
+genclient — generates typed client code+genclient:nonNamespaced — for cluster-scoped resources (put on BOTH the list type and the main type)+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object — generates DeepCopyObject()+kubebuilder:resource:scope=Cluster|Namespaced — CRD scope+kubebuilder:resource:shortName={...} — kubectl short namesGotcha: The List type needs +genclient:nonNamespaced too (for cluster-scoped resources), and +k8s:deepcopy-gen:interfaces=... on both types. Do NOT put +kubebuilder:resource on the List type — only on the main resource type.
File: api/pkg/apis/projectcalico/v3/register.go
Add both types to the AllKnownTypes slice:
AllKnownTypes = []runtime.Object{
// ... existing types ...
&MyResource{},
&MyResourceList{},
}
cd api && make gen-files
This generates the DeepCopy methods, typed Kubernetes client, informers, listers, and OpenAPI schema needed for compilation of downstream layers:
api/pkg/apis/projectcalico/v3/zz_generated.deepcopy.go — DeepCopy methodsapi/pkg/client/clientset_generated/ — typed Kubernetes clientapi/pkg/client/informers_generated/ — informer factoriesapi/pkg/client/listers_generated/ — listersapi/pkg/openapi/generated.openapi.go — OpenAPI schemaRun this early so the remaining steps can compile against the generated types. A full make generate at the project root is still needed later (Step 19) to pick up CRDs, manifests, and other downstream generated files.
Calico has a dual-CRD system. Older clusters use crd.projectcalico.org/v1 CRDs, newer ones use projectcalico.org/v3. New resources need a type definition in the v1 scheme for backward compatibility.
File: libcalico-go/lib/apis/crd.projectcalico.org/v1/<myresource>_types.go
package v1
import (
v3 "github.com/projectcalico/api/pkg/apis/projectcalico/v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +kubebuilder:resource:scope=Cluster // or Namespaced
type MyResource struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec v3.MyResourceSpec `json:"spec,omitempty"`
}
Note: the v1 type re-uses the v3 Spec struct — only the top-level wrapper differs.
File: libcalico-go/lib/apis/crd.projectcalico.org/v1/scheme/scheme.go
Add the type to the BuilderCRDv1() function's AddKnownTypes call:
&apiv3.MyResource{},
&apiv3.MyResourceList{},
File: libcalico-go/lib/backend/model/resource.go
Add to the init() function:
registerResourceInfo[apiv3.MyResource](apiv3.KindMyResource, "myresources")
The plural name here must match the CRD plural. This enables the generic ResourceKey-based storage path.
File: libcalico-go/lib/backend/k8s/resources/<myresource>.go
For simple resources that use the v3 CRDs directly (typical for new resources):
package resources
import (
"reflect"
apiv3 "github.com/projectcalico/api/pkg/apis/projectcalico/v3"
"k8s.io/client-go/rest"
)
const (
MyResourceResourceName = "MyResources"
)
func NewMyResourceClient(r rest.Interface, group BackingAPIGroup) K8sResourceClient {
return &customResourceClient{
restClient: r,
resource: MyResourceResourceName,
k8sResourceType: reflect.TypeOf(apiv3.MyResource{}),
k8sListType: reflect.TypeOf(apiv3.MyResourceList{}),
kind: apiv3.KindMyResource,
apiGroup: group,
}
}
Gotcha: The resource field is the CRD resource name (plural, PascalCase used by the REST client).
File: libcalico-go/lib/backend/k8s/client.go
Add to the resource client registration block:
c.registerResourceClient(
reflect.TypeOf(model.ResourceKey{}),
reflect.TypeOf(model.ResourceListOptions{}),
apiv3.KindMyResource,
resources.NewMyResourceClient(restClient, group),
)
If there is any CRD cleanup or garbage-collection mechanism for v3 kinds in this client, ensure your new kind is included there as appropriate.
File: libcalico-go/lib/namespace/resource.go
If your resource is namespaced, add its Kind to the IsNamespaced switch:
func IsNamespaced(kind string) bool {
switch kind {
case // ... existing cases ...,
apiv3.KindMyResource:
return true
// ...
}
}
File: libcalico-go/lib/validator/v3/validator.go
Prefer kubebuilder annotations for validation wherever possible (e.g., +kubebuilder:validation:Enum, +kubebuilder:validation:Pattern, +kubebuilder:validation:Required). Kubebuilder annotations generate CRD schema validation that is enforced by the Kubernetes API server. The Go struct validator in this file is only executed by the crd.projectcalico.org/v1 code path — it is not executed for projectcalico.org/v3 CRDs, so any validation that only lives here will be silently skipped on newer clusters.
If your resource needs cross-field or complex validation that cannot be expressed with kubebuilder annotations, register a struct validator:
// In the init/registration function:
registerStructValidator(validate, validateMyResourceSpec, api.MyResourceSpec{})
// Validation function:
func validateMyResourceSpec(structLevel validator.StructLevel) {
spec := structLevel.Current().Interface().(api.MyResourceSpec)
// ... validation logic ...
}
Simple resources may not need custom validation if kubebuilder annotations are sufficient.
File: libcalico-go/lib/clientv3/<myresource>.go
Create the typed client interface and implementation:
package clientv3
import (
"context"
v3 "github.com/projectcalico/api/pkg/apis/projectcalico/v3"
"github.com/projectcalico/calico/libcalico-go/lib/options"
validator "github.com/projectcalico/calico/libcalico-go/lib/validator/v3"
"github.com/projectcalico/calico/libcalico-go/lib/watch"
)
// MyResourceInterface has methods to work with MyResource resources.
type MyResourceInterface interface {
Create(ctx context.Context, res *v3.MyResource, opts options.SetOptions) (*v3.MyResource, error)
Update(ctx context.Context, res *v3.MyResource, opts options.SetOptions) (*v3.MyResource, error)
Delete(ctx context.Context, name string, opts options.DeleteOptions) (*v3.MyResource, error)
Get(ctx context.Context, name string, opts options.GetOptions) (*v3.MyResource, error)
List(ctx context.Context, opts options.ListOptions) (*v3.MyResourceList, error)
Watch(ctx context.Context, opts options.ListOptions) (watch.Interface, error)
}
// myResources implements MyResourceInterface
type myResources struct {
client client
}
func (r myResources) Create(ctx context.Context, res *v3.MyResource, opts options.SetOptions) (*v3.MyResource, error) {
if err := validator.Validate(res); err != nil {
return nil, err
}
out, err := r.client.resources.Create(ctx, opts, v3.KindMyResource, res)
if out != nil {
return out.(*v3.MyResource), err
}
return nil, err
}
// ... Update, Delete, Get, List, Watch follow the same pattern.
// For namespaced: Delete/Get take (namespace, name) instead of just name.
// For namespaced: use namespace parameter instead of noNamespace constant.
File: libcalico-go/lib/clientv3/interface.go
Add the client interface:
type MyResourceClient interface {
MyResources() MyResourceInterface
}
Add MyResourceClient to the main Interface interface.
File: libcalico-go/lib/clientv3/client.go
Add the accessor method:
func (c client) MyResources() MyResourceInterface {
return myResources{client: c}
}
Create a new directory: apiserver/pkg/registry/projectcalico/<myresource>/
File: apiserver/pkg/registry/projectcalico/<myresource>/storage.go
Follow the pattern from apiserver/pkg/registry/projectcalico/ipamconfig/storage.go:
EmptyObject() returns &calico.MyResource{}NewList() returns &calico.MyResourceList{}NewREST() creates the registry.Store with:
KeyRootFunc / KeyFunc using opts.KeyRootFunc(namespaced) / opts.KeyFunc(namespaced)NoNamespaceKeyFunc (cluster-scoped) or NamespaceKeyFunc (namespaced)DefaultQualifiedResource: calico.Resource("myresources")File: apiserver/pkg/registry/projectcalico/<myresource>/strategy.go
Follow the pattern from apiserver/pkg/registry/projectcalico/ipamconfig/strategy.go:
NamespaceScoped() returns true/false based on resource scopeGetAttrs, MatchMyResource, MyResourceToSelectableFields functionsFile: apiserver/pkg/storage/calico/<myresource>_storage.go
Follow the pattern from apiserver/pkg/storage/calico/ipamconfig_storage.go. This wires the apiserver to the libcalico-go clientv3:
func NewMyResourceStorage(opts Options) (registry.DryRunnableStorage, factory.DestroyFunc) {
c := CreateClientFromConfig()
createFn := func(ctx context.Context, c clientv3.Interface, obj resourceObject, opts clientOpts) (resourceObject, error) {
oso := opts.(options.SetOptions)
res := obj.(*api.MyResource)
return c.MyResources().Create(ctx, res, oso)
}
// ... update, get, delete, list, watch functions ...
// Build resourceStore with converter
}
Include a converter struct that handles convertToLibcalico, convertToAAPI, and convertToAAPIList. For simple resources where the API type is the same in both layers, the converter is a straightforward field copy.
File: apiserver/pkg/storage/calico/storage_interface.go
Add a case to the NewStorage switch:
case "projectcalico.org/myresources":
return NewMyResourceStorage(opts)
File: apiserver/pkg/storage/calico/converter.go
Add a case to the convertToAAPI function:
case *v3.MyResource:
aapi := &v3.MyResource{}
MyResourceConverter{}.convertToAAPI(obj, aapi)
return aapi
File: apiserver/pkg/registry/projectcalico/rest/storage_calico.go
storage["myresources"] = rESTInPeace(calico<myresource>.NewREST(scheme, *myresourceOpts))
Only needed if a component (Felix, confd, etc.) needs to watch this resource.
New resources use ResourceKey and are passed directly through the syncer layer without transformation. This means you typically do NOT need an update processor.
File: libcalico-go/lib/backend/syncersv1/felixsyncer/felixsyncerv1.go
Add to the appropriate section (always-on or leader-only):
{
ListInterface: model.ResourceListOptions{Kind: apiv3.KindMyResource},
},
No UpdateProcessor is needed for resources using ResourceKey — they pass through as-is to Felix/confd.
Alternative: Components using Kubernetes informers — Some components (kube-controllers, webhooks) use the generated Kubernetes informers from api/pkg/client/informers_generated/ instead of the syncer layer. These components automatically pick up new resources after code generation (Step 3) without additional plumbing. Check if your consuming component uses:
File: calicoctl/calicoctl/resourcemgr/<myresource>.go
Register the resource for calicoctl CRUD commands (create, get, update, delete, replace). This is a single file with an init() function — no other calicoctl files need changing.
package resourcemgr
import (
"context"
api "github.com/projectcalico/api/pkg/apis/projectcalico/v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
client "github.com/projectcalico/calico/libcalico-go/lib/clientv3"
"github.com/projectcalico/calico/libcalico-go/lib/options"
)
func init() {
registerResource(
api.NewMyResource(),
newMyResourceList(),
false, // isNamespaced
[]string{"myresource", "myresources"},
[]string{"NAME"},
[]string{"NAME"},
map[string]string{
"NAME": "{{.ObjectMeta.Name}}",
},
func(ctx context.Context, client client.Interface, resource ResourceObject) (ResourceObject, error) {
r := resource.(*api.MyResource)
return client.MyResources().Create(ctx, r, options.SetOptions{})
},
func(ctx context.Context, client client.Interface, resource ResourceObject) (ResourceObject, error) {
r := resource.(*api.MyResource)
return client.MyResources().Update(ctx, r, options.SetOptions{})
},
func(ctx context.Context, client client.Interface, resource ResourceObject) (ResourceObject, error) {
r := resource.(*api.MyResource)
return client.MyResources().Delete(ctx, r.Name, options.DeleteOptions{ResourceVersion: r.ResourceVersion})
},
func(ctx context.Context, client client.Interface, resource ResourceObject) (ResourceObject, error) {
r := resource.(*api.MyResource)
return client.MyResources().Get(ctx, r.Name, options.GetOptions{ResourceVersion: r.ResourceVersion})
},
func(ctx context.Context, client client.Interface, resource ResourceObject) (ResourceListObject, error) {
r := resource.(*api.MyResource)
return client.MyResources().List(ctx, options.ListOptions{ResourceVersion: r.ResourceVersion, Name: r.Name})
},
)
}
func newMyResourceList() *api.MyResourceList {
return &api.MyResourceList{
TypeMeta: metav1.TypeMeta{
Kind: api.KindMyResourceList,
APIVersion: api.GroupVersionCurrent,
},
}
}
For namespaced resources: set isNamespaced to true, add "NAMESPACE": "{{.ObjectMeta.Namespace}}" to the headings map, and pass r.Namespace as the first argument to Delete/Get/List client calls.
The init() function auto-registers the resource — calicoctl's generic CRUD commands, help text, and resource name resolution all pick it up automatically.
File: charts/calico/templates/calico-node-rbac.yaml (and/or operator role templates)
Add RBAC rules for the new resource if components need to access it:
- apiGroups: ["projectcalico.org"]
resources: ["myresources"]
verbs: ["get", "list", "watch"]
# Regenerate everything — CRDs, manifests, CI config, and any remaining generated files
make generate
# This also runs make fix-changed automatically at the end.
# Verify
make yaml-lint
make check-go-mod
Gotcha: make generate at the project root produces many downstream files beyond the api/ directory — CRD YAML in manifests/, Helm chart outputs, Semaphore CI config, etc. You MUST commit all generated files alongside your source changes. CI will reject PRs with stale generated files.
Use this checklist to verify completeness:
api/pkg/apis/projectcalico/v3/api/pkg/apis/projectcalico/v3/register.go (AllKnownTypes)cd api && make gen-files)libcalico-go/lib/apis/crd.projectcalico.org/v1/.../scheme/scheme.golibcalico-go/lib/backend/model/resource.golibcalico-go/lib/backend/k8s/resources/libcalico-go/lib/backend/k8s/client.golibcalico-go/lib/namespace/resource.golibcalico-go/lib/validator/v3/validator.go (if needed)libcalico-go/lib/clientv3/libcalico-go/lib/clientv3/interface.golibcalico-go/lib/clientv3/client.goapiserver/pkg/registry/projectcalico/<resource>/apiserver/pkg/storage/calico/<resource>_storage.goapiserver/pkg/storage/calico/storage_interface.goapiserver/pkg/storage/calico/converter.goapiserver/pkg/registry/projectcalico/rest/storage_calico.golibcalico-go/lib/backend/syncersv1/felixsyncer/felixsyncerv1.gocalicoctl/calicoctl/resourcemgr/<resource>.gomake fix-changed)Forgetting to regenerate: Always run cd api && make gen-files after changing API types, and make generate at root level for CRDs and manifests.
CRD plural mismatch: The plural name must be consistent across model/resource.go, the apiserver storage interface, the REST storage provider, and the CRD YAML. Kubernetes lowercases everything.
Missing scheme registration: Resources must be registered in BOTH api/pkg/apis/projectcalico/v3/register.go (the API scheme) AND libcalico-go/lib/apis/crd.projectcalico.org/v1/scheme/scheme.go (the CRD v1 scheme).
Dual CRD versions: Calico supports both crd.projectcalico.org/v1 and projectcalico.org/v3 CRDs. New resources need type definitions and resource clients that handle both API groups, or at minimum a type in the v1 scheme.
Syncer vs Informer confusion: Felix/Typha use the syncer layer (explicit registration needed). Kube-controllers and webhooks use Kubernetes informers (automatic from codegen). Check which pattern your consuming component uses.
ResourceKey passthrough: New resources should use ResourceKey/ResourceListOptions for the syncer. They do NOT need update processors — the resource passes through as-is. Only legacy resources that need v1-to-v3 conversion use update processors.
Status subresource: If your resource has a Status field, you need additional apiserver plumbing for the /status subresource endpoint (see kubecontrollersconfig for an example).