mirror of https://github.com/grafana/grafana.git
616 lines
19 KiB
Go
616 lines
19 KiB
Go
package provisioning
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"slices"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
"k8s.io/apiserver/pkg/admission"
|
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
|
"k8s.io/apiserver/pkg/registry/generic/registry"
|
|
"k8s.io/apiserver/pkg/registry/rest"
|
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
|
"k8s.io/kube-openapi/pkg/common"
|
|
"k8s.io/kube-openapi/pkg/spec3"
|
|
"k8s.io/kube-openapi/pkg/validation/spec"
|
|
|
|
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
|
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/auth"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
|
|
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
)
|
|
|
|
var (
|
|
_ builder.APIGroupBuilder = (*ProvisioningAPIBuilder)(nil)
|
|
_ RepoGetter = (*ProvisioningAPIBuilder)(nil)
|
|
)
|
|
|
|
// This is used just so wire has something unique to return
|
|
type ProvisioningAPIBuilder struct {
|
|
urlProvider func(namespace string) string
|
|
webhookSecretKey string
|
|
|
|
features featuremgmt.FeatureToggles
|
|
getter rest.Getter
|
|
localFileResolver *repository.LocalFolderResolver
|
|
logger *slog.Logger
|
|
client *resourceClient
|
|
ghFactory github.ClientFactory
|
|
}
|
|
|
|
// This constructor will be called when building a multi-tenant apiserveer
|
|
// Avoid adding anything that secretly requires additional hidden dependencies
|
|
// like *settings.Cfg or core grafana services that depend on database connections
|
|
func NewProvisioningAPIBuilder(
|
|
local *repository.LocalFolderResolver,
|
|
urlProvider func(namespace string) string,
|
|
webhookSecreteKey string,
|
|
identities auth.BackgroundIdentityService,
|
|
features featuremgmt.FeatureToggles,
|
|
ghFactory github.ClientFactory,
|
|
) *ProvisioningAPIBuilder {
|
|
return &ProvisioningAPIBuilder{
|
|
urlProvider: urlProvider,
|
|
localFileResolver: local,
|
|
logger: slog.Default().With("logger", "provisioning-api-builder"),
|
|
webhookSecretKey: webhookSecreteKey,
|
|
client: newResourceClient(identities),
|
|
features: features,
|
|
ghFactory: ghFactory,
|
|
}
|
|
}
|
|
|
|
func RegisterAPIService(
|
|
// It is OK to use setting.Cfg here -- this is only used when running single tenant with a full setup
|
|
cfg *setting.Cfg,
|
|
features featuremgmt.FeatureToggles,
|
|
apiregistration builder.APIRegistrar,
|
|
reg prometheus.Registerer,
|
|
identities auth.BackgroundIdentityService,
|
|
ghFactory github.ClientFactory,
|
|
) *ProvisioningAPIBuilder {
|
|
if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
|
|
return nil // skip registration unless opting into experimental apis OR the feature specifically
|
|
}
|
|
builder := NewProvisioningAPIBuilder(&repository.LocalFolderResolver{
|
|
ProvisioningPath: cfg.ProvisioningPath,
|
|
DevenvPath: filepath.Join(cfg.HomePath, "devenv"),
|
|
}, func(namespace string) string {
|
|
return cfg.AppURL
|
|
}, cfg.SecretKey, identities, features, ghFactory)
|
|
apiregistration.RegisterAPI(builder)
|
|
return builder
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
|
return authorizer.AuthorizerFunc(
|
|
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
|
if a.GetSubresource() == "webhook" {
|
|
// for now????
|
|
return authorizer.DecisionAllow, "", nil
|
|
}
|
|
|
|
// fallback to the standard authorizer
|
|
return authorizer.DecisionNoOpinion, "", nil
|
|
})
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
|
return provisioning.SchemeGroupVersion
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
|
err := provisioning.AddToScheme(scheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// This is required for --server-side apply
|
|
err = provisioning.AddKnownTypes(provisioning.InternalGroupVersion, scheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Only 1 version (for now?)
|
|
return scheme.SetVersionPriority(provisioning.SchemeGroupVersion)
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
|
repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create repository storage: %w", err)
|
|
}
|
|
|
|
repositoryStorage.AfterCreate = b.afterCreate
|
|
// AfterUpdate doesn't have the old object, so we have to use BeginUpdate
|
|
repositoryStorage.BeginUpdate = b.beginUpdate
|
|
repositoryStorage.AfterDelete = b.afterDelete
|
|
|
|
repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage)
|
|
b.getter = repositoryStorage
|
|
|
|
helloWorld := &helloWorldSubresource{
|
|
getter: repositoryStorage,
|
|
statusUpdater: repositoryStatusStorage,
|
|
parent: b,
|
|
}
|
|
|
|
exportConnector := &exportConnector{
|
|
repoGetter: b,
|
|
client: b.client,
|
|
}
|
|
|
|
storage := map[string]rest.Storage{}
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
|
|
// Can be used by kubectl: kubectl --kubeconfig grafana.kubeconfig patch Repository local-devenv --type=merge --subresource=status --patch='status: {"currentGitCommit": "hello"}'
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath("hello")] = helloWorld
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{
|
|
getter: b,
|
|
client: b.client.identities,
|
|
}
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = &filesConnector{
|
|
getter: b,
|
|
client: b.client,
|
|
logger: b.logger.With("connector", "files"),
|
|
}
|
|
storage[provisioning.RepositoryResourceInfo.StoragePath("export")] = exportConnector
|
|
apiGroupInfo.VersionedResourcesStorageMap[provisioning.VERSION] = storage
|
|
return nil
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) GetRepository(ctx context.Context, name string) (repository.Repository, error) {
|
|
obj, err := b.getter.Get(ctx, name, &metav1.GetOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b.asRepository(ctx, obj)
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repository.Repository, error) {
|
|
if obj == nil {
|
|
return nil, fmt.Errorf("missing repository object")
|
|
}
|
|
r, ok := obj.(*provisioning.Repository)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected repository configuration")
|
|
}
|
|
|
|
switch r.Spec.Type {
|
|
case provisioning.LocalRepositoryType:
|
|
return repository.NewLocal(r, b.localFileResolver), nil
|
|
case provisioning.GitHubRepositoryType:
|
|
return repository.NewGitHub(ctx, r, b.ghFactory), nil
|
|
case provisioning.S3RepositoryType:
|
|
return repository.NewS3(r), nil
|
|
default:
|
|
return repository.NewUnknown(r), nil
|
|
}
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) afterCreate(obj runtime.Object, opts *metav1.CreateOptions) {
|
|
cfg, ok := obj.(*provisioning.Repository)
|
|
if !ok {
|
|
b.logger.Error("object is not *provisioning.Repository")
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
repo, err := b.asRepository(ctx, cfg)
|
|
if err != nil {
|
|
b.logger.Error("failed to get repository", "error", err)
|
|
return
|
|
}
|
|
|
|
if err := b.ensureRepositoryFolderExists(ctx, cfg); err != nil {
|
|
b.logger.Error("failed to ensure repository folder exists", "error", err)
|
|
return
|
|
}
|
|
|
|
if err := repo.AfterCreate(ctx); err != nil {
|
|
b.logger.Error("failed to run after create", "error", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) beginUpdate(ctx context.Context, obj, old runtime.Object, opts *metav1.UpdateOptions) (registry.FinishFunc, error) {
|
|
objCfg, ok := obj.(*provisioning.Repository)
|
|
if !ok {
|
|
return nil, fmt.Errorf("new object is not *provisioning.Repository")
|
|
}
|
|
oldCfg, ok := old.(*provisioning.Repository)
|
|
if !ok {
|
|
return nil, fmt.Errorf("old object is not *provisioning.Repository")
|
|
}
|
|
|
|
repo, err := b.asRepository(ctx, objCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get new repository: %w", err)
|
|
}
|
|
|
|
oldRepo, err := b.asRepository(ctx, oldCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get old repository: %w", err)
|
|
}
|
|
|
|
if err := b.ensureRepositoryFolderExists(ctx, objCfg); err != nil {
|
|
return nil, fmt.Errorf("failed to ensure the configured folder exists: %w", err)
|
|
}
|
|
|
|
undo, err := repo.BeginUpdate(ctx, oldRepo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return func(ctx context.Context, success bool) {
|
|
if !success && undo != nil {
|
|
if err := undo(ctx); err != nil {
|
|
b.logger.Error("failed to undo failed update", "error", err)
|
|
}
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) ensureRepositoryFolderExists(ctx context.Context, cfg *provisioning.Repository) error {
|
|
if !b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) && !b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerTestingWithExperimentalAPIs) {
|
|
// Nothing to do: we can't use the folders API.
|
|
return nil
|
|
}
|
|
|
|
if cfg.Spec.Folder == "" {
|
|
// The root folder can't not exist, so we don't have to do anything.
|
|
return nil
|
|
}
|
|
|
|
client, err := b.client.Client(cfg.GetNamespace())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lookup := newKindsLookup(client)
|
|
folderResource, ok := lookup.Resource(schema.GroupVersionKind{
|
|
Group: "folder.grafana.app",
|
|
Version: "v0alpha1",
|
|
Kind: "Folder",
|
|
})
|
|
if !ok {
|
|
return fmt.Errorf("failed to get resource client of the Folder kind")
|
|
}
|
|
folderIface := client.Resource(folderResource).Namespace(cfg.GetNamespace())
|
|
|
|
_, err = folderIface.Get(ctx, cfg.Spec.Folder, metav1.GetOptions{})
|
|
if err == nil {
|
|
// The folder exists and doesn't need to be created.
|
|
return nil
|
|
} else if !apierrors.IsNotFound(err) {
|
|
return fmt.Errorf("failed to search for existing repo folder: %w", err)
|
|
}
|
|
|
|
title := cfg.Spec.Title
|
|
if title == "" {
|
|
title = cfg.Spec.Folder
|
|
}
|
|
|
|
_, err = folderIface.Create(ctx, &unstructured.Unstructured{
|
|
Object: map[string]any{
|
|
"metadata": map[string]any{
|
|
"name": cfg.Spec.Folder,
|
|
"namespace": cfg.GetNamespace(),
|
|
},
|
|
"spec": map[string]any{
|
|
"title": title,
|
|
"description": "Repository-managed folder.",
|
|
},
|
|
},
|
|
}, metav1.CreateOptions{})
|
|
return err
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) afterDelete(obj runtime.Object, opts *metav1.DeleteOptions) {
|
|
ctx := context.Background()
|
|
cfg, ok := obj.(*provisioning.Repository)
|
|
if !ok {
|
|
b.logger.Error("object is not *provisioning.Repository")
|
|
return
|
|
}
|
|
|
|
repo, err := b.asRepository(ctx, cfg)
|
|
if err != nil {
|
|
b.logger.Error("failed to get repository", "error", err)
|
|
return
|
|
}
|
|
|
|
if err := repo.AfterDelete(ctx); err != nil {
|
|
b.logger.Error("failed to run after delete", "error", err)
|
|
return
|
|
}
|
|
}
|
|
func (b *ProvisioningAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
|
|
obj := a.GetObject()
|
|
|
|
if obj == nil || a.GetOperation() == admission.Connect {
|
|
return nil // This is normal for sub-resource
|
|
}
|
|
|
|
r, ok := obj.(*provisioning.Repository)
|
|
if !ok {
|
|
return fmt.Errorf("expected repository configuration")
|
|
}
|
|
|
|
if r.Spec.Type == provisioning.GitHubRepositoryType {
|
|
if r.Spec.GitHub == nil {
|
|
return fmt.Errorf("github configuration is required")
|
|
}
|
|
|
|
if r.Spec.GitHub.Branch == "" {
|
|
r.Spec.GitHub.Branch = "main"
|
|
}
|
|
|
|
if r.Spec.GitHub.WebhookURL == "" {
|
|
gvr := provisioning.RepositoryResourceInfo.GroupVersionResource()
|
|
r.Spec.GitHub.WebhookURL = fmt.Sprintf("%sapis/%s/%s/namespaces/%s/%s/%s/webhook",
|
|
b.urlProvider(a.GetNamespace()), // gets the full name
|
|
gvr.Group, gvr.Version,
|
|
a.GetNamespace(), gvr.Resource, a.GetName())
|
|
}
|
|
|
|
if r.Spec.GitHub.WebhookSecret == "" {
|
|
r.Spec.GitHub.WebhookSecret = b.generateWebhookSecret(r.Spec.GitHub.Token)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
|
obj := a.GetObject()
|
|
if obj == nil || a.GetOperation() == admission.Connect {
|
|
return nil // This is normal for sub-resource
|
|
}
|
|
|
|
repo, err := b.asRepository(ctx, obj)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Typed validation
|
|
list := repo.Validate()
|
|
cfg := repo.Config()
|
|
|
|
if a.GetOperation() == admission.Update {
|
|
oldRepo, err := b.asRepository(ctx, a.GetOldObject())
|
|
if err != nil {
|
|
return fmt.Errorf("get old repository for update: %w", err)
|
|
}
|
|
|
|
if cfg.Spec.Type != oldRepo.Config().Spec.Type {
|
|
list = append(list, field.Invalid(field.NewPath("spec", "type"),
|
|
cfg.Spec.Type, "Changing repository type is not supported"))
|
|
}
|
|
}
|
|
|
|
if cfg.Spec.Title == "" {
|
|
list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given"))
|
|
}
|
|
|
|
// Reserved names (for now)
|
|
reserved := []string{"classic", "SQL", "plugins", "legacy"}
|
|
if slices.Contains(reserved, cfg.Name) {
|
|
list = append(list, field.Invalid(field.NewPath("metadata", "name"), cfg.Name, "Name is reserved"))
|
|
}
|
|
|
|
if cfg.Spec.Type != provisioning.LocalRepositoryType && cfg.Spec.Local != nil {
|
|
list = append(list, field.Invalid(field.NewPath("spec", "local"),
|
|
cfg.Spec.GitHub, "Local config only valid when type is local"))
|
|
}
|
|
|
|
if cfg.Spec.Type != provisioning.GitHubRepositoryType && cfg.Spec.GitHub != nil {
|
|
list = append(list, field.Invalid(field.NewPath("spec", "github"),
|
|
cfg.Spec.GitHub, "Github config only valid when type is github"))
|
|
}
|
|
|
|
if cfg.Spec.Type != provisioning.S3RepositoryType && cfg.Spec.S3 != nil {
|
|
list = append(list, field.Invalid(field.NewPath("spec", "s3"),
|
|
cfg.Spec.GitHub, "S3 config only valid when type is s3"))
|
|
}
|
|
|
|
if len(list) > 0 {
|
|
return apierrors.NewInvalid(schema.GroupKind{
|
|
Group: provisioning.GROUP,
|
|
Kind: "Repository", //??
|
|
}, a.GetName(), list)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
|
return provisioning.GetOpenAPIDefinitions
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
|
|
// TODO: this is where we could inject a non-k8s managed handler... webhook maybe?
|
|
return nil
|
|
}
|
|
|
|
func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
|
|
oas.Info.Description = "Provisioning"
|
|
|
|
root := "/apis/" + b.GetGroupVersion().String() + "/"
|
|
repoprefix := root + "namespaces/{namespace}/repositories/{name}"
|
|
|
|
// TODO: we might want to register some extras for subresources here.
|
|
sub := oas.Paths.Paths[repoprefix+"/hello"]
|
|
if sub != nil && sub.Get != nil {
|
|
sub.Get.Description = "Get a nice hello :)"
|
|
sub.Get.Parameters = []*spec3.Parameter{
|
|
{
|
|
ParameterProps: spec3.ParameterProps{
|
|
Name: "whom",
|
|
In: "query",
|
|
Example: "World!",
|
|
Description: "Who should get the nice greeting?",
|
|
Schema: spec.StringProperty(),
|
|
Required: false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
sub = oas.Paths.Paths[repoprefix+"/webhook"]
|
|
if sub != nil && sub.Get != nil {
|
|
sub.Post.Description = "Currently only supports github webhooks"
|
|
}
|
|
|
|
// hide the version with no path
|
|
delete(oas.Paths.Paths, repoprefix+"/files")
|
|
|
|
// update the version with a path
|
|
sub = oas.Paths.Paths[repoprefix+"/files/{path}"]
|
|
if sub != nil {
|
|
sub.Parameters = append(sub.Parameters, &spec3.Parameter{
|
|
ParameterProps: spec3.ParameterProps{
|
|
Name: "ref",
|
|
In: "query",
|
|
Example: "the ref name of the branch",
|
|
Description: "the commit hash or branch name to look at (writes must be branch names)",
|
|
Schema: spec.StringProperty(),
|
|
Required: false,
|
|
},
|
|
})
|
|
|
|
sub.Get.Description = "Read value from upstream repository"
|
|
sub.Get.Parameters = []*spec3.Parameter{
|
|
{
|
|
ParameterProps: spec3.ParameterProps{
|
|
Name: "commit",
|
|
In: "query",
|
|
Example: "ca171cc730",
|
|
Description: "optional commit hash for the requested file",
|
|
Schema: spec.StringProperty(),
|
|
Required: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add message to the OpenAPI spec
|
|
comment := []*spec3.Parameter{
|
|
{
|
|
ParameterProps: spec3.ParameterProps{
|
|
Name: "message",
|
|
In: "query",
|
|
Example: "My commit message",
|
|
Description: "for git properties this will be in the commit message",
|
|
Schema: spec.StringProperty(),
|
|
Required: false,
|
|
},
|
|
},
|
|
}
|
|
sub.Delete.Parameters = comment
|
|
sub.Post.Parameters = comment
|
|
sub.Put.Parameters = comment
|
|
sub.Post.RequestBody = &spec3.RequestBody{
|
|
RequestBodyProps: spec3.RequestBodyProps{
|
|
Content: map[string]*spec3.MediaType{
|
|
"application/json": {
|
|
MediaTypeProps: spec3.MediaTypeProps{
|
|
Schema: spec.MapProperty(nil),
|
|
Example: &unstructured.Unstructured{},
|
|
Examples: map[string]*spec3.Example{
|
|
"dashboard": {
|
|
ExampleProps: spec3.ExampleProps{
|
|
Value: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"hello": "dashboard",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"playlist": {
|
|
ExampleProps: spec3.ExampleProps{
|
|
Value: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"hello": "playlist",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"application/x-yaml": {
|
|
MediaTypeProps: spec3.MediaTypeProps{
|
|
Schema: spec.MapProperty(nil),
|
|
Example: &unstructured.Unstructured{},
|
|
Examples: map[string]*spec3.Example{
|
|
"dashboard": {
|
|
ExampleProps: spec3.ExampleProps{
|
|
Value: `apiVersion: dashboards.grafana.app/v0alpha1
|
|
kind: Dashboard
|
|
spec:
|
|
title: Sample dashboard
|
|
`},
|
|
},
|
|
"playlist": {
|
|
ExampleProps: spec3.ExampleProps{
|
|
Value: `apiVersion: playlist.grafana.app/v0alpha1
|
|
kind: Playlist
|
|
spec:
|
|
title: Playlist from provisioning
|
|
interval: 5m
|
|
items:
|
|
- type: dashboard_by_tag
|
|
value: panel-tests
|
|
`},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
// POST and put have the same request
|
|
sub.Put.RequestBody = sub.Post.RequestBody
|
|
}
|
|
|
|
// The root API discovery list
|
|
sub = oas.Paths.Paths[root]
|
|
if sub != nil && sub.Get != nil {
|
|
sub.Get.Tags = []string{"API Discovery"} // sorts first in the list
|
|
}
|
|
|
|
return oas, nil
|
|
}
|
|
|
|
// generateWebhookSecret generates a webhook secret from a token
|
|
// using the configured secret key. The generated secret is consistent.
|
|
// TODO: this must be replaced by a app platform secrets once we have that.
|
|
func (b *ProvisioningAPIBuilder) generateWebhookSecret(token string) string {
|
|
secretKey := []byte(b.webhookSecretKey)
|
|
h := hmac.New(sha256.New, secretKey)
|
|
|
|
h.Write([]byte(token))
|
|
hashed := h.Sum(nil)
|
|
|
|
return hex.EncodeToString(hashed)
|
|
}
|