Apps: Add Example App to ./apps (#112069)

* [API Server] Add Example App for reference use.

* Remove Printlns.

* Upgrade app-sdk to v0.46.0, update apps to handle breaking changes.

* Only start the reconciler for the example app if the v1alpha1 API version is enabled.

* Some comment doc updates.

* Run make update-workspace

* Set codeowner for /apps/example

* Run make gofmt and make update-workspace

* Run prettier on apps/example/README.md

* Add COPY apps/example to Dockerfile

* Add an authorizer to the example app.

* Fix import ordering.

* Update apps/example/kinds/manifest.cue

Co-authored-by: Owen Diehl <ow.diehl@gmail.com>

* Run make update-workspace

* Re-run make gen-go for enterprise import updates

* Run make update-workspace

---------

Co-authored-by: Owen Diehl <ow.diehl@gmail.com>
This commit is contained in:
Austin Pond
2025-10-27 12:01:10 -04:00
committed by GitHub
co-authored by Owen Diehl
parent d25f5199c7
commit bf65c43783
71 changed files with 3744 additions and 4 deletions
+143
View File
@@ -0,0 +1,143 @@
package app
import (
"fmt"
"log/slog"
"os"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/k8s"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/operator"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana-app-sdk/simple"
"k8s.io/apimachinery/pkg/runtime/schema"
examplev0alpha1 "github.com/grafana/grafana/apps/example/pkg/apis/example/v0alpha1"
examplev1alpha1 "github.com/grafana/grafana/apps/example/pkg/apis/example/v1alpha1"
)
// New creates a new instance of the Example App. It gets called after the app's APIs have been registered,
// and is used for routing non-storage API requests, admission control, conversion, and can run
// reconcilers on kinds.
func New(cfg app.Config) (app.App, error) {
// APIPath needs to be set to `/apis`, as it defaults to empty
cfg.KubeConfig.APIPath = "/apis"
// We create a client to work with our Example kind in our reconciler
client, err := k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig()).ClientFor(examplev1alpha1.ExampleKind())
if err != nil {
return nil, fmt.Errorf("unable to create example client: %w", err)
}
var reconciler operator.Reconciler
exampleConfig, ok := cfg.SpecificConfig.(*ExampleConfig)
if ok && exampleConfig.EnableReconciler {
reconciler = NewExampleReconciler(client)
// Set the default logger if the reconciler is enabled--this should be done in grafana's API server handling instead,
// and will be corrected in a future PR
logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug, // Temporarily hardcoded to debug for the example
}))
}
// This is the configuration for our App.
simpleConfig := simple.AppConfig{
Name: "example",
KubeConfig: cfg.KubeConfig,
// ManagedKinds is the list of all kinds our app manages (the kinds owned by our app).
// Here, a Kind is defined as a distinct Group, Version, and Kind combination,
// so for each version of our Example kind, we need to add it to this list.
// Each kind can also have admission control attached to it--different versions can have different admission control attached.
// Handlers for custom routes defined in the manifest for the kind go here--this is where they actuall get routed,
// they are only defined in the manifest.
// Reconcilers and/or Watchers are also attached here, though they should only be attached to a single version per kind.
ManagedKinds: []simple.AppManagedKind{
{
Kind: examplev0alpha1.ExampleKind(),
// Validator is run on ingress and is it returns an error the request is rejected
Validator: NewValidator(),
// Mutator is run on ingress and makes changes to the input object
Mutator: NewMutator(),
},
{
Kind: examplev1alpha1.ExampleKind(),
// We only want the reconciler on one version of our kind, and it's usually best to use the latest
// We'll receive events for every example object, regardless of version used in the API,
// it will convert them to the version used for the reconciler.
Reconciler: reconciler,
// By default, reconcilers for ManagedKinds are wrapped in
ReconcileOptions: simple.BasicReconcileOptions{
// Namespace is the namespace your reconciler will watch.
// It defaults to all, so this isn't necessary to specify the way we do here.
Namespace: resource.NamespaceAll,
// We can optionally filter our reconciler to only get events for Example resources which
// satisfy the following label filters
// LabelFilters: []string{"foo=bar"},
// By default, reconcilers for ManagedKinds are wrapped in the app-sdk's OpinionatedReconciler.
// To turn this functionality off, you can set UsePlain to false
// UsePlain: true,
},
// Validator is run on ingress and is it returns an error the request is rejected
Validator: NewValidator(),
// Mutator is run on ingress and makes changes to the input object
Mutator: NewMutator(),
// We defined this route in our CUE, but we need to actually define the HTTP handler for it.
CustomRoutes: simple.AppCustomRouteHandlers{
{
Path: "foo",
Method: "GET",
}: ExampleGetFooHandler,
},
},
},
// Conversion for kinds is defined for all versions of a kind at once.
// This interface may change in the future, see https://github.com/grafana/grafana-app-sdk/issues/617
Converters: map[schema.GroupKind]simple.Converter{
{
Group: cfg.ManifestData.Group,
Kind: examplev0alpha1.ExampleKind().Kind(),
}: NewExampleConverter(),
},
// VersionedCustomRoutes are the custom route handlers for routes defined at the version level of the manifest
// instead of for a specific kind. This are sometimes referred to as "resource routes"
// (as opposed to "subresource routes" which are attached to kinds).
VersionedCustomRoutes: map[string]simple.AppVersionRouteHandlers{
"v1alpha1": {
{
Namespaced: true,
Path: "something",
Method: "GET",
}: GetSomethingHandler,
{
Namespaced: false,
Path: "other",
Method: "GET",
}: GetOtherHandler,
},
},
}
a, err := simple.NewApp(simpleConfig)
if err != nil {
return nil, err
}
// This makes it easier to catch problems at startup, rather than when something doesn't behave as expected.
// ValidateManifest will ensure that the capabilities you define in your simple.AppConfig
// match the capabilities described in the AppManifest.
err = a.ValidateManifest(cfg.ManifestData)
if err != nil {
return nil, err
}
return a, nil
}
func GetKinds() map[schema.GroupVersion][]resource.Kind {
gv := schema.GroupVersion{
Group: examplev1alpha1.ExampleKind().Group(),
Version: examplev1alpha1.ExampleKind().Version(),
}
return map[schema.GroupVersion][]resource.Kind{
gv: {examplev1alpha1.ExampleKind()},
}
}
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"context"
"fmt"
"regexp"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/apps/example/pkg/apis/example/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
var namespacedSomethingRouteMatcher = regexp.MustCompile(fmt.Sprintf(`^/apis/%s/%s/namespaces/([^\/]+)/something$`, v1alpha1.APIGroup, v1alpha1.APIVersion))
// GetAuthorizer returns an authorizer for all kinds managed by the example app.
// It must be added to the installer in pkg/registry/apps/example/register.go to be used
func GetAuthorizer() authorizer.Authorizer {
return authorizer.AuthorizerFunc(
func(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) {
if !attr.IsResourceRequest() {
return authorizer.DecisionNoOpinion, "", nil
}
// require a user
u, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
// check if is admin
if u.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
}
// Only allow admins to call the custom subresource
if attr.GetSubresource() == "custom" {
return authorizer.DecisionDeny, "forbidden", nil
}
// Only allow admins to call the namespaced and cluster routes
// There's no easy way to check that from attrs like with GetSubresource(),
// so we look at the full path and check
if namespacedSomethingRouteMatcher.MatchString(attr.GetPath()) {
return authorizer.DecisionDeny, "forbidden", nil
}
if attr.GetPath() == fmt.Sprintf("/apis/%s/%s/other", v1alpha1.APIGroup, v1alpha1.APIVersion) {
return authorizer.DecisionDeny, "forbidden", nil
}
// Otherwise, allow
return authorizer.DecisionAllow, "", nil
},
)
}
+7
View File
@@ -0,0 +1,7 @@
package app
// ExampleConfig is an example app-specific config type
type ExampleConfig struct {
EnableReconciler bool
EnableSomeFeature bool
}
+126
View File
@@ -0,0 +1,126 @@
package app
import (
"bytes"
"errors"
"fmt"
"strconv"
"github.com/grafana/grafana-app-sdk/k8s"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana-app-sdk/simple"
"github.com/grafana/grafana/apps/example/pkg/apis/example/v0alpha1"
"github.com/grafana/grafana/apps/example/pkg/apis/example/v1alpha1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var _ simple.Converter = NewExampleConverter()
type ExampleConverter struct{}
func NewExampleConverter() *ExampleConverter {
return &ExampleConverter{}
}
// Convert converts an object from an arbitrary input version slice of bytes
// to a target version, and returns the JSON bytes of that version.
func (e *ExampleConverter) Convert(obj k8s.RawKind, targetAPIVersion string) ([]byte, error) {
srcGVK := schema.FromAPIVersionAndKind(obj.APIVersion, obj.Kind)
dstGVK := schema.FromAPIVersionAndKind(targetAPIVersion, v1alpha1.ExampleKind().Kind())
if srcGVK.Group != v1alpha1.APIGroup {
// This should never happen, but check just in case
return nil, fmt.Errorf("wrong group to convert example.grafana.app, got %s", srcGVK.Group)
}
if srcGVK.Kind != v1alpha1.ExampleKind().Kind() {
// This should also never happen, but check just in case
return nil, fmt.Errorf("wrong kind to convert Example, got %s", srcGVK.Kind)
}
if srcGVK == dstGVK {
// This should never happen, but if it does no conversion is necessary, we can return the input
return obj.Raw, nil
}
// Check source version
switch srcGVK.Version {
case v0alpha1.APIVersion:
srcKind := v0alpha1.ExampleKind()
uncastSrcObj, err := srcKind.Read(bytes.NewReader(obj.Raw), resource.KindEncodingJSON)
if err != nil {
return nil, fmt.Errorf("unable to parse JSON bytes into %s: %w", srcGVK.String(), err)
}
srcObj, ok := uncastSrcObj.(*v0alpha1.Example)
if !ok {
return nil, errors.New("read object was not of type *v0alpha1.Example")
}
switch dstGVK.Version {
case v1alpha1.APIVersion:
dstObj := &v1alpha1.Example{}
// Set Type metadata
dstObj.SetGroupVersionKind(dstGVK)
// Copy Object metadata
srcObj.ObjectMeta.DeepCopyInto(&dstObj.ObjectMeta)
// Copy spec and status
dstObj.Spec.FirstField = strconv.Itoa(int(srcObj.Spec.FirstField))
dstObj.Status.LastObservedGeneration = srcObj.Status.LastObservedGeneration
dstObj.Status.AdditionalFields = srcObj.Status.AdditionalFields
if srcObj.Status.OperatorStates != nil {
dstObj.Status.OperatorStates = make(map[string]v1alpha1.ExamplestatusOperatorState)
for k, v := range srcObj.Status.OperatorStates {
dstObj.Status.OperatorStates[k] = v1alpha1.ExamplestatusOperatorState{
LastEvaluation: v.LastEvaluation,
State: v1alpha1.ExampleStatusOperatorStateState(v.State),
DescriptiveState: v.DescriptiveState,
Details: v.Details,
}
}
}
dstKind := v1alpha1.ExampleKind()
buf := &bytes.Buffer{}
err := dstKind.Write(dstObj, buf, resource.KindEncodingJSON)
return buf.Bytes(), err
default:
return nil, fmt.Errorf("unknown target version %s", dstGVK.Version)
}
case v1alpha1.APIVersion:
srcKind := v1alpha1.ExampleKind()
uncastSrcObj, err := srcKind.Read(bytes.NewReader(obj.Raw), resource.KindEncodingJSON)
if err != nil {
return nil, fmt.Errorf("unable to parse JSON bytes into %s: %w", srcGVK.String(), err)
}
srcObj, ok := uncastSrcObj.(*v1alpha1.Example)
if !ok {
return nil, errors.New("read object was not of type *v1alpha1.Example")
}
switch dstGVK.Version {
case v0alpha1.APIVersion:
dstObj := &v0alpha1.Example{}
// Set Type metadata
dstObj.SetGroupVersionKind(dstGVK)
// Copy Object metadata
srcObj.ObjectMeta.DeepCopyInto(&dstObj.ObjectMeta)
// Copy spec and status
castInt, _ := strconv.Atoi(srcObj.Spec.FirstField) // Lossy backwards conversion
dstObj.Spec.FirstField = int64(castInt)
dstObj.Status.LastObservedGeneration = srcObj.Status.LastObservedGeneration
dstObj.Status.AdditionalFields = srcObj.Status.AdditionalFields
if srcObj.Status.OperatorStates != nil {
dstObj.Status.OperatorStates = make(map[string]v0alpha1.ExamplestatusOperatorState)
for k, v := range srcObj.Status.OperatorStates {
dstObj.Status.OperatorStates[k] = v0alpha1.ExamplestatusOperatorState{
LastEvaluation: v.LastEvaluation,
State: v0alpha1.ExampleStatusOperatorStateState(v.State),
DescriptiveState: v.DescriptiveState,
Details: v.Details,
}
}
}
dstKind := v0alpha1.ExampleKind()
buf := &bytes.Buffer{}
err := dstKind.Write(dstObj, buf, resource.KindEncodingJSON)
return buf.Bytes(), err
default:
return nil, fmt.Errorf("unknown target version %s", dstGVK.Version)
}
}
return nil, fmt.Errorf("unknown source version %s", srcGVK.Version)
}
+31
View File
@@ -0,0 +1,31 @@
package app
import (
"context"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/simple"
)
var _ simple.KindMutator = NewMutator()
type Mutator struct{}
func NewMutator() *Mutator {
return &Mutator{}
}
// Mutate makes modifications to an input object from the API, and returns the changed object.
// This mutation will be done on every request, so it can be used to add or update things like labels
// or annotations. Here, we add an annotation noting the last resourceVersion this was called for.
func (m *Mutator) Mutate(ctx context.Context, req *app.AdmissionRequest) (*app.MutatingResponse, error) {
annotations := req.Object.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations["example.grafana.app/mutated"] = req.Object.GetResourceVersion()
req.Object.SetAnnotations(annotations)
return &app.MutatingResponse{
UpdatedObject: req.Object,
}, nil
}
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"context"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/operator"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/grafana/grafana/apps/example/pkg/apis/example/v1alpha1"
)
// ExampleReconciler wraps TypedReconciler to simplify some of our reconciliation logic,
// as TypedReconciler will handle type checking of the input object for us.
type ExampleReconciler struct {
operator.TypedReconciler[*v1alpha1.Example]
client resource.Client
}
func NewExampleReconciler(client resource.Client) *ExampleReconciler {
reconciler := ExampleReconciler{
TypedReconciler: operator.TypedReconciler[*v1alpha1.Example]{},
client: client,
}
reconciler.ReconcileFunc = reconciler.doReconcile
return &reconciler
}
// doReconcile is the main reconciliation loop for our app's Example reconciler.
// All it does is print a log message and then update the last observed generation in the status
// (if the request is a DELETE, it doesn't try to update the status, as the update would fail).
func (e *ExampleReconciler) doReconcile(ctx context.Context, req operator.TypedReconcileRequest[*v1alpha1.Example]) (operator.ReconcileResult, error) {
if req.Object.GetGeneration() == req.Object.Status.LastObservedGeneration {
// Skip if we've already processed this spec
return operator.ReconcileResult{}, nil
}
logging.FromContext(ctx).Info("reconciling example", "name", req.Object.GetName(), "namespace", req.Object.GetNamespace(), "action", operator.ResourceActionFromReconcileAction(req.Action))
// If this is a delete, we don't need to do anything
if req.Action == operator.ReconcileActionDeleted {
return operator.ReconcileResult{}, nil
}
// Update the status.
// We use resource.UpdateObject here to handle conflicts when doing the update,
// as it gets the current state, performs our update function, then pushes to the remote
_, err := resource.UpdateObject(ctx, e.client, req.Object.GetStaticMetadata().Identifier(), func(obj *v1alpha1.Example, _ bool) (*v1alpha1.Example, error) {
obj.Status.LastObservedGeneration = req.Object.GetGeneration()
return obj, nil
}, resource.UpdateOptions{
Subresource: "status",
})
if err != nil {
return operator.ReconcileResult{}, err
}
return operator.ReconcileResult{}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package app
import (
"context"
"encoding/json"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana/apps/example/pkg/apis/example/v1alpha1"
)
// ExampleGetFooHandler handles requests for the GET /foo subresource route
func ExampleGetFooHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
message := "Hello, world!"
return json.NewEncoder(writer).Encode(v1alpha1.GetFoo{
GetFooBody: v1alpha1.GetFooBody{
Message: message,
},
})
}
// GetSomethingHandler handles requests for the GET /something resource route
func GetSomethingHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
message := "This is a namespaced route"
if request.URL.Query().Has("message") {
message = request.URL.Query().Get("message")
}
return json.NewEncoder(writer).Encode(v1alpha1.GetSomething{
TypeMeta: metav1.TypeMeta{
APIVersion: fmt.Sprintf("%s/%s", v1alpha1.APIGroup, v1alpha1.APIVersion),
},
GetSomethingBody: v1alpha1.GetSomethingBody{
Namespace: request.ResourceIdentifier.Namespace,
Message: message,
},
})
}
// GetOtherHandler handles requests for the GET /other cluster-scoped resource route
func GetOtherHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
message := "This is a cluster route"
if request.URL.Query().Has("message") {
message = request.URL.Query().Get("message")
}
return json.NewEncoder(writer).Encode(v1alpha1.GetOther{
Message: message,
})
}
+28
View File
@@ -0,0 +1,28 @@
package app
import (
"context"
"errors"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/simple"
)
var _ simple.KindValidator = NewValidator()
// Validator implements simple.KindValidator
type Validator struct{}
func NewValidator() *Validator {
return &Validator{}
}
// Validate runs any kind of validation on incoming objects,
// and returns an error to reject the request.
// Here, we just reject any Example resource which is named "invalid"
func (v *Validator) Validate(ctx context.Context, req *app.AdmissionRequest) error {
if req.Object.GetName() == "invalid" {
return errors.New("example cannot be named 'invalid'")
}
return nil
}