Skip to content

Latest commit

 

History

History
205 lines (171 loc) · 10.8 KB

writing-an-app.md

File metadata and controls

205 lines (171 loc) · 10.8 KB

Writing an App

Within the context of the grafana-app-sdk, an App is a collection of behaviors which can be run by a runner, typically as a kubernetes operator. An App and the way which it is run are decoupled, such that an App can be run in any supported runner format. Currently, the only out-of-the-box support in the grafana-app-sdk is for running an App as a standalone kubernetes operator, but additional runners are expected to be added in the future.

Quickstart

To quickly get app code to look at, either consider running through the tutorial, or run the following commands in a directory you want to build your app in:

grafana-app-sdk project init test-app
grafana-app-sdk project kind add MyTestKind
grafana-app-sdk generate
grafana-app-sdk project component add operator

Considerations

As mentioned, an App is a collection of behaviors, which is defined by the app.App interface:

type App interface {
	// Validate validates the incoming request, and returns an error if validation fails
	Validate(ctx context.Context, request *AdmissionRequest) error
	// Mutate runs mutation on the incoming request, responding with a MutatingResponse on success, or an error on failure
	Mutate(ctx context.Context, request *AdmissionRequest) (*MutatingResponse, error)
	// Convert converts the object based on the ConversionRequest, returning a RawObject which MUST contain
	// the converted bytes and encoding (Raw and Encoding respectively), and MAY contain the Object representation of those bytes.
	// It returns an error if the conversion fails, or if the functionality is not supported by the app.
	Convert(ctx context.Context, req ConversionRequest) (*RawObject, error)
	// CallResourceCustomRoute handles the call to a resource custom route, and returns a response to the request or an error.
	// If the route doesn't exist, the implementer MAY return ErrCustomRouteNotFound to signal to the runner,
	// or may choose to return a response with a not found status code and custom body.
	// It returns an error if the functionality is not supported by the app.
	CallResourceCustomRoute(ctx context.Context, request *ResourceCustomRouteRequest) (*ResourceCustomRouteResponse, error)
	// ManagedKinds returns a slice of Kinds which are managed by this App.
	// If there are multiple versions of a Kind, each one SHOULD be returned by this method,
	// as app runners may depend on having access to all kinds.
	ManagedKinds() []resource.Kind
	// Runner returns a Runnable with an app main loop. Any business logic that is not/can not be exposed
	// via other App interfaces should be contained within this method.
	// Runnable MAY be nil, in which case, the app has no main loop business logic.
	Runner() Runnable
}

An App doesn't need to support all these behaviors, and if it doesn't, it returns app.ErrNotImplemented from that function. The App indicates which behaviors it supports to the runner via its Manifest.

All of these behaviors (except Runner() and ManagedKinds()) are actions taken on one or more kinds managed by the app. For example, when a new resource is created for a kind which the app manages, Mutate and Validate will both be called if supported. Convert may be called if the stored version is different from the version used in the create request.

CallResourceCustomRoute is currently unsupported in the operator runner (as it is not a supported action for a standalone kubernetes operator), but is to be used on custom subroutes for resources (unsupported by kubernetes CRDs). This will be further elaborated on when new runners are released.

ManagedKinds() is used by the runner to get the go types for kinds managed by the app (as the manifest is data and does not contain the go types used).

Finally, Runner() returns a main loop for the app, for logic which is not explicitly event-based like Validate, Mutate, and Convert. However, often this main loop should also be event-based, using the operator pattern to watch resources and react to changes. This behavior is the default behavior of Runner() in simple.App.

App Manifest

The app manifest is a collection of data about which kinds are managed by the app, what behaviors are supported by the app for each kind, and other additional information needed for the app to function (such as additional permissions). When you write your CUE manifest and use grafana-app-sdk generate, this manifest will be automatically generated for you, as both an embeddable go variable, or a JSON/YAML custom resource. Writing a manifest from scratch is not advised, but can be done. See manifest.cue for the definition of the custom resource, or manifest.go to write your app.ManifestData in go directly.

The easiest way to get started on this is to use grafana-app-sdk project init <my-project-module-name>. This will create a starting CUE manifest to use with grafana-app-sdk generate. You can add template kinds to this with grafana-app-sdk project kind add <MyKindName>.

simple.App

The standard way to build an app, without implementing app.App yourself, is to use the simple package's App type. simple.NewApp creates a new simple.App, using the provided simple.AppConfig. simple.App runs an operator as its main Runner() code, and the config has you specify watchers or reconcilers for the kinds you manage with the app.

A simple.App has two variants for kinds which you attach watchers/reconcilers to: AppManagedKinds and AppUnmanagedKinds. AppManagedKinds are kinds which your app owns and manages, which you can attach mutation, validation, and conversion logic to. AppUnmanagedKinds are kinds which your app wants to watch (such as dashboards), but does not own or manage.

The easiest way to see how to use simple.App is to use grafana-app-sdk project component add operator, or to follow the tutorial, which will have you build out a fully-featured simple.App with a watcher, validation, and mutation.

App Provider

In order for an app to be run by a runner, you need something which will provide a manifest to the runner, and instantiate a new instance of your app once the runner has loaded the configuration necessary for running it. In this case, this is defined by app.Provider:

// Provider represents a type which can provide an app manifest, and create a new App when given a configuration.
// It should be used by runners to determine an app's capabilities and create an instance of the app to run.
type Provider interface {
	// Manifest returns a Manifest, which may contain ManifestData or may point to a location where ManifestData can be fetched from.
	// The runner should use the ManifestData to determine app capabilities.
	Manifest() Manifest
	// SpecificConfig is any app-specific config that cannot be loaded by the runner that should be provided in NewApp
	SpecificConfig() SpecificConfig
	// NewApp creates a new App instance using the provided config, or returns an error if an App cannot be instantiated.
	NewApp(Config) (App, error)
}

Luckily, the simple package has a simple implementation of this, with simple.AppProvider. If you use grafana-app-sdk project component add operator, this is automatically generated for you.

App Runner

Finally, we need to run our app. As mentioned previously, the only currently supported runner in the grafana-app-sdk is a standalone kubernetes operator. However, because the app.App and app.Provider interfaces are what are used to run an app, app runners can be build outside of the grafana-app-sdk which will run any valid app.

In this case, though, to run as a standalone kubernetes operator, we use the operator.Runner type (instantiated with operator.NewRunner). This runner takes configuration necessary for running as a standalone operator (kubeconfig, webhook configuration, etc.), and then runs any apps given to its Run method via their AppProvider.

An Example

As an example, let's build a quick, simple app that runs as a standalone operator, assuming we have a kind already generated. You can quickly generate a kind and manifest to follow this code with:

mkdir test-app && cd test-app && grafana-app-sdk project init test-app && grafana-app-sdk project kind add MyKind && grafana-app-sdk generate

Now, for simplicity, we'll put everything in a main.go file:

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"

	"github.com/grafana/grafana-app-sdk/app"
	"github.com/grafana/grafana-app-sdk/logging"
	"github.com/grafana/grafana-app-sdk/operator"
	"github.com/grafana/grafana-app-sdk/simple"
	"k8s.io/client-go/tools/clientcmd"

	"test-app/pkg/generated"
	mykind "test-app/pkg/generated/mykind/v1"
)

func main() {
	// Configure the default logger to use slog
	logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
	}))

	// Load the kube config from a file
	kubeConfig, err := clientcmd.BuildConfigFromFlags("", "local.kubeconfig")
	if err != nil {
		panic(err)
	}
	// Build the runner
	runner, err := operator.NewRunner(operator.RunnerConfig{
		KubeConfig: *kubeConfig,
	})
	if err != nil {
		panic(err)
	}

	// Run the app
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
	defer cancel()
	err = runner.Run(ctx, simple.NewAppProvider(generated.LocalManifest(), nil, NewApp))
}

func NewApp(cfg app.Config) (app.App, error) {
	return simple.NewApp(simple.AppConfig{
		Name:       "my-test-app",
		KubeConfig: cfg.KubeConfig,
		ManagedKinds: []simple.AppManagedKind{{
			Kind: mykind.Kind(),
			Reconciler: &operator.TypedReconciler[*mykind.MyKind]{
				ReconcileFunc: func(ctx context.Context, req operator.TypedReconcileRequest[*mykind.MyKind]) (operator.ReconcileResult, error) {
					logging.FromContext(ctx).Info("Reconcile request", "name", req.Object.GetName(), "action", operator.ResourceActionFromReconcileAction(req.Action))
					return operator.ReconcileResult{}, nil
				},
			},
		}},
	})
}

You can connect this to a kubernetes API server by changing "local.kubeconfig" (or populating that file), and making sure the MyKind CRD exists by applying the generated definitions/mykind.testapp.ext.grafana.com.json file.

You can expand on this to add Validation or Mutation (or conversion), but keep in mind you'll need to add a WebhookConfig to the operator.RunnerConfig, and set up the webhook configurations in your kubernetes API server. Unless you're already familiar with how to set up webhooks, the easier way to do this is to follow the tutorial which sets up validation and mutation in part 8.