Remote provisioning: consolidate resource operations (#102972)

* Move to new repository

* Rename it to dual writer

* Rename the function

* Rename the methods

* Rename to exportResource

* Clean up logic in migrate and add TODOs

* Add TODOs

* Use generic client for unprovisioned

* ForEachResource

* More consolidation

* Refactor more around client

* Consolidate constants

* ForEachFolder

* More use of constants

* Add FIXME notes

* Use more constant

* Remove Dashboard

* Pass tree to folder manager

* Replicate tree

* Reduce export complexity

* More refactoring

* Use the ForEach for loading users

* Limit in-memory folders

* Isolate the object

* Improve the export function

* Move resources to resources package

* Move delete operation

* Move more logic

* More consolidation

* More renaming

* Fix more issues

* Ensure path exists when created a resource

* Simply append error

* Fix receiver lint issue

* Fix cyclomatic complexity

* Fix linting

* Remove folder path creation
This commit is contained in:
Roberto Jiménez Sánchez
2025-03-31 14:27:46 +02:00
committed by GitHub
parent 5668ab9676
commit bb344fcd83
21 changed files with 1104 additions and 1123 deletions
@@ -5,10 +5,13 @@ import (
"fmt"
"sync"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
folders "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
iam "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver"
@@ -142,29 +145,105 @@ func (c *ResourceClients) ForResource(gvr schema.GroupVersionResource) (dynamic.
return info.client, info.gvk, nil
}
func (c *ResourceClients) Folder() (dynamic.ResourceInterface, error) {
v, _, err := c.ForResource(schema.GroupVersionResource{
Group: folders.GROUP,
Version: folders.VERSION,
Resource: folders.RESOURCE,
// ForEachResource applies the function to each resource in the discovery client
func (c *ResourceClients) ForEachResource(ctx context.Context, kind schema.GroupVersionResource, fn func(client dynamic.ResourceInterface, item *unstructured.Unstructured) error) error {
client, _, err := c.ForResource(kind)
if err != nil {
return err
}
return ForEachResource(ctx, client, func(item *unstructured.Unstructured) error {
return fn(client, item)
})
return v, err
}
// ForEachResource applies the function to each resource in the discovery client
func ForEachResource(ctx context.Context, client dynamic.ResourceInterface, fn func(item *unstructured.Unstructured) error) error {
var continueToken string
for ctx.Err() == nil {
list, err := client.List(ctx, metav1.ListOptions{Limit: 100, Continue: continueToken})
if err != nil {
return fmt.Errorf("error executing list: %w", err)
}
for _, item := range list.Items {
if ctx.Err() != nil {
return ctx.Err()
}
if err := fn(&item); err != nil {
return err
}
}
continueToken = list.GetContinue()
if continueToken == "" {
break
}
}
return nil
}
// ForEachUnmanagedResource applies the function to each unprovisioned supported resource
func (c *ResourceClients) ForEachUnmanagedResource(ctx context.Context, fn func(client dynamic.ResourceInterface, item *unstructured.Unstructured) error) error {
return c.ForEachSupportedResource(ctx, func(client dynamic.ResourceInterface, item *unstructured.Unstructured) error {
meta, err := utils.MetaAccessor(item)
if err != nil {
return fmt.Errorf("extract meta accessor: %w", err)
}
// Skip if managed
_, ok := meta.GetManagerProperties()
if ok {
return nil
}
return fn(client, item)
})
}
// ForEachSupportedResource applies the function to each supported resource
func (c *ResourceClients) ForEachSupportedResource(ctx context.Context, fn func(client dynamic.ResourceInterface, item *unstructured.Unstructured) error) error {
for _, kind := range SupportedResources {
if err := c.ForEachResource(ctx, kind, fn); err != nil {
return err
}
}
return nil
}
func (c *ResourceClients) Folder() (dynamic.ResourceInterface, error) {
client, _, err := c.ForResource(FolderResource)
return client, err
}
func (c *ResourceClients) ForEachFolder(ctx context.Context, fn func(client dynamic.ResourceInterface, item *unstructured.Unstructured) error) error {
return c.ForEachResource(ctx, FolderResource, fn)
}
func (c *ResourceClients) User() (dynamic.ResourceInterface, error) {
v, _, err := c.ForResource(schema.GroupVersionResource{
Group: iam.GROUP,
Version: iam.VERSION,
Resource: iam.UserResourceInfo.GroupResource().Resource,
})
v, _, err := c.ForResource(UserResource)
return v, err
}
func (c *ResourceClients) Dashboard() (dynamic.ResourceInterface, error) {
v, _, err := c.ForResource(schema.GroupVersionResource{
Group: dashboard.GROUP,
Version: dashboard.VERSION,
Resource: dashboard.DASHBOARD_RESOURCE,
})
return v, err
var UserResource = schema.GroupVersionResource{
Group: iam.GROUP,
Version: iam.VERSION,
Resource: iam.UserResourceInfo.GroupResource().Resource,
}
var FolderResource = schema.GroupVersionResource{
Group: folders.GROUP,
Version: folders.VERSION,
Resource: folders.RESOURCE,
}
var DashboardResource = schema.GroupVersionResource{
Group: dashboard.GROUP,
Version: dashboard.VERSION,
Resource: dashboard.DASHBOARD_RESOURCE,
}
// SupportedResources is the list of resources that are supported by provisioning
var SupportedResources = []schema.GroupVersionResource{FolderResource, DashboardResource}
@@ -0,0 +1,276 @@
package resources
import (
"context"
"errors"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
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/safepath"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DualReadWriter is a wrapper around a repository that can read and write resources
// TODO: it does not support folders yet
type DualReadWriter struct {
repo repository.ReaderWriter
parser *Parser
folders *FolderManager
}
func NewDualReadWriter(repo repository.ReaderWriter, parser *Parser, folders *FolderManager) *DualReadWriter {
return &DualReadWriter{repo: repo, parser: parser, folders: folders}
}
func (r *DualReadWriter) Read(ctx context.Context, path string, ref string) (*ParsedResource, error) {
// TODO: implement this
if safepath.IsDir(path) {
return nil, fmt.Errorf("folder read not supported")
}
info, err := r.repo.Read(ctx, path, ref)
if err != nil {
return nil, err
}
parsed, err := r.parser.Parse(ctx, info, false)
if err != nil {
return nil, err
}
// GVR will exist for anything we can actually save
// TODO: Add known error in parser for unsupported resource
if parsed.GVR == nil {
if parsed.GVK != nil {
//nolint:govet
parsed.Errors = append(parsed.Errors, fmt.Errorf("unknown resource for Kind: %s", parsed.GVK.Kind))
} else {
parsed.Errors = append(parsed.Errors, fmt.Errorf("unknown resource"))
}
}
return parsed, nil
}
func (r *DualReadWriter) Delete(ctx context.Context, path string, ref string, message string) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil {
return nil, err
}
// TODO: implement this
if safepath.IsDir(path) {
return nil, fmt.Errorf("folder delete not supported")
}
file, err := r.repo.Read(ctx, path, ref)
if err != nil {
return nil, err // unable to read value
}
// TODO: document in API specification
// We can only delete parsable things
parsed, err := r.parser.Parse(ctx, file, false)
if err != nil {
return nil, err // unable to read value
}
parsed.Action = provisioning.ResourceActionDelete
err = r.repo.Delete(ctx, path, ref, message)
if err != nil {
return nil, fmt.Errorf("delete file from repository: %w", err)
}
// Delete the file in the grafana database
if ref == "" {
err = parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if apierrors.IsNotFound(err) {
err = nil // ignorable
}
if err != nil {
return nil, fmt.Errorf("delete resource from storage: %w", err)
}
}
return parsed, err
}
// CreateFolder creates a new folder in the repository
// FIXME: fix signature to return ParsedResource
func (r *DualReadWriter) CreateFolder(ctx context.Context, path string, ref string, message string) (*provisioning.ResourceWrapper, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil {
return nil, err
}
if !safepath.IsDir(path) {
return nil, fmt.Errorf("not a folder path")
}
// Now actually create the folder
if err := r.repo.Create(ctx, path, ref, nil, message); err != nil {
return nil, fmt.Errorf("failed to create folder: %w", err)
}
cfg := r.repo.Config()
wrap := &provisioning.ResourceWrapper{
Path: path,
Ref: ref,
Repository: provisioning.ResourceRepositoryInfo{
Type: cfg.Spec.Type,
Namespace: cfg.Namespace,
Name: cfg.Name,
Title: cfg.Spec.Title,
},
Resource: provisioning.ResourceObjects{
Action: provisioning.ResourceActionCreate,
},
}
if ref == "" {
folderName, err := r.folders.EnsureFolderPathExist(ctx, path)
if err != nil {
return nil, err
}
current, err := r.folders.GetFolder(ctx, folderName)
if err != nil && !apierrors.IsNotFound(err) {
return nil, err // unable to check if the folder exists
}
wrap.Resource.Upsert = v0alpha1.Unstructured{
Object: current.Object,
}
}
return wrap, nil
}
// CreateResource creates a new resource in the repository
func (r *DualReadWriter) CreateResource(ctx context.Context, path string, ref string, message string, data []byte) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil {
return nil, err
}
info := &repository.FileInfo{
Data: data,
Path: path,
Ref: ref,
}
// TODO: improve parser to parse out of reader
parsed, err := r.parser.Parse(ctx, info, true)
if err != nil {
if errors.Is(err, ErrUnableToReadResourceBytes) {
return nil, apierrors.NewBadRequest("unable to read the request as a resource")
}
return nil, err
}
// GVR will exist for anything we can actually save
// TODO: Add known error in parser for unsupported resource
if parsed.GVR == nil {
return nil, apierrors.NewBadRequest("The payload does not map to a known resource")
}
// Do not write if any errors exist
if len(parsed.Errors) > 0 {
return parsed, err
}
data, err = parsed.ToSaveBytes()
if err != nil {
return nil, err
}
err = r.repo.Create(ctx, path, ref, data, message)
if err != nil {
return nil, fmt.Errorf("create resource in repository: %w", err)
}
// Directly update the grafana database
// Behaves the same running sync after writing
if ref == "" {
// FIXME: we are not creating the folder path
// TODO: will existing also be present here? for update?
if parsed.Existing == nil {
parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{})
if err != nil {
parsed.Errors = append(parsed.Errors, err)
}
} else {
parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
if err != nil {
parsed.Errors = append(parsed.Errors, err)
}
}
}
return parsed, err
}
// UpdateResource updates a resource in the repository
func (r *DualReadWriter) UpdateResource(ctx context.Context, path string, ref string, message string, data []byte) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), ref); err != nil {
return nil, err
}
info := &repository.FileInfo{
Data: data,
Path: path,
Ref: ref,
}
// TODO: improve parser to parse out of reader
parsed, err := r.parser.Parse(ctx, info, true)
if err != nil {
if errors.Is(err, ErrUnableToReadResourceBytes) {
return nil, apierrors.NewBadRequest("unable to read the request as a resource")
}
return nil, err
}
// GVR will exist for anything we can actually save
// TODO: Add known error in parser for unsupported resource
if parsed.GVR == nil {
return nil, apierrors.NewBadRequest("The payload does not map to a known resource")
}
// Do not write if any errors exist
if len(parsed.Errors) > 0 {
return parsed, err
}
data, err = parsed.ToSaveBytes()
if err != nil {
return nil, err
}
err = r.repo.Update(ctx, path, ref, data, message)
if err != nil {
return nil, fmt.Errorf("update resource in repository: %w", err)
}
// Directly update the grafana database
// Behaves the same running sync after writing
if ref == "" {
// FIXME: we are not creating the folder path
// FIXME: I don't like this parsed strategy here
if parsed.Existing == nil {
parsed.Upsert, err = parsed.Client.Create(ctx, parsed.Obj, metav1.CreateOptions{})
if err != nil {
parsed.Errors = append(parsed.Errors, err)
}
} else {
parsed.Upsert, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
if err != nil {
parsed.Errors = append(parsed.Errors, err)
}
}
}
return parsed, err
}
@@ -2,6 +2,7 @@ package resources
import (
"context"
"errors"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -15,16 +16,18 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
const maxFolders = 10000
type FolderManager struct {
repo repository.Repository
lookup *FolderTree
repo repository.ReaderWriter
tree *FolderTree
client dynamic.ResourceInterface
}
func NewFolderManager(repo repository.Repository, client dynamic.ResourceInterface) *FolderManager {
func NewFolderManager(repo repository.ReaderWriter, client dynamic.ResourceInterface, lookup *FolderTree) *FolderManager {
return &FolderManager{
repo: repo,
lookup: NewEmptyFolderTree(),
tree: lookup,
client: client,
}
}
@@ -33,6 +36,14 @@ func (fm *FolderManager) Client() dynamic.ResourceInterface {
return fm.client
}
func (fm *FolderManager) Tree() *FolderTree {
return fm.tree
}
func (fm *FolderManager) SetTree(tree *FolderTree) {
fm.tree = tree
}
// EnsureFoldersExist creates the folder structure in the cluster.
func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath string) (parent string, err error) {
cfg := fm.repo.Config()
@@ -48,13 +59,13 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str
}
f := ParseFolder(dir, cfg.Name)
if fm.lookup.In(f.ID) {
if fm.tree.In(f.ID) {
return f.ID, nil
}
err = safepath.Walk(ctx, f.Path, func(ctx context.Context, traverse string) error {
f := ParseFolder(traverse, cfg.GetName())
if fm.lookup.In(f.ID) {
if fm.tree.In(f.ID) {
parent = f.ID
return nil
}
@@ -63,7 +74,7 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str
return fmt.Errorf("ensure folder exists: %w", err)
}
fm.lookup.Add(f, parent)
fm.tree.Add(f, parent)
parent = f.ID
return nil
})
@@ -131,3 +142,40 @@ func (fm *FolderManager) EnsureFolderExists(ctx context.Context, folder Folder,
func (fm *FolderManager) GetFolder(ctx context.Context, name string) (*unstructured.Unstructured, error) {
return fm.client.Get(ctx, name, metav1.GetOptions{})
}
// ReplicateTree replicates the folder tree to the repository.
// The function fn is called for each folder.
// If the folder already exists, the function is called with created set to false.
// If the folder is created, the function is called with created set to true.
func (fm *FolderManager) EnsureTreeExists(ctx context.Context, ref, path string, fn func(folder Folder, created bool, err error) error) error {
return fm.tree.Walk(ctx, func(ctx context.Context, folder Folder) error {
p := folder.Path
if path != "" {
p = safepath.Join(path, p)
}
_, err := fm.repo.Read(ctx, p, ref)
if err != nil && !(errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) {
return fn(folder, false, fmt.Errorf("check if folder exists before writing: %w", err))
} else if err == nil {
return fn(folder, false, nil)
}
msg := fmt.Sprintf("Add folder %s", p)
if err := fm.repo.Create(ctx, p, ref, nil, msg); err != nil {
return fn(folder, true, fmt.Errorf("write folder in repo: %w", err))
}
return fn(folder, true, nil)
})
}
func (fm *FolderManager) LoadFromServer(ctx context.Context) error {
return ForEachResource(ctx, fm.client, func(item *unstructured.Unstructured) error {
if fm.tree.Count() > maxFolders {
return errors.New("too many folders")
}
return fm.tree.AddUnstructured(item, fm.repo.Config().Name)
})
}
@@ -104,6 +104,8 @@ type ParsedResource struct {
Errors []error
}
// FIXME: eliminate clients from parser
func (r *Parser) Clients() *ResourceClients {
return r.clients
}
@@ -0,0 +1,229 @@
package resources
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var ErrAlreadyInRepository = errors.New("already in repository")
type WriteOptions struct {
Identifier bool
Path string
Ref string
}
type resourceID struct {
Name string
Resource string
Group string
}
type ResourcesManager struct {
repo repository.ReaderWriter
folders *FolderManager
parser *Parser
clients *ResourceClients
userInfo map[string]repository.CommitSignature
resourcesLookup map[resourceID]string // the path with this k8s name
}
func NewResourcesManager(repo repository.ReaderWriter, folders *FolderManager, parser *Parser, clients *ResourceClients, userInfo map[string]repository.CommitSignature) *ResourcesManager {
return &ResourcesManager{
repo: repo,
folders: folders,
parser: parser,
clients: clients,
userInfo: userInfo,
resourcesLookup: map[resourceID]string{},
}
}
// CreateResource writes an object to the repository
func (r *ResourcesManager) CreateResourceFileFromObject(ctx context.Context, obj *unstructured.Unstructured, options WriteOptions) (string, error) {
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("context error: %w", err)
}
meta, err := utils.MetaAccessor(obj)
if err != nil {
return "", fmt.Errorf("extract meta accessor: %w", err)
}
// Message from annotations
commitMessage := meta.GetMessage()
if commitMessage == "" {
g := meta.GetGeneration()
if g > 0 {
commitMessage = fmt.Sprintf("Generation: %d", g)
} else {
commitMessage = "exported from grafana"
}
}
ctx = r.withAuthorSignature(ctx, meta)
name := meta.GetName()
manager, _ := meta.GetManagerProperties()
// TODO: how we should handle this?
if manager.Identity == r.repo.Config().GetName() {
// If it's already in the repository, we don't need to write it
return "", ErrAlreadyInRepository
}
title := meta.FindTitle("")
if title == "" {
title = name
}
folder := meta.GetFolder()
// Get the absolute path of the folder
fid, ok := r.folders.Tree().DirPath(folder, "")
if !ok {
// FIXME: Shouldn't this fail instead?
fid = Folder{
Path: "__folder_not_found/" + slugify.Slugify(folder),
}
// r.logger.Error("folder of item was not in tree of repository")
}
// Clear the metadata
delete(obj.Object, "metadata")
if options.Identifier {
meta.SetName(name) // keep the identifier in the metadata
}
body, err := json.MarshalIndent(obj.Object, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal dashboard: %w", err)
}
fileName := slugify.Slugify(title) + ".json"
if fid.Path != "" {
fileName = safepath.Join(fid.Path, fileName)
}
if options.Path != "" {
fileName = safepath.Join(options.Path, fileName)
}
err = r.repo.Write(ctx, fileName, options.Ref, body, commitMessage)
if err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return fileName, nil
}
func (r *ResourcesManager) WriteResourceFromFile(ctx context.Context, path string, ref string) (string, *schema.GroupVersionKind, error) {
// Read the referenced file
fileInfo, err := r.repo.Read(ctx, path, ref)
if err != nil {
return "", nil, fmt.Errorf("failed to read file: %w", err)
}
parsed, err := r.parser.Parse(ctx, fileInfo, false) // no validation
if err != nil {
return "", nil, fmt.Errorf("failed to parse file: %w", err)
}
// Check if the resource already exists
id := resourceID{
Name: parsed.Obj.GetName(),
Resource: parsed.GVR.Resource,
Group: parsed.GVK.Group,
}
existing, found := r.resourcesLookup[id]
if found {
return "", parsed.GVK, fmt.Errorf("duplicate resource name: %s, %s and %s", parsed.Obj.GetName(), path, existing)
}
r.resourcesLookup[id] = path
// Make sure the parent folders exist
folder, err := r.folders.EnsureFolderPathExist(ctx, path)
if err != nil {
return "", parsed.GVK, fmt.Errorf("failed to ensure folder path exists: %w", err)
}
parsed.Meta.SetFolder(folder)
parsed.Meta.SetUID("") // clear identifiers
parsed.Meta.SetResourceVersion("") // clear identifiers
// Update will also create (for resources we care about)
_, err = parsed.Client.Update(ctx, parsed.Obj, metav1.UpdateOptions{})
return parsed.Obj.GetName(), parsed.GVK, err
}
func (r *ResourcesManager) RenameResourceFile(ctx context.Context, previousPath, previousRef, newPath, newRef string) (string, *schema.GroupVersionKind, error) {
name, gvk, err := r.RemoveResourceFromFile(ctx, previousPath, previousRef)
if err != nil {
return name, gvk, fmt.Errorf("failed to remove resource: %w", err)
}
return r.WriteResourceFromFile(ctx, newPath, newRef)
}
func (r *ResourcesManager) RemoveResourceFromFile(ctx context.Context, path string, ref string) (string, *schema.GroupVersionKind, error) {
info, err := r.repo.Read(ctx, path, ref)
if err != nil {
return "", nil, fmt.Errorf("failed to read file: %w", err)
}
obj, gvk, _ := DecodeYAMLObject(bytes.NewBuffer(info.Data))
if obj == nil {
return "", nil, fmt.Errorf("no object found")
}
objName := obj.GetName()
if objName == "" {
// Find the referenced file
objName, _ = NamesFromHashedRepoPath(r.repo.Config().Name, path)
}
client, _, err := r.clients.ForKind(*gvk)
if err != nil {
return "", nil, fmt.Errorf("unable to get client for deleted object: %w", err)
}
err = client.Delete(ctx, objName, metav1.DeleteOptions{})
if err != nil {
return "", nil, fmt.Errorf("failed to delete: %w", err)
}
return objName, gvk, nil
}
func (r *ResourcesManager) withAuthorSignature(ctx context.Context, item utils.GrafanaMetaAccessor) context.Context {
id := item.GetUpdatedBy()
if id == "" {
id = item.GetCreatedBy()
}
if id == "" {
id = "grafana"
}
sig := r.userInfo[id] // lookup
if sig.Name == "" && sig.Email == "" {
sig.Name = id
}
t, err := item.GetUpdatedTimestamp()
if err == nil && t != nil {
sig.When = *t
} else {
sig.When = item.GetCreationTimestamp().Time
}
return repository.WithAuthorSignature(ctx, sig)
}
@@ -19,6 +19,7 @@ import (
type FolderTree struct {
tree map[string]string
folders map[string]Folder
count int
}
// In determines if the given folder is in the tree at all. That is, it answers "does the folder even exist in the Grafana instance?"
@@ -66,6 +67,11 @@ func (t *FolderTree) DirPath(folder, baseFolder string) (fid Folder, ok bool) {
func (t *FolderTree) Add(folder Folder, parent string) {
t.tree[folder.ID] = parent
t.folders[folder.ID] = folder
t.count++
}
func (t *FolderTree) Count() int {
return t.count
}
type WalkFunc func(ctx context.Context, folder Folder) error
@@ -113,6 +119,7 @@ func (t *FolderTree) AddUnstructured(item *unstructured.Unstructured, skipRepo s
}
t.tree[folder.ID] = meta.GetFolder()
t.folders[folder.ID] = folder
t.count++
return nil
}
@@ -133,7 +140,8 @@ func NewFolderTreeFromResourceList(resources *provisioning.ResourceList) *Folder
}
return &FolderTree{
tree,
folderIDs,
tree: tree,
folders: folderIDs,
count: len(resources.Items),
}
}