diff --git a/README.md b/README.md index a6fe3be..d62fbde 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,16 @@ func (m *MyResource) GetGroupResource() schema.GroupResource { func (m *MyResource) CopyStatusTo(obj runtime.Object) { obj.(*MyResource).Status = m.Status } + +// Optional: set singularName for kubectl usage +func (m *MyResource) GetSingularName() string { + return "myresource" +} + +// Optional: set shortNames for kubectl usage +func (m *MyResource) ShortNames() []string { + return []string{"mr", "mrs"} +} ``` ### 2. Build and run the API server @@ -128,16 +138,16 @@ var _ = AfterSuite(func() { Resources can implement optional interfaces to customize API server behavior: -| Interface | Purpose | -|-----------|---------| -| `Validater` | Validate on create | -| `ValidateUpdater` | Validate on update | -| `PrepareForCreater` | Normalize before create | -| `PrepareForUpdater` | Normalize before update | -| `Canonicalizer` | Transform to canonical form | -| `AllowCreateOnUpdater` | Allow PUT to create | +| Interface | Purpose | +| --- | --- | +| `Validater` | Validate on create | +| `ValidateUpdater` | Validate on update | +| `PrepareForCreater` | Normalize before create | +| `PrepareForUpdater` | Normalize before update | +| `Canonicalizer` | Transform to canonical form | +| `AllowCreateOnUpdater` | Allow PUT to create | | `AllowUnconditionalUpdater` | Allow updates without resourceVersion | -| `TableConverter` | Custom kubectl table output | +| `TableConverter` | Custom kubectl table output | Example validation: diff --git a/apiserver/builder_test.go b/apiserver/builder_test.go index 8d3d817..5a28ea8 100644 --- a/apiserver/builder_test.go +++ b/apiserver/builder_test.go @@ -4,6 +4,7 @@ package apiserver import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -217,3 +218,134 @@ func (m *mockStorage) DeepCopyObject() runtime.Object { func (m *mockStorage) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind } + +var _ = Describe("Resource with interfaces", func() { + Describe("Resource with SingularNameProvider", func() { + It("should set singular name on the store", func() { + obj := &mockResourceObject{ + gr: schema.GroupResource{Group: "test.example.com", Resource: "testresources"}, + singularName: "testresource", + } + handler := Resource(obj, schema.GroupVersion{Group: "test.example.com", Version: "v1"}) + + Expect(handler.groupVersions).To(HaveLen(1)) + Expect(handler.groupVersions[0]).To(Equal(schema.GroupVersion{Group: "test.example.com", Version: "v1"})) + }) + }) + + Describe("Resource with ShortNamesProvider", func() { + It("should wrap store with ShortNamesProvider when short names provided", func() { + obj := &mockResourceObject{ + gr: schema.GroupResource{Group: "test.example.com", Resource: "testresources"}, + shortNames: []string{"tr", "tres"}, + } + handler := Resource(obj, schema.GroupVersion{Group: "test.example.com", Version: "v1"}) + + Expect(handler.groupVersions).To(HaveLen(1)) + Expect(handler.groupVersions[0]).To(Equal(schema.GroupVersion{Group: "test.example.com", Version: "v1"})) + }) + }) + + Describe("Resource with both SingularNameProvider and ShortNamesProvider", func() { + It("should set both options correctly", func() { + obj := &mockResourceObject{ + gr: schema.GroupResource{Group: "test.example.com", Resource: "testresources"}, + singularName: "testresource", + shortNames: []string{"tr"}, + } + handler := Resource(obj, schema.GroupVersion{Group: "test.example.com", Version: "v1"}) + + Expect(handler.groupVersions).To(HaveLen(1)) + Expect(handler.groupVersions[0]).To(Equal(schema.GroupVersion{Group: "test.example.com", Version: "v1"})) + }) + }) + + Describe("Resource with no custom interfaces", func() { + It("should work without implementing ShortNamesProvider or SingularNameProvider", func() { + obj := &mockResourceObject{ + gr: schema.GroupResource{Group: "test.example.com", Resource: "testresources"}, + } + handler := Resource(obj, schema.GroupVersion{Group: "test.example.com", Version: "v1"}) + + Expect(handler.groupVersions).To(HaveLen(1)) + }) + }) +}) + +type mockResourceObject struct { + gr schema.GroupResource + singularName string + shortNames []string +} + +func (m *mockResourceObject) GetObjectMeta() *metav1.ObjectMeta { + return &metav1.ObjectMeta{} +} + +func (m *mockResourceObject) NamespaceScoped() bool { + return true +} + +func (m *mockResourceObject) New() runtime.Object { + return &mockResourceObject{} +} + +func (m *mockResourceObject) NewList() runtime.Object { + return &mockResourceList{} +} + +func (m *mockResourceObject) GetGroupResource() schema.GroupResource { + return m.gr +} + +func (m *mockResourceObject) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (m *mockResourceObject) ShortNames() []string { + return m.shortNames +} + +func (m *mockResourceObject) GetSingularName() string { + return m.singularName +} + +func (m *mockResourceObject) DeepCopyInto(out *mockResourceObject) { + *out = *m +} + +func (m *mockResourceObject) DeepCopyObject() runtime.Object { + if m == nil { + return nil + } + outCopy := &mockResourceObject{} + m.DeepCopyInto(outCopy) + + return outCopy +} + +type mockResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []mockResourceObject +} + +func (l *mockResourceList) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (l *mockResourceList) DeepCopyObject() runtime.Object { + if l == nil { + return nil + } + out := &mockResourceList{ + TypeMeta: l.TypeMeta, + ListMeta: l.ListMeta, + } + if l.Items != nil { + out.Items = make([]mockResourceObject, len(l.Items)) + copy(out.Items, l.Items) + } + + return out +} diff --git a/apiserver/resource.go b/apiserver/resource.go index db934d2..4183205 100644 --- a/apiserver/resource.go +++ b/apiserver/resource.go @@ -16,11 +16,30 @@ import ( "go.opendefense.cloud/kit/apiserver/rest" ) +// ResourceHandler holds the configuration for registering a resource with the API server. type ResourceHandler struct { groupVersions []schema.GroupVersion apiGroupFn APIGroupFn } +// Resource registers a Kubernetes resource with the API server. +// +// The type parameters are: +// - E: the internal resource type implementing resource.Object +// - T: the typed resource (e.g., *Bar) that also implements resource.ObjectWithDeepCopy[E] +// +// The gvs parameter specifies which group versions to register. +// +// To customize the resource's short names or singular name in kubectl, implement +// ShortNamesProvider or SingularNameProvider on the resource type T: +// +// func (b *Bar) ShortNames() []string { +// return []string{"br"} +// } +// +// func (b *Bar) GetSingularName() string { +// return "bar" +// } func Resource[E resource.Object, T resource.ObjectWithDeepCopy[E]](obj T, gvs ...schema.GroupVersion) ResourceHandler { return ResourceHandler{ groupVersions: gvs, @@ -45,9 +64,12 @@ func Resource[E resource.Object, T resource.ObjectWithDeepCopy[E]](obj T, gvs .. copyableOld := any(old).(T) copyableOld.DeepCopyInto(copyableObj) } - statusStore := *store + // We need to access the underlying *registry.Store for status subresource. + // Use rest.Unwrap to handle both wrapped (storeWithShortNames) and unwrapped cases. + // Make a value copy so we can modify only the status copy's UpdateStrategy. + statusStore := *rest.Unwrap(store) statusStore.UpdateStrategy = &rest.PrepareForUpdaterStrategy{ - RESTUpdateStrategy: store.UpdateStrategy, + RESTUpdateStrategy: statusStore.UpdateStrategy, OverrideFn: statusPrepareForUpdate, } storage[gr.Resource+"/status"] = &statusStore diff --git a/apiserver/rest/interface.go b/apiserver/rest/interface.go index bed0ec2..bd20b06 100644 --- a/apiserver/rest/interface.go +++ b/apiserver/rest/interface.go @@ -93,3 +93,18 @@ type ValidateUpdater interface { // the object. ValidateUpdate(ctx context.Context, obj runtime.Object) field.ErrorList } + +// ShortNamesProvider allows a resource to specify short names for kubectl. +// Short names allow users to use shorter commands like "kubectl get po" instead of +// "kubectl get pods". +type ShortNamesProvider interface { + // ShortNames returns a list of short names for the resource. + ShortNames() []string +} + +// SingularNameProvider returns the singular name of the resource. +// This is used by kubectl for discovery and display (e.g., "pod" instead of "pods"). +type SingularNameProvider interface { + // GetSingularName returns the singular form of the resource name. + GetSingularName() string +} diff --git a/apiserver/rest/rest.go b/apiserver/rest/rest.go index 0bddc95..01023bb 100644 --- a/apiserver/rest/rest.go +++ b/apiserver/rest/rest.go @@ -52,13 +52,13 @@ func SelectableFields(obj *metav1.ObjectMeta) fields.Set { // - optsGetter: RESTOptionsGetter for storage backend configuration // // Returns: -// - *genericregistry.Store: configured store for the resource +// - rest.Storage: configured store for the resource (may be wrapped for ShortNamesProvider) // - error: if store setup fails func NewStore( scheme *runtime.Scheme, single, list func() runtime.Object, gr schema.GroupResource, - strategy Strategy, optsGetter generic.RESTOptionsGetter) (*genericregistry.Store, error) { + strategy Strategy, optsGetter generic.RESTOptionsGetter) (rest.Storage, error) { store := &genericregistry.Store{ NewFunc: single, NewListFunc: list, @@ -71,6 +71,28 @@ func NewStore( DeleteStrategy: strategy, } + // If the strategy implements SingularNameProvider, use the custom singular name. + if sn, ok := strategy.(SingularNameProvider); ok { + singularName := sn.GetSingularName() + if singularName != "" { + store.SingularQualifiedResource = schema.GroupResource{ + Group: gr.Group, + Resource: singularName, + } + } + } + + // If the strategy implements ShortNamesProvider, wrap the store to expose short names. + if sn, ok := strategy.(ShortNamesProvider); ok && len(sn.ShortNames()) > 0 { + wrapped := &storeWithShortNames{Store: store, shortNames: sn.ShortNames()} + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + if err := wrapped.CompleteWithOptions(options); err != nil { + return nil, err + } + + return wrapped, nil + } + // StoreOptions wires up REST options and attribute extraction for filtering. options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} if err := store.CompleteWithOptions(options); err != nil { @@ -79,3 +101,26 @@ func NewStore( return store, nil } + +// storeWithShortNames wraps a genericregistry.Store to provide short names for a resource. +// This implements the ShortNamesProvider interface, allowing kubectl to use short aliases. +type storeWithShortNames struct { + *genericregistry.Store + shortNames []string +} + +// ShortNames returns the list of short names for the resource. +func (s *storeWithShortNames) ShortNames() []string { + return s.shortNames +} + +// Unwrap returns the underlying *genericregistry.Store. +// This is useful when you need to access the store directly, e.g., for setting +// the status subresource update strategy. +func Unwrap(s rest.Storage) *genericregistry.Store { + if wrapped, ok := s.(*storeWithShortNames); ok { + return wrapped.Store + } + + return s.(*genericregistry.Store) +} diff --git a/apiserver/rest/strategy.go b/apiserver/rest/strategy.go index 4af59d2..f733179 100644 --- a/apiserver/rest/strategy.go +++ b/apiserver/rest/strategy.go @@ -225,3 +225,27 @@ func (s *PrepareForUpdaterStrategy) PrepareForUpdate(ctx context.Context, obj, o s.OverrideFn(ctx, obj, old) } } + +// ShortNames returns the short names for the resource if the object implements ShortNamesProvider. +func (d DefaultStrategy) ShortNames() []string { + if d.Object == nil { + return nil + } + if n, ok := d.Object.(ShortNamesProvider); ok { + return n.ShortNames() + } + + return nil +} + +// GetSingularName returns the singular name of the resource if the object implements SingularNameProvider. +func (d DefaultStrategy) GetSingularName() string { + if d.Object == nil { + return "" + } + if n, ok := d.Object.(SingularNameProvider); ok { + return n.GetSingularName() + } + + return "" +} diff --git a/example/cmd/foo-apiserver/main.go b/example/cmd/foo-apiserver/main.go index c2a9736..1eb2976 100644 --- a/example/cmd/foo-apiserver/main.go +++ b/example/cmd/foo-apiserver/main.go @@ -21,9 +21,7 @@ const ( componentName = "foo" ) -var ( - scheme = runtime.NewScheme() -) +var scheme = runtime.NewScheme() func init() { install.Install(scheme) @@ -42,6 +40,7 @@ func init() { &metav1.APIResourceList{}, ) } + func main() { code := apiserver.NewBuilder(scheme). WithComponentName(componentName). diff --git a/go.sum b/go.sum index 054e165..412b04e 100644 --- a/go.sum +++ b/go.sum @@ -332,45 +332,30 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= -k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= -k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= -k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= +k8s.io/code-generator v0.35.1 h1:yLKR2la7Z9cWT5qmk67ayx8xXLM4RRKQMnC8YPvTWRI= k8s.io/code-generator v0.35.1/go.mod h1:F2Fhm7aA69tC/VkMXLDokdovltXEF026Tb9yfQXQWKg= -k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= -k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= -k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kms v0.35.1 h1:kjv2r9g1mY7uL+l1RhyAZvWVZIA/4qIfBHXyjFGLRhU= k8s.io/kms v0.35.1/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kube-aggregator v0.35.0 h1:FBtbuRFA7Ohe2QKirFZcJf8rgimC8oSaNiCi4pdU5xw= k8s.io/kube-aggregator v0.35.0/go.mod h1:vKBRpQUfDryb7udwUwF3eCSvv3AJNgHtL4PGl6PqAg8= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= -k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec=