Contents

Kubernetes DAO

This post covers a basic API implementation using Kubernetes informers

Limitations

  • etcd is the most vulnerable component of Kubernetes

We need to limit both the number of stored objects and the number of API requests.
That’s one of the reasons we use informers - to reduce load on the API server and, indirectly, on etcd.

  • Kubernetes does not use pessimistic locking

We rely on optimistic concurrency using resourceVersion:

Worker A reads object O with resourceVersion N → 
    applies changes → 
        sends an update request with version N → 
            the object is updated to version M.

Worker B reads object O with resourceVersion N → 
    applies changes → 
        sends an update request with version N → 
            receives a conflict error because the current version is no longer N.

This is similar to Grafana, where a user may receive an error indicating that a dashboard has been updated since it was opened, with an option to force an update.

In Kubernetes, a similar force update behavior can be implemented, but without field ownership, it will not be atomic. It requires re-reading the latest version of the object and re-applying changes on top of it.

Overview

There are multiple levels of abstraction to operate with custom resources in Kubernetes.
Depending on our needs we can use:

API Client

This is the lowest-level building block and all other abstractions rely on it.
It allows us to interact with built-in Kubernetes resources such as Pods, ReplicaSets and Deployments via direct API calls.
For custom resources, we typically need to manually marshal and unmarshal YAML or JSON into Go structs.
The client can also be configured with rate limiting to reduce the number of API requests and prevent overloading the API server.

Watchers

Watchers are a mechanism for receiving real-time updates from the Kubernetes API server about changes to resources.

They stream events:

  • ADD
  • UPDATE
  • DELETE

and can reduce API load by maintaining local state based on these events.

However, watchers alone are low-level and do not provide caching, indexing or type safety by themselves.

Informers

Informers are a higher-level abstraction built on top of watchers.

They provide:

  • Local caching of objects
  • Event handlers
  • Automatic resync mechanisms
  • Indexers for efficient lookups
  • Cache transform interface (minimizing memory consumption)
  • Reduced load on the API server

For custom resources, informers can be generated using code generation tools (client-gen, informers-gen or controller-gen depending on the stack).

Controllers

Controllers are the highest-level abstraction in this stack.

They implement reconciliation loops that continuously ensure the cluster state matches the desired state defined in Kubernetes objects.

Controllers typically:

  • Use informers for efficient state observation
  • Contain reconciliation logic (the control loop)
  • Expose minimal or no external API surface (mostly metrics endpoints)

Model

In our example, to avoid being distracted by the business domain, we’ll implement a simple library service that allows administrators to add books and tenants to borrow them.

Book

Field Type Description Source
ID string Book ID ObjectMeta.Name
Author string Book author CustomResourceDefinition.Spec
Title string Book title CustomResourceDefinition.Spec
Description string Book description CustomResourceDefinition.Spec
NamespaceID string Book namespace ObjectMeta.Namespace
ManagedBy string Book object manager ObjectMeta.Labels
CategoryID string Book category ObjectMeta.Labels
Field Label Type Description
ManagedBy app.kubernetes.io/managed-by string Recommended Labels
CategoryID books.bookshelf.lostinsoba.com/category string Book category

The ManagedBy label serves two purposes:

  • It identifies whether a book was created by the service or provisioned through an IaC workflow.
  • It enables us to prevent users from modifying resources managed by IaC.

The CategoryID label is used solely to demonstrate label-based indexing and resource lookup, since labels are being indexed while annotations are not.

Labels can be efficiently queried both through the API server and informer indexes.

However, labels should not be treated as arbitrary storage:

  • Labels are intended for filtering and grouping
  • Values should remain relatively small
  • High-cardinality labels may negatively affect performance

BookLoan

Field Type Description Source
ID string Book loan ID ObjectMeta.Name
BorrowedBy string Tenant ID CustomResourceDefinition.Spec

We could use a status subresource for this, but that approach may introduce additional complexity in access control and resource update flows.

Instead, we’ll use an event-driven approach with a small adjustment: a BookLoan object will use the same name as the corresponding Book object, simplifying state checks and lookups.

This approach can be changed later to preserve the full borrowing history and provide visibility into all events related to a particular book.

Development enviroment

It’s possible to test in any available Kubernetes cluster, but not obligatory.
Our example only requires having minikube installed on your laptop.

minikube start --driver=docker

CustomResourceDefinition

Book

Let’s create and apply custom resource definition for Book model:

kubectl apply -f - <<EOF
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: books.bookshelf.lostinsoba.com
spec:
  group: bookshelf.lostinsoba.com
  scope: Namespaced
  names:
    plural: books
    singular: book
    kind: Book
  preserveUnknownFields: false
  versions:
    - name: v1alpha1
      served: true
      storage: true
      additionalPrinterColumns:
        - name: Author
          type: string
          jsonPath: .spec.author
        - name: Title
          type: string
          jsonPath: .spec.title
        - name: Category
          type: string
          jsonPath: .metadata.labels['books\.bookshelf\.lostinsoba\.com/category']
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: |
            Book            
          required:
            - spec
          properties:
            spec:
              type: object
              required:
                - author
                - title
              properties:
                author:
                  type: string
                  description: Book author
                title:
                  type: string
                  description: Book title
                description:
                  type: string
                  description: Book description
EOF

Create namespace:

kubectl create namespace science-library

Create example books:

kubectl apply -f - <<EOF
apiVersion: bookshelf.lostinsoba.com/v1alpha1
kind: Book
metadata:
  name: modenov-analytic-geometry
  namespace: science-library
  labels:
    books.bookshelf.lostinsoba.com/category: math
spec:
  author: "Modenov P.S."
  title: "Analytic Geometry"
EOF
kubectl apply -f - <<EOF
apiVersion: bookshelf.lostinsoba.com/v1alpha1
kind: Book
metadata:
  name: taylor-biological-science
  namespace: science-library
  labels:
    books.bookshelf.lostinsoba.com/category: biology
spec:
  author: "Taylor D.J., Green N.P.O., Stout G.W."
  title: "Biological Science"
EOF

List created books:

> kubectl get books -n science-library

NAME                        AUTHOR                        TITLE                CATEGORY   AGE
modenov-analytic-geometry   Modenov P.S.                  Analytic Geometry    math       25s
taylor-biological-science   Taylor D.J., Green N.P.O...   Biological Science   biology    14s

Labels are indexed, so we can query books by label:

> kubectl get books -l books.bookshelf.lostinsoba.com/category=biology -n science-library

NAME                        AUTHOR                        TITLE                CATEGORY   AGE
taylor-biological-science   Taylor D.J., Green N.P.O...   Biological Science   biology    38s

BookLoan

And the model for book loans:

kubectl apply -f - <<EOF
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: bookloans.bookshelf.lostinsoba.com
spec:
  group: bookshelf.lostinsoba.com
  scope: Namespaced
  names:
    plural: bookloans
    singular: bookloan
    kind: BookLoan
  preserveUnknownFields: false
  versions:
    - name: v1alpha1
      served: true
      storage: true
      additionalPrinterColumns:
        - name: Borrowed By
          type: string
          jsonPath: .spec.borrowedBy
        - name: Borrowed At
          type: string
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          description: |
            Book Loan            
          required:
            - spec
          properties:
            spec:
              type: object
              required:
                - borrowedBy
              properties:
                borrowedBy:
                  type: string
                  description: Borrowed by
EOF

Codebase structure

We’ll need to clone kubernetes sample controller
We are only interested in the hack directory, we’ll copy this directory to our project directory
And in update-codegen.sh we need to change THIS_PKG variable to store our application name:

THIS_PKG="bookshelf"

kube::codegen::gen_client \
    --with-watch \
    --output-dir "${SCRIPT_ROOT}/pkg/generated" \
    --output-pkg "${THIS_PKG}/pkg/generated" \
    --boilerplate "${SCRIPT_ROOT}/hack/boilerplate.go.txt" \
    "${SCRIPT_ROOT}/pkg/apis"

So now we can create a Makefile with this directive:

generate:
    ./hack/update-codegen.sh

Then we need to create directory pkg and containing apis so our project now has the following directory structure:


  ├── Makefile
  ├── hack
  ├── pkg
  │    ├── apis.go
  │    └── apis/bookshelf/v1alpha1
  │         ├── doc.go
  │         ├── register.go
  │         └── types.go
  └── rest of the project code

We can also use the existing go.mod file to understand what modules are required.
We just need to ensure all k8s.io packages use the same version.

apis.go:

package apis

import (
	"k8s.io/apimachinery/pkg/runtime"

	bookshelfv1alpha1 "bookshelf/pkg/apis/bookshelf/v1alpha1"
)

func RegisterScheme() (*runtime.Scheme, error) {
	sch := runtime.NewScheme()
	err := bookshelfv1alpha1.AddToScheme(sch)
	if err != nil {
		return nil, err
	}
	return sch, nil
}

doc.go:

// +k8s:deepcopy-gen=package,register
// +groupName=bookshelf.lostinsoba.com

package v1alpha1

register.go:

package v1alpha1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

const (
	resourceGroup   = "bookshelf.lostinsoba.com"
	resourceVersion = "v1alpha1"
)

var SchemeGroupVersion = schema.GroupVersion{
	Group:   resourceGroup,
	Version: resourceVersion,
}

func Resource(resource string) schema.GroupResource {
	return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (
	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
	AddToScheme   = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
	scheme.AddKnownTypes(SchemeGroupVersion,
		&Book{},
		&BookList{},
		&BookLoan{},
		&BookLoanList{},
	)
	metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
	return nil
}

types.go:

Book

package v1alpha1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
)

const (
	bookResourceKind       = "Book"
	bookResourceKindPlural = "books"
)

var BookGroupResourceVersion = SchemeGroupVersion.WithResource(bookResourceKindPlural)

var BookGroupVersionKind = SchemeGroupVersion.WithKind(bookResourceKind)

var BookTypeMeta = metav1.TypeMeta{
	APIVersion: BookGroupVersionKind.GroupVersion().String(),
	Kind:       BookGroupVersionKind.Kind,
}

const (
	labelCategory = "books.bookshelf.lostinsoba.com/category"
)

func (b *Book) GetCategory() string {
	return b.Labels[labelCategory]
}

func BuildCategoryLabelSet(categoryID string) labels.Set {
	return labels.Set{
		labelCategory: categoryID,
	}
}

const (
	labelManagedBy      = "app.kubernetes.io/managed-by"
	labelManagedByValue = "bookshelf"
)

func (b *Book) GetManagedBy() string {
	return b.Labels[labelManagedBy]
}

func (b *Book) IsManageable() bool {
  managedBy := b.Labels[labelManagedBy]
  return managedBy == "" || managedBy == labelManagedByValue
}

func BuildManagedByLabelSet() labels.Set {
	return labels.Set{
		labelManagedBy: labelManagedByValue,
	}
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type BookList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata"`
	Items           []*Book `json:"items"`
}

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Book struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec              BookSpec `json:"spec"`
}

type BookSpec struct {
	Author      string `json:"author"`
	Title       string `json:"title"`
	Description string `json:"description"`
}

BookLoan

const (
	bookLoanResourceKind       = "BookLoan"
	bookLoanResourceKindPlural = "bookloans"
)

var BookLoanGroupResourceVersion = SchemeGroupVersion.WithResource(bookLoanResourceKindPlural)

var BookLoanGroupVersionKind = SchemeGroupVersion.WithKind(bookLoanResourceKind)

var BookLoanTypeMeta = metav1.TypeMeta{
	APIVersion: BookLoanGroupVersionKind.GroupVersion().String(),
	Kind:       BookLoanGroupVersionKind.Kind,
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type BookLoanList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata"`
	Items           []*BookLoan `json:"items"`
}

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type BookLoan struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec              BookLoanSpec `json:"spec"`
}

type BookLoanSpec struct {
	BorrowedBy string `json:"borrowedBy"`
}

Generate code for informers:

> make generate

./hack/update-codegen.sh
Generating deepcopy code for 1 targets
Generating client code for 1 targets
Generating lister code for 1 targets
Generating informer code for 1 targets

Docker image

Instead of using docker build, we’ll use minikube image build to build and deliver our image to minikube:

minikube image build -t bookshelf:develop .

Helm chart

Next, we need to write our helm chart.
We’ll use it in the following way:

helm upgrade --install bookshelf . \
  -n bookshelf \
  --create-namespace

Our helm chart must contain at least:

  • ServiceAccount
  • ClusterRole
  • ClusterRoleBinding
  • Deployment
  • Service

Our service will have to run under a ServiceAccount to be able to operate with our CRDs:

ServiceAccount

apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookshelf

ClusterRole

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: bookshelf
rules:
- apiGroups: ["bookshelf.lostinsoba.com"]
  resources: ["books", "bookloans"]
  verbs: ["*"]

ClusterRoleBinding

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: bookshelf
subjects:
- kind: ServiceAccount
  name: bookshelf
  namespace: {{ .Release.Namespace }}
roleRef:
  kind: ClusterRole
  name: bookshelf
  apiGroup: rbac.authorization.k8s.io

Deployment

Deployment must contain field spec.template.spec.serviceAccountName equal to ServiceAccount Name.
After the image build, we’ll be restarting our deployment the following way:

kubectl rollout restart deployment/bookshelf -n bookshelf

Service

Service must expose our API port.
We’ll connect to our service using port-forwarding:

kubectl port-forward -n bookshelf svc/bookshelf 8080:8080

Service development

Model

package model

type Book struct {
	ID          string
	Author      string
	Title       string
	Description string
	CategoryID  string
	NamespaceID string
	ManagedBy   string
}

Storage

package storage

import (
	"log/slog"

	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/rest"

	"bookshelf/internal/storage/kubernetes"
)

type Storage struct {
	store  *kubernetes.Store
	logger *slog.Logger
}

func New(cfg *rest.Config, scheme *runtime.Scheme, logger *slog.Logger) (*Storage, error) {
	store, err := kubernetes.New(cfg, scheme)
	if err != nil {
		return nil, err
	}
	return &Storage{
		store:  store,
		logger: logger,
	}, nil
}

Connector

In our example, we use namespaced objects. However, this connector (as well as the informer) can be used for cluster scoped objects too. The namespace field will just contain an empty string.

package kubernetes

import (
  "context"

  "k8s.io/apimachinery/pkg/labels"
  "k8s.io/apimachinery/pkg/runtime"
  "k8s.io/client-go/rest"

  "sigs.k8s.io/controller-runtime/pkg/client"
)

type Store struct {
  typedClient client.Client
}

func New(cfg *rest.Config, scheme *runtime.Scheme) (*Store, error) {
  typedClient, err := client.New(cfg, client.Options{Scheme: scheme})
  if err != nil {
    return nil, err
  }
  return &Store{
    typedClient: typedClient,
  }, nil
}

func (s *Store) Get(ctx context.Context, namespace, name string, obj client.Object) error {
  key := client.ObjectKey{
    Namespace: namespace,
    Name:      name,
  }
  return s.typedClient.Get(ctx, key, obj)
}

func (s *Store) List(
  ctx context.Context,
  namespace string,
  labelSelector labels.Selector,
  list client.ObjectList,
) error {
  return s.typedClient.List(
    ctx,
    list,
    &client.ListOptions{
      Namespace:     namespace,
      LabelSelector: labelSelector,
    },
  )
}

func (s *Store) Create(ctx context.Context, obj client.Object) error {
  return s.typedClient.Create(ctx, obj)
}

func (s *Store) Update(ctx context.Context, obj client.Object) error {
  return s.typedClient.Update(ctx, obj)
}

func (s *Store) Patch(ctx context.Context, oldObj, newObj client.Object) error {
  return s.typedClient.Patch(ctx, newObj, client.MergeFrom(oldObj))
}

func (s *Store) Delete(ctx context.Context, obj client.Object) error {
  uid := obj.GetUID()
  return s.typedClient.Delete(ctx, obj, client.Preconditions{
    UID: &uid,
  })
}

Repository

Book
package storage

import (
  "context"
  "fmt"
  "log/slog"
  "strings"

  apierrors "k8s.io/apimachinery/pkg/api/errors"
  metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  "k8s.io/apimachinery/pkg/labels"
  "k8s.io/apimachinery/pkg/runtime"
  "k8s.io/apimachinery/pkg/util/validation"
  "sigs.k8s.io/controller-runtime/pkg/client"

  "bookshelf/internal/model"

  bookshelfv1alpha1 "bookshelf/pkg/apis/bookshelf/v1alpha1"
)

func (s *Storage) ListBooks(
  ctx context.Context,
  query model.ListBooksQuery,
) ([]*model.Book, error) {
  var books bookshelfv1alpha1.BookList
  err := s.store.List(ctx, query.NamespaceID, buildLabelSelector(query), &books)
  if err != nil {
    return nil, err
  }
  list := make([]*model.Book, 0, len(books.Items))
  for _, item := range books.Items {
    listItem, convertErr := convertRuntimeObjectToBookModel(item)
    if convertErr != nil {
      s.logger.Error("failed to convert object to book, skipping...",
        slog.String("name", item.Name),
        slog.String("namespace id", item.Namespace),
        slog.Any("error", convertErr),
      )
    }
    list = append(list, listItem)
  }
  return list, nil
}

func (s *Storage) CreateBook(ctx context.Context, query model.CreateBookQuery) error {
  book, err := convertBookModelToClientObject(query.Book)
  if err != nil {
    return err
  }
  err = s.store.Create(ctx, book)
  if apierrors.IsAlreadyExists(err) {
    return model.ErrAlreadyExist
  }
  return err
}

func (s *Storage) UpdateBook(ctx context.Context, query model.UpdateBookQuery) error {
  var targetBook bookshelfv1alpha1.Book
  err := s.store.Get(ctx, query.Book.NamespaceID, query.Book.ID, &targetBook)
  if apierrors.IsNotFound(err) {
    return model.ErrNotFound
  }
  if err != nil {
    return err
  }
  if !targetBook.IsManageable() {
    return model.ErrNotManageable
  }
  applyBookUpdate(&targetBook, query.Book)
  err = s.store.Update(ctx, &targetBook)
  if apierrors.IsConflict(err) {
    return model.ErrUpdateConflict
  }
  return err
}

func (s *Storage) GetBookByID(
  ctx context.Context,
  query model.GetBookByIDQuery,
) (*model.Book, error) {
  book, err := s.getBookByID(ctx, query.NamespaceID, query.BookID)
  if err != nil {
    return nil, err
  }
  return convertRuntimeObjectToBookModel(&book)
}

func (s *Storage) SetCategoryID(ctx context.Context, query model.SetCategoryIDQuery) error {
  book, err := s.getBookByID(ctx, query.NamespaceID, query.BookID)
  if err != nil {
    return err
  }
  original := book.DeepCopy()
  categoryLabelSet := bookshelfv1alpha1.BuildCategoryLabelSet(query.CategoryID)
  book.Labels = labels.Merge(book.Labels, categoryLabelSet)
  err = s.store.Patch(ctx, original, &book)
  if apierrors.IsConflict(err) {
    return model.ErrUpdateConflict
  }
  return err
}

func (s *Storage) getBookByID(
  ctx context.Context,
  namespace string,
  id string,
) (bookshelfv1alpha1.Book, error) {
  var book bookshelfv1alpha1.Book
  err := s.store.Get(ctx, namespace, id, &book)
  switch {
  case apierrors.IsNotFound(err):
    return bookshelfv1alpha1.Book{}, model.ErrNotFound
  case err != nil:
    return bookshelfv1alpha1.Book{}, err
  default:
    return book, nil
  }
}

func (s *Storage) DeleteBookByID(ctx context.Context, query model.DeleteBookByIDQuery) error {
  book, err := s.getBookByID(ctx, query.NamespaceID, query.BookID)
  if err != nil {
    return err
  }
  if !book.IsManageable() {
    return model.ErrNotManageable
  }
  err = s.store.Delete(ctx, &book)
  return client.IgnoreNotFound(err)
}

func buildLabelSelector(query model.ListBooksQuery) labels.Selector {
  labelSet := make(labels.Set)
  if query.CategoryID != "" {
    bookCategoryLabelSet := bookshelfv1alpha1.BuildCategoryLabelSet(query.CategoryID)
    labelSet = labels.Merge(labelSet, bookCategoryLabelSet)
  }
  if len(labelSet) == 0 {
    return labels.Everything()
  }
  return labels.SelectorFromSet(labelSet)
}

func convertRuntimeObjectToBookModel(object runtime.Object) (*model.Book, error) {
  switch crd := object.(type) {
  case *bookshelfv1alpha1.Book:
    return &model.Book{
      ID:          crd.Name,
      Author:      crd.Spec.Author,
      Title:       crd.Spec.Title,
      Description: crd.Spec.Description,
      CategoryID:  crd.GetCategory(),
      NamespaceID: crd.GetNamespace(),
      ManagedBy:   crd.GetManagedBy(),
    }, nil
  default:
    return nil, fmt.Errorf("unsupported object type: %T", crd)
  }
}

func applyBookUpdate(book *bookshelfv1alpha1.Book, bookModel model.Book) {
  categoryLabelSet := bookshelfv1alpha1.BuildCategoryLabelSet(bookModel.CategoryID)
  manageableLabelSet := bookshelfv1alpha1.BuildManagedByLabelSet()
  labelSet := labels.Merge(categoryLabelSet, manageableLabelSet)

  book.Spec.Author = bookModel.Author
  book.Spec.Title = bookModel.Title
  book.Spec.Description = bookModel.Description

  book.Labels = labels.Merge(
    book.Labels,
    labelSet,
  )
}

func convertBookModelToClientObject(book model.Book) (client.Object, error) {
  if book.ID == "" {
    return nil, fmt.Errorf("missing book id")
  }
  if book.NamespaceID == "" {
    return nil, fmt.Errorf("missing namespace id")
  }
  if book.Author == "" {
    return nil, fmt.Errorf("missing book author")
  }
  if book.Title == "" {
    return nil, fmt.Errorf("missing book title")
  }
  if errs := validation.IsDNS1123Subdomain(book.ID); len(errs) > 0 {
    return nil, fmt.Errorf("invalid book id: %s", strings.Join(errs, ", "))
  }
  categoryLabelSet := bookshelfv1alpha1.BuildCategoryLabelSet(book.CategoryID)
  manageableLabelSet := bookshelfv1alpha1.BuildManagedByLabelSet()
  return &bookshelfv1alpha1.Book{
    TypeMeta: bookshelfv1alpha1.BookTypeMeta,
    ObjectMeta: metav1.ObjectMeta{
      Name:      book.ID,
      Namespace: book.NamespaceID,
      Labels:    labels.Merge(categoryLabelSet, manageableLabelSet),
    },
    Spec: bookshelfv1alpha1.BookSpec{
      Author:      book.Author,
      Title:       book.Title,
      Description: book.Description,
    },
  }, nil
}
BookLoan
package storage

import (
	"context"
	"fmt"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"

	"bookshelf/internal/model"

	bookshelfv1alpha1 "bookshelf/pkg/apis/bookshelf/v1alpha1"
)

func (s *Storage) BorrowBookByID(ctx context.Context, query model.BorrowBookByIDQuery) error {
	bookLoan, err := convertBorrowBookByIDQueryToBookLoanClientObject(query)
	if err != nil {
		return err
	}
	err = s.store.Create(ctx, bookLoan)
	if apierrors.IsAlreadyExists(err) {
		return model.ErrAlreadyBorrowed
	}
	return err
}

func (s *Storage) ReturnBookByID(ctx context.Context, query model.ReturnBookByIDQuery) error {
	var bookLoan bookshelfv1alpha1.BookLoan
	err := s.store.Get(ctx, query.NamespaceID, query.BookID, &bookLoan)
	switch {
	case apierrors.IsNotFound(err):
		return nil
	case err != nil:
		return err
	default:
		if bookLoan.Spec.BorrowedBy != query.ReturnedBy {
			return model.ErrForbidden
		}
		return s.store.Delete(ctx, &bookLoan)
	}
}

func convertBorrowBookByIDQueryToBookLoanClientObject(
  query model.BorrowBookByIDQuery,
) (client.Object, error) {
  if query.BookID == "" {
    return nil, fmt.Errorf("missing book id")
  }
  if query.NamespaceID == "" {
    return nil, fmt.Errorf("missing namespace id")
  }
  if query.BorrowedBy == "" {
    return nil, fmt.Errorf("missing borrowed by")
  }
  return &bookshelfv1alpha1.BookLoan{
    TypeMeta: bookshelfv1alpha1.BookLoanTypeMeta,
    ObjectMeta: metav1.ObjectMeta{
      Name:      query.BookID,
      Namespace: query.NamespaceID,
    },
    Spec: bookshelfv1alpha1.BookLoanSpec{
      BorrowedBy: query.BorrowedBy,
    },
  }, nil
}

Cache

package cache

import (
  "context"
  "log/slog"
  "time"

  "github.com/go-logr/logr"

  "k8s.io/client-go/rest"
  "k8s.io/klog/v2"

  "sigs.k8s.io/controller-runtime/pkg/client"

  "bookshelf/internal/cache/kubernetes"
  "bookshelf/pkg/generated/clientset/versioned"
  "bookshelf/pkg/generated/informers/externalversions"

  bookshelfv1alpha1 "bookshelf/pkg/apis/bookshelf/v1alpha1"
)

type Cache struct {
  namespacedBooks     *kubernetes.Informer
  namespacedBookLoans *kubernetes.Informer
  logger              *slog.Logger
}

func New(cfg *rest.Config, resyncInterval time.Duration, logger *slog.Logger) (*Cache, error) {
  {
    handler := logger.Handler()
    kLogger := logr.FromSlogHandler(handler)
    klog.SetLogger(kLogger)
  }
  clientSet, err := versioned.NewForConfig(cfg)
  if err != nil {
    return nil, err
  }
  // books
  namespacedBooksCacheItemTransformFunc := func(obj interface{}) (interface{}, error) {
    // Informer caches keep full copies of all observed objects in memory.
    // Large text fields such as descriptions may significantly increase memory
    // consumption when thousands of resources are stored.
    // The transform function allows us to remove fields that are not required for
    // read operations served from cache.
    switch received := obj.(type) {
    case *bookshelfv1alpha1.Book:
      cached := received.DeepCopy()
      // cached.Spec.Description = ""
      return cached, nil
    }
    return obj, nil
  }
  namespacedBooksInformerBuilder := func() (kubernetes.GenericInformer, error) {
    factory := externalversions.NewSharedInformerFactoryWithOptions(
      clientSet,
      resyncInterval,
      externalversions.WithTransform(namespacedBooksCacheItemTransformFunc),
    )
    return factory.ForResource(bookshelfv1alpha1.BookGroupResourceVersion)
  }
  eventCallback := func(eventKind kubernetes.InformerEventKind, obj client.Object) {
    logger.Info("received informer event",
      slog.String("object", obj.GetName()),
      slog.String("object namespace", obj.GetNamespace()),
      slog.String("event kind", eventKind.String()),
    )
  }
  namespacedBooksInformer, err := kubernetes.New(
    namespacedBooksInformerBuilder,
    kubernetes.OptionWithEventCallback(eventCallback),
  )
  if err != nil {
    return nil, err
  }
  // book loans
  namespacedBookLoansInformerBuilder := func() (kubernetes.GenericInformer, error) {
    factory := externalversions.NewSharedInformerFactoryWithOptions(
      clientSet,
      resyncInterval,
    )
    return factory.ForResource(bookshelfv1alpha1.BookLoanGroupResourceVersion)
  }
  namespacedBookLoansInformer, err := kubernetes.New(namespacedBookLoansInformerBuilder)
  if err != nil {
    return nil, err
  }
  return &Cache{
    namespacedBooks:     namespacedBooksInformer,
    namespacedBookLoans: namespacedBookLoansInformer,
    logger:              logger,
  }, nil
}

func (c *Cache) SyncInBackground(ctx context.Context) error {
  err := c.namespacedBooks.Run(ctx)
  if err != nil {
    return err
  }
  err = c.namespacedBookLoans.Run(ctx)
  if err != nil {
    return err
  }
  return nil
}

Informer

package kubernetes

import (
  "context"
  "fmt"

  "k8s.io/apimachinery/pkg/api/meta"
  "k8s.io/apimachinery/pkg/labels"
  "k8s.io/apimachinery/pkg/runtime"
  "k8s.io/client-go/tools/cache"

  "sigs.k8s.io/controller-runtime/pkg/client"
)

type Informer struct {
  internalInformer GenericInformer
}

type InformerBuilder func() (GenericInformer, error)

type InformerEventKind int

const (
  InformerEventKindCreate InformerEventKind = iota
  InformerEventKindUpdate
  InformerEventKindDelete
)

func (ek InformerEventKind) String() string {
  switch ek {
  case InformerEventKindCreate:
    return "create"
  case InformerEventKindUpdate:
    return "update"
  case InformerEventKindDelete:
    return "delete"
  default:
    return "unknown"
  }
}

// This function signature is only for demonstration purposes.
// Later, it can be extended with oldObj to cover more cases related with update events.
// And there's one notable thing about delete events: 
// We cannot rely on DeletionTimestamp field of object, 
// because the time we receive the event, it will be empty
type InformerEventCallback func(eventKind InformerEventKind, obj client.Object)

type GenericInformer interface {
  Informer() cache.SharedIndexInformer
  Lister() cache.GenericLister
}

type Option func(GenericInformer)

func OptionWithEventCallback(eventCallback InformerEventCallback) Option {
  return func(informer GenericInformer) {
    eventHandler := cache.ResourceEventHandlerFuncs{
      AddFunc: func(obj interface{}) {
        clientObj, isClientObj := obj.(client.Object)
        if isClientObj {
          eventCallback(InformerEventKindCreate, clientObj)
        }
      },
      UpdateFunc: func(_, newObj interface{}) {
        clientObj, isClientObj := newObj.(client.Object)
        if isClientObj {
          eventCallback(InformerEventKindUpdate, clientObj)
        }
      },
      DeleteFunc: func(obj interface{}) {
        switch o := obj.(type) {
        case client.Object:
          eventCallback(InformerEventKindDelete, o)
        case cache.DeletedFinalStateUnknown:
          if clientObj, ok := o.Obj.(client.Object); ok {
            eventCallback(InformerEventKindDelete, clientObj)
          }
        }
      },
    }
    _, _ = informer.Informer().AddEventHandler(eventHandler)
  }
}

func New(informerBuilder InformerBuilder, opts ...Option) (*Informer, error) {
  internalInformer, err := informerBuilder()
  if err != nil {
    return nil, err
  }
  for _, opt := range opts {
    opt(internalInformer)
  }
  return &Informer{
    internalInformer: internalInformer,
  }, nil
}

func (i *Informer) Run(ctx context.Context) error {
  informer := i.internalInformer.Informer()
  go informer.Run(ctx.Done())
  if !cache.WaitForCacheSync(
    ctx.Done(),
    informer.HasSynced,
  ) {
    if err := context.Cause(ctx); err != nil {
      return err
    }
    return fmt.Errorf("failed to sync informer cache")
  }
  return nil
}

func (i *Informer) Get(ctx context.Context, namespace, name string) (runtime.Object, error) {
  return i.internalInformer.Lister().ByNamespace(namespace).Get(name)
}

func (i *Informer) List(
  ctx context.Context,
  namespace string,
  labelSelector labels.Selector,
  list client.ObjectList,
) error {
  runtimeObjects, err := i.internalInformer.Lister().
    ByNamespace(namespace).List(labelSelector)
  if err != nil {
    return err
  }
  return meta.SetList(list, runtimeObjects)
}

Repository

package cache

import (
	"context"
	"fmt"
	"log/slog"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/runtime"

	"bookshelf/internal/model"
	bookshelfv1alpha1 "bookshelf/pkg/apis/bookshelf/v1alpha1"
)

func (c *Cache) ListBooks(
  ctx context.Context,
  query model.ListBooksQuery,
) ([]*model.Book, error) {
  var books bookshelfv1alpha1.BookList
  err := c.namespacedBooks.List(ctx, query.NamespaceID, buildLabelSelector(query), &books)
  if err != nil {
    return nil, err
  }
  list := make([]*model.Book, 0, len(books.Items))
  for _, item := range books.Items {
    listItem, convertErr := convertRuntimeObjectToBookModel(item)
    if convertErr != nil {
      c.logger.Error("failed to convert object to book, skipping...",
        slog.String("name", item.Name),
        slog.String("namespace id", item.Namespace),
        slog.Any("error", convertErr),
      )
    }
    list = append(list, listItem)
  }
  return list, nil
}

func (c *Cache) GetBookByID(
  ctx context.Context,
  query model.GetBookByIDQuery,
) (*model.Book, error) {
  obj, err := c.namespacedBooks.Get(ctx, query.NamespaceID, query.BookID)
  switch {
  case apierrors.IsNotFound(err):
    return nil, model.ErrNotFound
  case err != nil:
    return nil, err
  default:
    return convertRuntimeObjectToBookModel(obj)
  }
}

func buildLabelSelector(query model.ListBooksQuery) labels.Selector {
	labelSet := make(labels.Set)
	if query.CategoryID != "" {
		bookCategoryLabelSet := bookshelfv1alpha1.BuildCategoryLabelSet(query.CategoryID)
		labelSet = labels.Merge(labelSet, bookCategoryLabelSet)
	}
	if len(labelSet) == 0 {
		return labels.Everything()
	}
	return labels.SelectorFromSet(labelSet)
}

func convertRuntimeObjectToBookModel(object runtime.Object) (*model.Book, error) {
  switch crd := object.(type) {
  case *bookshelfv1alpha1.Book:
    return &model.Book{
      ID:          crd.Name,
      Author:      crd.Spec.Author,
      Title:       crd.Spec.Title,
      Description: crd.Spec.Description,
      CategoryID:  crd.GetCategory(),
      NamespaceID: crd.GetNamespace(),
      ManagedBy:   crd.GetManagedBy(),
    }, nil
  default:
    return nil, fmt.Errorf("unsupportyed object type: %T", crd)
  }
}

API

Admin API

package router

func (r *Router) Route() http.Handler {
  mux := http.NewServeMux()
  mux.Handle("GET /book/{namespaceID}", r.listBooks)
  mux.Handle("POST /book/{namespaceID}", r.createBook)
  mux.Handle("GET /book/{namespaceID}/{id}", r.getBookByID)
  mux.Handle("PUT /book/{namespaceID}/{id}", r.updateBook)
  mux.Handle("DELETE /book/{namespaceID}/{id}", r.deleteBookByID)
  mux.Handle("PATCH /book/{namespaceID}/{id}/category", r.setCategoryID)
  mux.Handle("GET /health", r.health())
  route := middleware.ExtractTenantID(mux)
  route = middleware.ExtractRequestID(route)
  return route
}
List books
curl http://localhost:8080/book/science-library | python3 -m json.tool

{
    "list": [
        {
            "id": "modenov-analytic-geometry",
            "author": "Modenov P.S.",
            "title": "Analytic Geometry",
            "category_id": "math",
            "namespace_id": "science-library"
        },
        {
            "id": "taylor-biological-science",
            "author": "Taylor D.J., Green N.P.O., Stout G.W.",
            "title": "Biological Science",
            "category_id": "biology",
            "namespace_id": "science-library"
        }
    ]
}
curl http://localhost:8080/book/science-library?category_id=math | python3 -m json.tool

{
    "list": [
        {
            "id": "modenov-analytic-geometry",
            "author": "Modenov P.S.",
            "title": "Analytic Geometry",
            "category_id": "math",
            "namespace_id": "science-library"
        }
    ]
}
Update book
curl -X PUT http://localhost:8080/book/science-library/modenov-analytic-geometry \
  -H "Content-Type: application/json" \
  -d '{
        "id": "modenov-analytic-geometry",
        "author": "Modenov P.S.",
        "title": "Analytic Geometry",
        "category_id": "math",
        "description": "The green book for 1st semester",
        "namespace_id": "science-library"
  }'
Set category ID
curl -X POST http://localhost:8080/book/science-library/modenov-analytic-geometry/category \
  -H "Content-Type: application/json" \
  -d '{
        "category_id": "geometry"
  }'
Get book by ID
curl "http://localhost:8080/book/science-library/modenov-analytic-geometry" |
  python3 -m json.tool

{
  "id": "modenov-analytic-geometry",
  "author": "Modenov P.S.",
  "title": "Analytic Geometry",
  "category_id": "geometry",
  "namespace_id": "science-library",
  "description": "The green book for 1st semester",
  "managed_by": "bookshelf"
}

User API

package router

func (r *Router) Route() http.Handler {
  mux := http.NewServeMux()
  mux.Handle("GET /book/{namespaceID}", r.listBooks)
  mux.Handle("GET /book/{namespaceID}/{id}", r.getBookByID)
  mux.Handle("POST /book/{namespaceID}/{id}/borrow", r.borrowBookByID)
  mux.Handle("POST /book/{namespaceID}/{id}/return", r.returnBookByID)
  mux.Handle("GET /health", r.health())
  route := middleware.ExtractTenantID(mux)
  route = middleware.ExtractRequestID(route)
  return route
}
BorrowBookByID
curl -X POST http://localhost:8080/book/science-library/modenov-analytic-geometry/borrow \
  -H "X-TENANT-ID: bob"
curl -X POST http://localhost:8080/book/science-library/modenov-analytic-geometry/borrow \
  -H "X-TENANT-ID: alice" | python3 -m json.tool

{
  "message":"already borrowed"
}
> kubectl get bookloans -n science-library

NAME                        BORROWED BY   BORROWED AT
modenov-analytic-geometry   bob           2026-06-18T15:38:51Z