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:
co-authored by
Owen Diehl
parent
d25f5199c7
commit
bf65c43783
@@ -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()},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
// ExampleConfig is an example app-specific config type
|
||||
type ExampleConfig struct {
|
||||
EnableReconciler bool
|
||||
EnableSomeFeature bool
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user