Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
132 changes: 132 additions & 0 deletions apiserver/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
}
26 changes: 24 additions & 2 deletions apiserver/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions apiserver/rest/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
49 changes: 47 additions & 2 deletions apiserver/rest/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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)
}
24 changes: 24 additions & 0 deletions apiserver/rest/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
5 changes: 2 additions & 3 deletions example/cmd/foo-apiserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ const (
componentName = "foo"
)

var (
scheme = runtime.NewScheme()
)
var scheme = runtime.NewScheme()

func init() {
install.Install(scheme)
Expand All @@ -42,6 +40,7 @@ func init() {
&metav1.APIResourceList{},
)
}

func main() {
code := apiserver.NewBuilder(scheme).
WithComponentName(componentName).
Expand Down
Loading
Loading