Provisioning: Move repository file API (#108514)
* Bump nanogit version * Embed git repository in the Github one
This commit is contained in:
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user