Provisioning: Move repository file API (#108514)

* Bump nanogit version
* Embed git repository in the Github one
This commit is contained in:
Roberto Jiménez Sánchez
2025-07-24 23:28:59 -05:00
committed by GitHub
parent 22bcd4dab4
commit 16339b0788
24 changed files with 2011 additions and 99 deletions
@@ -28,11 +28,12 @@ type DualReadWriter struct {
}
type DualWriteOptions struct {
Path string
Ref string
Message string
Data []byte
SkipDryRun bool
Path string
Ref string
Message string
Data []byte
SkipDryRun bool
OriginalPath string // Used for move operations
}
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter {
@@ -278,6 +279,216 @@ func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts D
return parsed, err
}
// MoveResource moves a resource from one path to another in the repository
func (r *DualReadWriter) MoveResource(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
if err := repository.IsWriteAllowed(r.repo.Config(), opts.Ref); err != nil {
return nil, err
}
if opts.OriginalPath == "" {
return nil, fmt.Errorf("originalPath is required for move operations")
}
// Validate that both paths are either files or directories (consistent types)
// Files should end without '/', directories should end with '/'
sourceIsDir := safepath.IsDir(opts.OriginalPath)
targetIsDir := safepath.IsDir(opts.Path)
if sourceIsDir != targetIsDir {
return nil, fmt.Errorf("cannot move between file and directory types - source is %s, target is %s",
getPathType(sourceIsDir), getPathType(targetIsDir))
}
// Handle directory moves separately (no parsing/authorization needed)
if sourceIsDir {
return r.moveDirectory(ctx, opts)
}
// Handle file moves with parsing and authorization
return r.moveFile(ctx, opts)
}
func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// For directory moves, we just perform the repository move without parsing
// Always use the provisioning identity when writing
ctx, _, err := identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace)
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity: %w", err)
}
// Perform the move operation in the repository
if err = r.repo.Move(ctx, opts.OriginalPath, opts.Path, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("move directory in repository: %w", err)
}
// Create a basic parsed resource response for directories
cfg := r.repo.Config()
parsed := &ParsedResource{
Action: provisioning.ResourceActionMove,
Info: &repository.FileInfo{
Path: opts.Path,
Ref: opts.Ref,
},
GVK: schema.GroupVersionKind{
Group: FolderResource.Group,
Version: FolderResource.Version,
Kind: "Folder",
},
GVR: FolderResource,
Repo: provisioning.ResourceRepositoryInfo{
Type: cfg.Spec.Type,
Namespace: cfg.Namespace,
Name: cfg.Name,
Title: cfg.Spec.Title,
},
}
// Handle folder management for main branch
if opts.Ref == "" {
// Ensure destination folder path exists
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure destination folder path exists: %w", err)
}
// Try to delete the old folder structure from grafana (if it exists)
// This handles cleanup when folders are moved to new locations
oldFolderName, err := r.folders.EnsureFolderPathExist(ctx, opts.OriginalPath)
if err != nil {
return nil, fmt.Errorf("ensure original folder path exists: %w", err)
}
if oldFolderName != "" {
oldFolder, err := r.folders.GetFolder(ctx, oldFolderName)
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("get old folder for cleanup: %w", err)
}
if err == nil {
err = r.folders.Client().Delete(ctx, oldFolder.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("delete old folder from storage: %w", err)
}
}
}
}
return parsed, nil
}
func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
// Read the original file to get its content for parsing and authorization
originalFile, err := r.repo.Read(ctx, opts.OriginalPath, "")
if err != nil {
return nil, fmt.Errorf("read original file: %w", err)
}
// Parse the original file to check permissions
parsed, err := r.parser.Parse(ctx, originalFile)
if err != nil {
return nil, fmt.Errorf("parse original file: %w", err)
}
// Authorize delete on the original path
if err = r.authorize(ctx, parsed, utils.VerbDelete); err != nil {
return nil, fmt.Errorf("not authorized to delete original file: %w", err)
}
// Determine the content to use for the destination
// If new content is provided in opts.Data, use it; otherwise use original content
var destinationData []byte
if len(opts.Data) > 0 {
destinationData = opts.Data
} else {
destinationData = originalFile.Data
}
// Create new parsed resource with updated path and content
newInfo := &repository.FileInfo{
Data: destinationData,
Path: opts.Path,
Ref: opts.Ref,
}
newParsed, err := r.parser.Parse(ctx, newInfo)
if err != nil {
return nil, fmt.Errorf("parse new file: %w", err)
}
// Make sure the new resource is valid
if !opts.SkipDryRun {
if err := newParsed.DryRun(ctx); err != nil {
logger := logging.FromContext(ctx).With("path", opts.Path, "originalPath", opts.OriginalPath, "name", newParsed.Obj.GetName(), "ref", opts.Ref)
logger.Warn("failed to dry run resource on move", "error", err)
return nil, fmt.Errorf("error running dryRun on moved resource: %w", err)
}
}
if len(newParsed.Errors) > 0 {
return nil, fmt.Errorf("errors while parsing moved file [%v]", newParsed.Errors)
}
// Authorize create on the new path
verb := utils.VerbCreate
if newParsed.Action == provisioning.ResourceActionUpdate {
verb = utils.VerbUpdate
}
if err = r.authorize(ctx, newParsed, verb); err != nil {
return nil, fmt.Errorf("not authorized to create new file: %w", err)
}
data, err := newParsed.ToSaveBytes()
if err != nil {
return nil, err
}
// Always use the provisioning identity when writing
ctx, _, err = identity.WithProvisioningIdentity(ctx, newParsed.Obj.GetNamespace())
if err != nil {
return nil, fmt.Errorf("unable to use provisioning identity: %w", err)
}
// Perform the move operation in the repository
// If we have new content, we need to update the file content as part of the move
if len(opts.Data) > 0 {
// For moves with content updates, we need to delete the old file and create the new one
if err = r.repo.Delete(ctx, opts.OriginalPath, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("delete original file in repository: %w", err)
}
if err = r.repo.Create(ctx, opts.Path, opts.Ref, data, opts.Message); err != nil {
return nil, fmt.Errorf("create moved file with new content in repository: %w", err)
}
} else {
// For simple moves without content changes, use the move operation
if err = r.repo.Move(ctx, opts.OriginalPath, opts.Path, opts.Ref, opts.Message); err != nil {
return nil, fmt.Errorf("move file in repository: %w", err)
}
}
// Update the grafana database if this is the main branch
if opts.Ref == "" && newParsed.Client != nil {
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
// Delete the old resource from grafana if name changed
if newParsed.Obj.GetName() != parsed.Obj.GetName() {
err = parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{})
if err != nil && !apierrors.IsNotFound(err) {
return nil, fmt.Errorf("delete original resource from storage: %w", err)
}
}
// Create/update the new resource in grafana
err = newParsed.Run(ctx)
if err != nil {
return nil, fmt.Errorf("create moved resource in storage: %w", err)
}
}
newParsed.Action = provisioning.ResourceActionMove
return newParsed, nil
}
func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error {
id, err := identity.GetRequester(ctx)
if err != nil {
@@ -376,6 +587,14 @@ func getFolderURLs(ctx context.Context, path, ref string, repo repository.Reposi
return nil, nil
}
// getPathType returns a human-readable description of the path type
func getPathType(isDir bool) string {
if isDir {
return "directory (ends with '/')"
}
return "file (no trailing '/')"
}
func folderDeleteResponse(ctx context.Context, path, ref string, repo repository.Repository) (*ParsedResource, error) {
urls, err := getFolderURLs(ctx, path, ref, repo)
if err != nil {
@@ -0,0 +1,147 @@
package resources
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
)
func TestGetPathType(t *testing.T) {
tests := []struct {
name string
isDir bool
expected string
}{
{
name: "directory path",
isDir: true,
expected: "directory (ends with '/')",
},
{
name: "file path",
isDir: false,
expected: "file (no trailing '/')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getPathType(tt.isDir)
assert.Equal(t, tt.expected, result)
})
}
}
func TestMovePathValidation(t *testing.T) {
tests := []struct {
name string
originalPath string
newPath string
expectError bool
errorMessage string
}{
{
name: "file to file move (valid)",
originalPath: "old/file.json",
newPath: "new/file.json",
expectError: false,
},
{
name: "directory to directory move (valid)",
originalPath: "old/folder/",
newPath: "new/folder/",
expectError: false,
},
{
name: "file to directory move (invalid)",
originalPath: "old/file.json",
newPath: "new/folder/",
expectError: true,
errorMessage: "cannot move between file and directory types",
},
{
name: "directory to file move (invalid)",
originalPath: "old/folder/",
newPath: "new/file.json",
expectError: true,
errorMessage: "cannot move between file and directory types",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the path validation logic that would be used in MoveResource
sourceIsDir := safepath.IsDir(tt.originalPath)
targetIsDir := safepath.IsDir(tt.newPath)
if tt.expectError {
assert.NotEqual(t, sourceIsDir, targetIsDir, "Path types should be different for invalid moves")
} else {
assert.Equal(t, sourceIsDir, targetIsDir, "Path types should be the same for valid moves")
}
})
}
}
func TestMoveOptionsContentHandling(t *testing.T) {
tests := []struct {
name string
opts DualWriteOptions
originalData []byte
expectedContentToUse []byte
expectedUseOriginal bool
}{
{
name: "move with new content provided",
opts: DualWriteOptions{
Path: "new/file.json",
OriginalPath: "old/file.json",
Data: []byte(`{"updated": "content"}`),
},
originalData: []byte(`{"original": "content"}`),
expectedContentToUse: []byte(`{"updated": "content"}`),
expectedUseOriginal: false,
},
{
name: "move without new content (nil)",
opts: DualWriteOptions{
Path: "new/file.json",
OriginalPath: "old/file.json",
Data: nil,
},
originalData: []byte(`{"original": "content"}`),
expectedContentToUse: []byte(`{"original": "content"}`),
expectedUseOriginal: true,
},
{
name: "move without new content (empty slice)",
opts: DualWriteOptions{
Path: "new/file.json",
OriginalPath: "old/file.json",
Data: []byte{},
},
originalData: []byte(`{"original": "content"}`),
expectedContentToUse: []byte(`{"original": "content"}`),
expectedUseOriginal: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the content selection logic from moveFile method
var destinationData []byte
useOriginal := len(tt.opts.Data) == 0
if useOriginal {
destinationData = tt.originalData
} else {
destinationData = tt.opts.Data
}
assert.Equal(t, tt.expectedUseOriginal, useOriginal, "Should correctly determine whether to use original content")
assert.Equal(t, tt.expectedContentToUse, destinationData, "Should select correct content for destination")
})
}
}