package resources import ( "bytes" "context" "errors" "fmt" "slices" "sync" 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/schema" "k8s.io/apimachinery/pkg/util/validation/field" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/safepath" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/tracing" ) var ( ErrAlreadyInRepository = errors.New("already in repository") ErrDuplicateName = errors.New("duplicate name in repository") ErrMissingName = field.Required(field.NewPath("name", "metadata", "name"), "missing name in resource") ) // NewResourceOwnershipConflictError creates a BadRequest error for when a resource // is owned by a different repository or manager and cannot be modified func NewResourceOwnershipConflictError(resourceName string, currentManager utils.ManagerProperties, requestingManager utils.ManagerProperties) error { message := fmt.Sprintf("resource '%s' is managed by %s '%s' and cannot be modified by %s '%s'", resourceName, currentManager.Kind, currentManager.Identity, requestingManager.Kind, requestingManager.Identity) return apierrors.NewBadRequest(message) } type WriteOptions struct { 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 resourcesLookup map[resourceID]string // the path with this k8s name mu sync.RWMutex } func NewResourcesManager(repo repository.ReaderWriter, folders *FolderManager, parser Parser, clients ResourceClients) *ResourcesManager { return &ResourcesManager{ repo: repo, folders: folders, parser: parser, clients: clients, resourcesLookup: map[resourceID]string{}, } } // findResource checks if a resource exists in the lookup map (read operation) func (r *ResourcesManager) findResource(id resourceID) (string, bool) { r.mu.RLock() defer r.mu.RUnlock() path, found := r.resourcesLookup[id] return path, found } func (r *ResourcesManager) addResource(id resourceID, path string) { r.mu.Lock() defer r.mu.Unlock() if _, found := r.resourcesLookup[id]; found { return } r.resourcesLookup[id] = path } // CheckResourceOwnership validates that the requesting manager can modify the existing resource // Returns an error if the existing resource is owned by a different manager that doesn't allow edits // If existingResource is nil, no ownership conflict exists (new resource) // This is a package-level function that can be used without a ResourcesManager instance func CheckResourceOwnership(existingResource *unstructured.Unstructured, resourceName string, requestingManager utils.ManagerProperties) error { if existingResource == nil { // Resource doesn't exist, so no ownership conflict return nil } // Check if the existing resource has manager properties existingMeta, err := utils.MetaAccessor(existingResource) if err != nil { // If we can't get metadata, allow the operation return nil } currentManager, hasManager := existingMeta.GetManagerProperties() if !hasManager { // No manager information, so no ownership conflict return nil } // Check if this is the same manager if currentManager.Kind == requestingManager.Kind && currentManager.Identity == requestingManager.Identity { // Same manager, no conflict return nil } // Check if the current manager allows edits if currentManager.AllowsEdits { // Manager allows edits from others, no conflict return nil } // Different manager and edits not allowed - return ownership conflict error return NewResourceOwnershipConflictError(resourceName, currentManager, requestingManager) } // CreateResource writes an object to the repository func (r *ResourcesManager) WriteResourceFileFromObject(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" } } name := meta.GetName() if name == "" { return "", ErrMissingName } manager, _ := meta.GetManagerProperties() // TODO: how should we 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 rootFolder := RootFolder(r.repo.Config()) // If no folder is specified in the file, set it to the root to ensure everything is written under it var fid Folder if folder == "" { fid = Folder{ID: rootFolder} meta.SetFolder(rootFolder) // Set the folder in the metadata to the root folder } else { var ok bool fid, ok = r.folders.Tree().DirPath(folder, rootFolder) if !ok { return "", fmt.Errorf("folder %s NOT found in tree with root: %s", folder, rootFolder) } } fileName := slugify.Slugify(title) + ".json" if fid.Path != "" { fileName = safepath.Join(fid.Path, fileName) } if options.Path != "" { fileName = safepath.Join(options.Path, fileName) } parsed := ParsedResource{ Info: &repository.FileInfo{ Path: fileName, Ref: options.Ref, }, Obj: obj, } body, err := parsed.ToSaveBytes() if err != nil { return "", err } err = r.repo.Write(ctx, fileName, options.Ref, body, commitMessage) if err != nil { return "", fmt.Errorf("failed to write file: %s, %w", fileName, err) } return fileName, nil } func (r *ResourcesManager) WriteResourceFromFile(ctx context.Context, path string, ref string) (string, schema.GroupVersionKind, error) { // Read the referenced file readCtx, readSpan := tracing.Start(ctx, "provisioning.resources.write_resource_from_file.read_file") fileInfo, err := r.repo.Read(readCtx, path, ref) if err != nil { readSpan.RecordError(err) readSpan.End() return "", schema.GroupVersionKind{}, fmt.Errorf("failed to read file: %w", err) } readSpan.End() parseCtx, parseSpan := tracing.Start(ctx, "provisioning.resources.write_resource_from_file.parse_file") parsed, err := r.parser.Parse(parseCtx, fileInfo) if err != nil { parseSpan.RecordError(err) parseSpan.End() return "", schema.GroupVersionKind{}, fmt.Errorf("failed to parse file: %w", err) } parseSpan.End() if parsed.Obj.GetName() == "" { return "", schema.GroupVersionKind{}, ErrMissingName } // Check if the resource already exists id := resourceID{ Name: parsed.Obj.GetName(), Resource: parsed.GVR.Resource, Group: parsed.GVK.Group, } if existing, found := r.findResource(id); found { return "", parsed.GVK, fmt.Errorf("duplicate resource name: %s, %s and %s: %w", parsed.Obj.GetName(), path, existing, ErrDuplicateName) } r.addResource(id, path) // For resources that exist in folders, set the header annotation if slices.Contains(SupportsFolderAnnotation, parsed.GVR.GroupResource()) { // Make sure the parent folders exist folderCtx, folderSpan := tracing.Start(ctx, "provisioning.resources.write_resource_from_file.ensure_folder") folder, err := r.folders.EnsureFolderPathExist(folderCtx, path) if err != nil { folderSpan.RecordError(err) folderSpan.End() return "", parsed.GVK, fmt.Errorf("failed to ensure folder path exists: %w", err) } parsed.Meta.SetFolder(folder) folderSpan.End() } // Clear any saved identifiers parsed.Meta.SetUID("") parsed.Meta.SetResourceVersion("") runCtx, runSpan := tracing.Start(ctx, "provisioning.resources.write_resource_from_file.run_resource") err = parsed.Run(runCtx) if err != nil { runSpan.RecordError(err) } runSpan.End() return parsed.Obj.GetName(), parsed.GVK, err } func (r *ResourcesManager) RenameResourceFile(ctx context.Context, previousPath, previousRef, newPath, newRef string) (string, string, schema.GroupVersionKind, error) { name, oldFolderName, gvk, err := r.RemoveResourceFromFile(ctx, previousPath, previousRef) if err != nil { return name, oldFolderName, gvk, fmt.Errorf("failed to remove resource: %w", err) } newName, gvk, err := r.WriteResourceFromFile(ctx, newPath, newRef) if err != nil { return name, oldFolderName, gvk, fmt.Errorf("failed to write resource: %w", err) } return newName, oldFolderName, gvk, nil } func (r *ResourcesManager) RemoveResourceFromFile(ctx context.Context, path string, ref string) (string, string, schema.GroupVersionKind, error) { info, err := r.repo.Read(ctx, path, ref) if err != nil { return "", "", schema.GroupVersionKind{}, fmt.Errorf("failed to read file: %w", err) } obj, gvk, _ := DecodeYAMLObject(bytes.NewBuffer(info.Data)) if obj == nil { return "", "", schema.GroupVersionKind{}, fmt.Errorf("no object found") } objName := obj.GetName() if objName == "" { return "", "", schema.GroupVersionKind{}, ErrMissingName } client, _, err := r.clients.ForKind(ctx, *gvk) if err != nil { return "", "", schema.GroupVersionKind{}, fmt.Errorf("unable to get client for deleted object: %w", err) } // the folder annotation is not stored in the git file, so we need to get it from grafana grafanaObj, err := client.Get(ctx, objName, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { return objName, "", schema.GroupVersionKind{}, nil // Already deleted or simply non-existing, nothing to do } return "", "", schema.GroupVersionKind{}, fmt.Errorf("unable to get grafana object: %w", err) } meta, err := utils.MetaAccessor(grafanaObj) if err != nil { return "", "", schema.GroupVersionKind{}, fmt.Errorf("unable to get meta accessor: %w", err) } folderName := meta.GetFolder() err = client.Delete(ctx, objName, metav1.DeleteOptions{}) if err != nil { if apierrors.IsNotFound(err) { return objName, folderName, schema.GroupVersionKind{}, nil // Already deleted or simply non-existing, nothing to do } return "", "", schema.GroupVersionKind{}, fmt.Errorf("failed to delete: %w", err) } return objName, folderName, schema.GroupVersionKind{}, nil }