254 lines
7.1 KiB
Go
254 lines
7.1 KiB
Go
package provisioning
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
apitypes "k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apiserver/pkg/registry/rest"
|
|
"k8s.io/client-go/dynamic"
|
|
|
|
apiutils "github.com/grafana/grafana/pkg/apimachinery/utils"
|
|
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
|
|
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
|
)
|
|
|
|
type exportConnector struct {
|
|
repoGetter RepoGetter
|
|
client *resources.ClientFactory
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func (*exportConnector) New() runtime.Object {
|
|
// This is added as the "ResponseType" regardless what ProducesObject() returns
|
|
return &provisioning.ResourceWrapper{}
|
|
}
|
|
|
|
func (*exportConnector) Destroy() {}
|
|
|
|
func (*exportConnector) NamespaceScoped() bool {
|
|
return true
|
|
}
|
|
|
|
func (*exportConnector) GetSingularName() string {
|
|
return "Export"
|
|
}
|
|
|
|
func (*exportConnector) ProducesMIMETypes(verb string) []string {
|
|
return []string{"application/json"}
|
|
}
|
|
|
|
func (c *exportConnector) ProducesObject(verb string) any {
|
|
return c.New()
|
|
}
|
|
|
|
func (*exportConnector) ConnectMethods() []string {
|
|
return []string{http.MethodPost}
|
|
}
|
|
|
|
func (*exportConnector) NewConnectOptions() (runtime.Object, bool, string) {
|
|
return nil, false, ""
|
|
}
|
|
|
|
func (c *exportConnector) Connect(
|
|
ctx context.Context,
|
|
name string,
|
|
opts runtime.Object,
|
|
responder rest.Responder,
|
|
) (http.Handler, error) {
|
|
logger := c.logger.With("repository_name", name)
|
|
repo, err := c.repoGetter.GetRepository(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ns := repo.Config().GetNamespace()
|
|
|
|
// TODO: We need some way to filter what we export.
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
client, _, err := c.client.New(ns)
|
|
if err != nil {
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to create a dynamic client: %w", err)))
|
|
return
|
|
}
|
|
|
|
dashboardIface := client.Resource(schema.GroupVersionResource{
|
|
Group: "dashboard.grafana.app",
|
|
Version: "v2alpha1",
|
|
Resource: "dashboards",
|
|
})
|
|
folderIface := client.Resource(schema.GroupVersionResource{
|
|
Group: "folder.grafana.app",
|
|
Version: "v0alpha1",
|
|
Resource: "folders",
|
|
})
|
|
|
|
// TODO: handle pagination
|
|
folders, err := c.fetchFolderInfo(ctx, folderIface)
|
|
if err != nil {
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to list folders: %w", err)))
|
|
return
|
|
}
|
|
|
|
// TODO: handle pagination
|
|
dashboardList, err := dashboardIface.List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to list dashboards: %w", err)))
|
|
return
|
|
}
|
|
|
|
for _, item := range dashboardList.Items {
|
|
if ctx.Err() != nil {
|
|
// FIXME: use a proper error code when context is cancelled
|
|
responder.Error(apierrors.NewInternalError(ctx.Err()))
|
|
return
|
|
}
|
|
|
|
logger.InfoContext(ctx, "got item in dashboard list",
|
|
"name", item.GetName(),
|
|
"namespace", item.GetNamespace())
|
|
name := item.GetName()
|
|
if namespace := item.GetNamespace(); namespace != ns {
|
|
logger.DebugContext(ctx, "skipping dashboard in export due to mismatched namespace",
|
|
"name", name,
|
|
"namespace", map[string]string{"expected": ns, "actual": namespace})
|
|
continue
|
|
}
|
|
|
|
folder := folders[item.GetAnnotations()[apiutils.AnnoKeyFolder]]
|
|
|
|
delete(item.Object, "metadata")
|
|
marshalledBody, baseFileName, err := c.marshalPreferredFormat(item.Object, name, repo)
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "failed to marshal dashboard into preferred format",
|
|
"err", err,
|
|
"dashboard", name,
|
|
"namespace", ns)
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to marshal dashboard %s: %w", name, err)))
|
|
return
|
|
}
|
|
|
|
var ref string
|
|
if repo.Config().Spec.Type == provisioning.GitHubRepositoryType {
|
|
ref = repo.Config().Spec.GitHub.Branch
|
|
}
|
|
|
|
fileName := filepath.Join(folder.CreatePath(), baseFileName)
|
|
_, err = repo.Read(ctx, logger, fileName, ref)
|
|
if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to check if file exists before writing: %w", err)))
|
|
return
|
|
} else if err != nil { // ErrFileNotFound
|
|
err = repo.Create(ctx, logger, fileName, ref, marshalledBody, "export of dashboard "+name+" in namespace "+ns)
|
|
} else {
|
|
err = repo.Update(ctx, logger, fileName, ref, marshalledBody, "export of dashboard "+name+" in namespace "+ns)
|
|
}
|
|
if err != nil {
|
|
logger.ErrorContext(ctx, "failed to write dashboard model to repository",
|
|
"err", err,
|
|
"repository", repo.Config().GetName(),
|
|
"dashboard", name,
|
|
"namespace", ns)
|
|
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to write file in repo: %w", err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
responder.Object(http.StatusOK, &provisioning.ResourceWrapper{})
|
|
}), nil
|
|
}
|
|
|
|
type folderExport struct {
|
|
Name string
|
|
UID apitypes.UID
|
|
// Parent is a pointer to a parent folder. Nil if none.
|
|
Parent *folderExport
|
|
}
|
|
|
|
func (e *folderExport) CreatePath() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
|
|
if e.Parent == nil {
|
|
return e.Name
|
|
} else {
|
|
return filepath.Join(e.Parent.CreatePath(), e.Name)
|
|
}
|
|
}
|
|
|
|
func (c *exportConnector) fetchFolderInfo(
|
|
ctx context.Context,
|
|
iface dynamic.ResourceInterface,
|
|
) (map[string]*folderExport, error) {
|
|
folders := make(map[string]*folderExport)
|
|
|
|
// TODO: handle pagination
|
|
rawFolders, err := iface.List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rf := range rawFolders.Items {
|
|
name := rf.GetName()
|
|
uid := rf.GetUID()
|
|
folders[name] = &folderExport{
|
|
Name: name,
|
|
UID: uid,
|
|
}
|
|
}
|
|
|
|
// Double iteration is not great but whatever... probably not too many folders anyhow...
|
|
for _, folder := range folders {
|
|
parents, err := iface.Get(ctx, folder.Name, metav1.GetOptions{}, "parents")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// FIXME: use "items" here when we undo that hack
|
|
uncastItems := parents.Object["infoItems"]
|
|
if uncastItems == nil { // avoid interface conversion panic
|
|
continue
|
|
}
|
|
items := uncastItems.([]any)
|
|
if len(items) == 0 {
|
|
continue
|
|
}
|
|
|
|
// the UID in the returned value is actually the metadata.name!
|
|
parentUid := items[0].(map[string]interface{})["uid"].(string)
|
|
folder.Parent = folders[parentUid]
|
|
}
|
|
|
|
return folders, nil
|
|
}
|
|
|
|
func (c *exportConnector) marshalPreferredFormat(obj any, name string, repo repository.Repository) (body []byte, fileName string, err error) {
|
|
if repo.Config().Spec.PreferYAML {
|
|
body, err = yaml.Marshal(obj)
|
|
return body, name + ".yaml", err
|
|
} else {
|
|
body, err := json.MarshalIndent(obj, "", " ")
|
|
return body, name + ".json", err
|
|
}
|
|
}
|
|
|
|
var (
|
|
_ rest.Connecter = (*exportConnector)(nil)
|
|
_ rest.Storage = (*exportConnector)(nil)
|
|
_ rest.Scoper = (*exportConnector)(nil)
|
|
_ rest.SingularNameProvider = (*exportConnector)(nil)
|
|
_ rest.StorageMetadata = (*exportConnector)(nil)
|
|
)
|