From grafana-app-sdk
Guides implementing validation and mutation admission handlers for grafana-app-sdk Go apps using Kubernetes webhooks.
npx claudepluginhub grafana/skills --plugin grafana-app-sdkThis skill uses the workspace's default tool permissions.
Admission control intercepts resource create/update requests before they are persisted. In grafana-app-sdk there are two types:
Guides grafana-app-sdk project initialization, CLI usage, deployment modes (standalone operator, grafana/apps, frontend-only), and Grafana App Platform workflows.
Provides Kubernetes CRD design patterns: schema with kubebuilder markers, validating/mutating webhooks, idempotent reconcile loops, and minimal RBAC for operators.
Generates mutations in Kubernetes operator controllers using controller-runtime for mutation testing, targeting reconciliation logic, error handling, requeue behavior, status updates, and API interactions.
Share bugs, ideas, or general feedback.
Admission control intercepts resource create/update requests before they are persisted. In grafana-app-sdk there are two types:
The app business logic for admission is identical whether the app runs as a standalone operator or inside grafana/apps. The only difference is the runtime: standalone apps stand up their own webhook server; grafana/apps apps have admission auto-registered as a Kubernetes plugin.
For standalone apps, if pkg/app/app.go does not yet exist, a stub App can be generated with:
grafana-app-sdk project component add operator
This creates scaffolded simple.App which admission handlers can be added to for each kind in ManagedKinds.
// Implement this interface for each kind you want to validate
type Validator interface {
Validate(ctx context.Context, request *app.AdmissionRequest) error
}
nil to admit the requestapp.AdmissionRequest provides access to the incoming object and operation typek8s.NewAdmissionError(err error, statusCode int, reason string) (from "github.com/grafana/grafana-app-sdk/k8s") to better control the returned error informationtype MyKindValidator struct{}
func (v *MyKindValidator) Validate(ctx context.Context, req *app.AdmissionRequest) error {
obj, ok := req.Object.(*v1.MyKind)
if !ok {
return fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
}
// Validate spec fields
if obj.Spec.Title == "" {
return fmt.Errorf("spec.title is required")
}
if obj.Spec.Count < 0 {
return fmt.Errorf("spec.count must be non-negative, got %d", obj.Spec.Count)
}
// Distinguish create vs update
if req.Action == resource.AdmissionActionUpdate && req.OldObject != nil {
old, ok := req.OldObject.(*v1.MyKind)
if !ok {
return fmt.Errorf("admission request old object was of invalid type %T (expected *v1.MyKind)", req.OldObject)
}
if old.Spec.Title != obj.Spec.Title {
return fmt.Errorf("spec.title is immutable after creation")
}
}
return nil
}
// Implement this interface to mutate resources before persistence
type Mutator interface {
Mutate(ctx context.Context, request *app.AdmissionRequest) (*app.MutatingResponse, error)
}
MutatingResponse containing the (optionally modified) objecttype MyKindMutator struct{}
func (m *MyKindMutator) Mutate(
ctx context.Context,
req *app.AdmissionRequest,
) (*app.MutatingResponse, error) {
obj, ok := req.Object.(*v1.MyKind)
if !ok {
return nil, fmt.Errorf("admission request object was of invalid type %T (expected *v1.MyKind)", req.Object)
}
// Set defaults on create
if req.Action == resource.AdmissionActionCreate {
if obj.Spec.Description == "" {
obj.Spec.Description = "No description provided"
}
}
return &app.MutatingResponse{UpdatedObject: obj}, nil
}
Register validators and mutators when building the app in pkg/app/app.go:
func New(cfg app.Config) (app.App, error) {
cfg.KubeConfig.APIPath = "/apis"
a, err := simple.NewApp(simple.AppConfig{
ManagedKinds: []simple.AppManagedKind{
{
Kind: v1.MyKindKind(),
Validator: &MyKindValidator{},
Mutator: &MyKindMutator{},
},
},
})
if err != nil {
return nil, fmt.Errorf("error creating app: %w", err)
}
if err = a.ValidateManifest(cfg.ManifestData); err != nil {
return nil, fmt.Errorf("app manifest validation failed: %w", err)
}
return a, nil
}
Note that mutation and validation must also be enabled in the kind's CUE definition (mutation.operations and validation.operations fields) — see the cue-kind-definition skill for details.
Key fields available on app.AdmissionRequest:
| Field | Type | Description |
|---|---|---|
Object | resource.Object | The incoming resource (after decoding) |
OldObject | resource.Object | Previous state (only on UPDATE operations) |
Action | resource.AdmissionAction | AdmissionActionCreate, AdmissionActionUpdate, AdmissionActionDelete, AdmissionActionConnect |
UserInfo | resource.AdmissionUserInfo | The user making the request |
Kind | string | The Object kind |
Group | string | The Object API Group |
Version | string | The Object API Version |
Common patterns to implement:
// Immutability check
if req.Action == resource.AdmissionActionUpdate && old.Spec.ImmutableField != obj.Spec.ImmutableField {
return fmt.Errorf("spec.immutableField cannot be changed after creation")
}
// Cross-field validation
if obj.Spec.StartTime.After(obj.Spec.EndTime) {
return fmt.Errorf("spec.startTime must be before spec.endTime")
}
// Referential validation (e.g. check referenced resource exists)
if _, err := v.client.Get(ctx, resource.Identifier{Name: obj.Spec.RefName, Namespace: obj.Namespace}); err != nil {
return fmt.Errorf("referenced resource %q not found", obj.Spec.RefName)
}
| Mode | Admission runtime |
|---|---|
| Standalone operator | App starts a webhook server; Kubernetes routes admission requests to it |
grafana/apps | Admission handlers are auto-registered as a Kubernetes in-process plugin — no separate server required |
The handler code itself is identical in both cases.