diff --git a/pkg/registry/apis/provisioning/jobs/exporter.go b/pkg/registry/apis/provisioning/jobs/exporter.go index c8c5be179b2..d4b7da55173 100644 --- a/pkg/registry/apis/provisioning/jobs/exporter.go +++ b/pkg/registry/apis/provisioning/jobs/exporter.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "path/filepath" + "path" "gopkg.in/yaml.v3" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -69,8 +69,8 @@ func (r *exporter) Export(ctx context.Context, if err != nil { return nil, err } - // TODO: handle pagination - folders, err := r.fetchRepoFolderTree(ctx) + + folders, err := resources.FetchRepoFolderTree(ctx, r.client) if err != nil { return nil, fmt.Errorf("failed to list folders: %w", err) } @@ -104,7 +104,8 @@ func (r *exporter) Export(ctx context.Context, folder := item.GetAnnotations()[apiutils.AnnoKeyFolder] logger = logger.With("folder", folder) - if !folders.In(folder) { + fid, ok := folders.DirPath(folder, r.repository.Config().Spec.Folder) + if !ok { logger.Debug("folder of item was not in tree of repository") continue } @@ -114,7 +115,7 @@ func (r *exporter) Export(ctx context.Context, if err != nil { return nil, fmt.Errorf("failed to marshal dashboard %s: %w", name, err) } - fileName := filepath.Join(folders.DirPath(folder), name+".yaml") + fileName := path.Join(fid.Path, name+".yaml") logger = logger.With("file", fileName) var ref string @@ -143,54 +144,3 @@ func (r *exporter) Export(ctx context.Context, State: provisioning.JobStateSuccess, }, nil } - -func (r *exporter) fetchRepoFolderTree(ctx context.Context) (*folderTree, error) { - iface := r.client.Resource(schema.GroupVersionResource{ - Group: "folder.grafana.app", - Version: "v0alpha1", - Resource: "folders", - }) - - // TODO: handle pagination - rawFolders, err := iface.List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err - } - - folders := make(map[string]string, len(rawFolders.Items)) - for _, rf := range rawFolders.Items { - name := rf.GetName() - // TODO: Can I use MetaAccessor here? - parent := rf.GetAnnotations()[apiutils.AnnoKeyFolder] - folders[name] = parent - } - - // folders now includes a map[folder name]parent name - // The top-most folder has a parent of "". Any folders below have parent refs. - // We want to find only folders which are or start in repoFolder. - repoFolder := r.repository.Config().Spec.Folder - for folder, parent := range folders { - if folder == repoFolder { - continue - } - - hasRepoRoot := false - for parent != "" { - if parent == repoFolder { - hasRepoRoot = true - break - } - parent = folders[parent] - } - if !hasRepoRoot { - delete(folders, folder) - } - } - - // folders now only includes the tree of the repoFolder. - - return &folderTree{ - tree: folders, - repoFolder: repoFolder, - }, nil -} diff --git a/pkg/registry/apis/provisioning/jobs/syncer.go b/pkg/registry/apis/provisioning/jobs/syncer.go index 7f0185cb026..3e217217dde 100644 --- a/pkg/registry/apis/provisioning/jobs/syncer.go +++ b/pkg/registry/apis/provisioning/jobs/syncer.go @@ -390,20 +390,21 @@ func (r *Syncer) createFolderPath(ctx context.Context, filePath string) (string, } currentPath = path.Join(currentPath, folder) + folderID := resources.ParseFolder(currentPath, r.repository.Config().GetName()) - logger := logger.With("folder", folder) - obj, err := r.folders.Get(ctx, folder, metav1.GetOptions{}) + logger := logger.With("folder", currentPath) + obj, err := r.folders.Get(ctx, folderID.ID, metav1.GetOptions{}) // FIXME: Check for IsNotFound properly if obj != nil || err == nil { logger.Debug("folder already existed") - parent = folder + parent = folderID.ID continue } obj = &unstructured.Unstructured{ Object: map[string]interface{}{ "spec": map[string]any{ - "title": folder, // TODO: how do we want to get this? + "title": folderID.Title, "description": "Repository-managed folder.", }, }, @@ -415,7 +416,7 @@ func (r *Syncer) createFolderPath(ctx context.Context, filePath string) (string, } obj.SetNamespace(r.client.GetNamespace()) - obj.SetName(folder) + obj.SetName(folderID.ID) meta.SetFolder(parent) meta.SetRepositoryInfo(&utils.ResourceRepositoryInfo{ Name: r.repository.Config().Name, @@ -426,10 +427,10 @@ func (r *Syncer) createFolderPath(ctx context.Context, filePath string) (string, _, err = r.folders.Create(ctx, obj, metav1.CreateOptions{}) if err != nil { - return parent, fmt.Errorf("failed to create folder '%s': %w", folder, err) + return parent, fmt.Errorf("failed to create folder '%s': %w", folderID.ID, err) } - parent = folder + parent = folderID.ID logger.Info("folder created") } diff --git a/pkg/registry/apis/provisioning/jobs/utils.go b/pkg/registry/apis/provisioning/jobs/utils.go deleted file mode 100644 index b51a5ba514a..00000000000 --- a/pkg/registry/apis/provisioning/jobs/utils.go +++ /dev/null @@ -1,34 +0,0 @@ -package jobs - -import "path" - -type folderTree struct { - tree map[string]string - repoFolder string -} - -func (t *folderTree) In(folder string) bool { - _, ok := t.tree[folder] - return ok -} - -// DirPath creates the path to the directory with slashes. -// The repository folder is not included in the path. -// If In(folder) is false, this will panic, because it would be undefined behaviour. -func (t *folderTree) DirPath(folder string) string { - if folder == t.repoFolder { - return "" - } - if !t.In(folder) { - panic("undefined behaviour") - } - - dirPath := folder - parent := t.tree[folder] - for parent != "" && parent != t.repoFolder { - dirPath = path.Join(parent, dirPath) - parent = t.tree[parent] - } - // Not using Clean here is intentional. We don't want `.` or similar. - return dirPath -} diff --git a/pkg/registry/apis/provisioning/jobs/utils_test.go b/pkg/registry/apis/provisioning/jobs/utils_test.go deleted file mode 100644 index a55f35e2d54..00000000000 --- a/pkg/registry/apis/provisioning/jobs/utils_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package jobs - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFolderTree(t *testing.T) { - t.Run("empty tree", func(t *testing.T) { - tree := &folderTree{ - tree: map[string]string{"x": ""}, - repoFolder: "x", - } - - assert.True(t, tree.In("x")) - assert.False(t, tree.In("z")) - assert.Empty(t, tree.DirPath("x")) - assert.PanicsWithValue(t, "undefined behaviour", func() { tree.DirPath("z") }, "unknown folders cannot have a DirPath result") - }) - - t.Run("simple tree", func(t *testing.T) { - tree := &folderTree{ - tree: map[string]string{"a": "b", "b": "c", "c": "x", "x": ""}, - repoFolder: "x", - } - - assert.True(t, tree.In("x")) - assert.True(t, tree.In("a")) - assert.False(t, tree.In("z")) - assert.Empty(t, tree.DirPath("x")) - assert.Equal(t, "c", tree.DirPath("c")) - assert.Equal(t, "c/b/a", tree.DirPath("a")) - }) -} diff --git a/pkg/registry/apis/provisioning/resources/id.go b/pkg/registry/apis/provisioning/resources/id.go new file mode 100644 index 00000000000..375463ff26b --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/id.go @@ -0,0 +1,155 @@ +package resources + +import ( + "crypto/sha256" + "encoding/base64" + "path" + "strings" +) + +// sanitiseKubeName removes all characters that don't fulfil the DNS subdomain name rules: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names +// > contain no more than 253 characters +// > contain only lowercase alphanumeric characters, '-' or '.' +// > start with an alphanumeric character +// > end with an alphanumeric character +// +// If no characters are valid, this returns an empty string. +func sanitiseKubeName(s string) string { + // Note: Builder never returns an error. + var b strings.Builder + lastHyphen := false // Having at most 1 hyphen in a row is not a requirement, but is closer to standard convention. + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' { + // We don't want to have multiple hyphens following one another. + if !lastHyphen || r != '-' { + lastHyphen = r == '-' + _, _ = b.WriteRune(r) + } + } else if r >= 'A' && r <= 'Z' { + lastHyphen = false + r += 'a' - 'A' + _, _ = b.WriteRune(r) + } else if (r == '_' || r == '/') && !lastHyphen { // Special-case for common characters that we still want to keep somehow + lastHyphen = true + _, _ = b.WriteRune('-') + } + // Else, skip it, silently. + + if b.Len() == 253 { + // Technically, we could be less than 253 after some more cleaning... but this is good enough. + break + } + } + str := b.String() + // We must start and end with alphanumerics. + str = strings.Trim(str, "-") + str = strings.Trim(str, ".") + + return str +} + +// appendHashSuffix creates a function that modifies an input string to include a hash suffix. +// The goal of this function is to represent all needs for hashing IDs. +// +// The hash uses the input hashKey and repositoryName as a salt. +// The output string is at most 253 characters long. +// The output string is a valid Kubernetes name (see [sanitiseKubeName]). +// The output string contains at least 12 characters of a hash, plus a 1 character hyphen to separate the input and the hash. +// The function is deterministic given the same invocation parameters to this function. +func appendHashSuffix(hashKey, repositoryName string) func(string) string { + salt := []byte(repositoryName + "/" + hashKey) + + const maxLen = 253 // valid Kubernetes name + const minSuffix = 12 // excluding hyphen + const minSpace = minSuffix + 1 // +1 for hyphen + + return func(s string) string { + hasher := sha256.New() + // From hash.Hash docs: + // > Write (via the embedded io.Writer interface) adds more data to the running hash. + // > It never returns an error. + // As such, we ignore all errors. + _, _ = hasher.Write(salt) // Input to the parent function + _, _ = hasher.Write([]byte(s)) + hash := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) + hash = sanitiseKubeName(hash) // We have rules to follow, as per our doc contract + + if len(s) > maxLen-minSpace { + s = s[:maxLen-minSpace] + } + + spaceForHash := maxLen - len(s) - 1 + if spaceForHash < len(hash) { + hash = hash[:spaceForHash] + } + return sanitiseKubeName(s + "-" + hash) + } +} + +// Will pick a name based on the hashed repository and path +func NamesFromHashedRepoPath(repo string, fpath string) (objectName string, folderName string) { + fpath = strings.Trim(fpath, "/") // remove trailing and leading slashes + fpath = path.Clean(fpath) // get rid of path traversals etc + + // Remove the extension: we don't want the extension to impact the ID. This lets the user change between all supported formats. + idx := strings.LastIndex(fpath, ".") + if idx > 0 { // we don't want to remove the dot if the filename is e.g. `.gitignore` + fpath = fpath[0:idx] + } + + hasher := appendHashSuffix(fpath, repo) + objectName = hasher(path.Base(fpath)) + + idx = strings.LastIndex(fpath, "/") + if idx > 0 { + folderName = hasher(fpath[0:idx]) + } + return +} + +// Folder contains the data for a folder we use in provisioning. +type Folder struct { + // Title is the human-readable name created by a human who wrote it. + Title string + // ID represents the name the folder should have, derived from the title. + // It contains a suffix calculated from the path of the folder. + // The ID is used in Kubernetes and the folders API server. This is the same as the legacy (and by the time you read this, hopefully removed) UID concept of folders. + ID string + // Path is the full path to the folder, as given to the parse function. + Path string +} + +func ParseFolder(dirPath, repositoryName string) Folder { + clean := strings.Trim(path.Clean(dirPath), "/") + return Folder{ + Title: folderTitleFromPath(clean), + ID: idFromPath(clean, repositoryName), + Path: dirPath, + } +} + +func folderTitleFromPath(cleanDirPath string) string { + titleIdx := strings.LastIndex(cleanDirPath, "/") + title := cleanDirPath + if titleIdx != -1 { + title = title[titleIdx+1:] + } + return title +} + +func idFromPath(cleanDirPath, repositoryName string) string { + suffixer := appendHashSuffix(cleanDirPath, repositoryName) + + kubernetesName := stringAfterLastSep(cleanDirPath, "/") + kubernetesName = sanitiseKubeName(kubernetesName) + + return suffixer(kubernetesName) +} + +func stringAfterLastSep(s, sep string) string { + idx := strings.LastIndex(s, sep) + if idx == -1 { + return s + } + return s[idx+1:] +} diff --git a/pkg/registry/apis/provisioning/resources/id_test.go b/pkg/registry/apis/provisioning/resources/id_test.go new file mode 100644 index 00000000000..0a3907b3750 --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/id_test.go @@ -0,0 +1,105 @@ +package resources + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitiseKubeName(t *testing.T) { + for _, tc := range []struct{ Name, Input, Expected string }{ + {"Valid Kubernetes name", "a-b.123.-c", "a-b.123.-c"}, + {"Capital letters lowercase", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"}, + {"Trailing hyphen removed", "abc-", "abc"}, + {"Trailing dot removed", "abc.", "abc"}, + {"Leading hyphen removed", "-abc", "abc"}, + {"Leading dot removed", ".abc", "abc"}, + {"Double hyphen simplified", "ab--c", "ab-c"}, + {"Five hyphens simplified", "ab-----c", "ab-c"}, + {"Underscore converted", "ab_c", "ab-c"}, + {"Slash converted", "ab/c", "ab-c"}, + {"Stops at 253 characters", strings.Repeat("a", 300), strings.Repeat("a", 253)}, + } { + t.Run(tc.Name, func(t *testing.T) { + assert.Equal(t, tc.Expected, sanitiseKubeName(tc.Input)) + }) + } +} + +func TestAppendHashSuffix(t *testing.T) { + t.Run("hash key is part of hash", func(t *testing.T) { + keyA := appendHashSuffix("A", "unit-test")("test") + keyB := appendHashSuffix("B", "unit-test")("test") + assert.NotEqual(t, keyA, keyB, "hash key is not part of hash") + }) + t.Run("repository name is part of hash", func(t *testing.T) { + repoA := appendHashSuffix("unit-test", "A")("test") + repoB := appendHashSuffix("unit-test", "B")("test") + assert.NotEqual(t, repoA, repoB, "repo name is not part of hash") + }) + t.Run("is deterministic", func(t *testing.T) { + hasher := appendHashSuffix("unit", "test") + assert.Equal(t, hasher("a"), hasher("a")) + _ = hasher("b") // Assume it isn't deterministic: this would likely modify a hasher state + assert.Equal(t, hasher("a"), hasher("a")) // alas, it is deterministic! + }) + + // These are covered by the tests above, so we can just use static values from now on + const repoName = "repository" + const hashKey = "key" + hasher := appendHashSuffix(hashKey, repoName) + + for _, tc := range []struct{ Name, Input, Expected string }{ + {"Simple, short prefix", "test", "test-ae2h65vh3ygoxmprmnludpyhhpr3d-5iosyhbdro-j0"}, + {"Suffix requiring cutting hash to min", strings.Repeat("test", 200), "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest-8dogmh7bizi8"}, + {"Suffix requiring partially cutting hash", strings.Repeat("test", 59), "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest-urg0-j8db5uuvpak"}, + } { + t.Run(tc.Name, func(t *testing.T) { + hashed := hasher(tc.Input) + assert.Equal(t, tc.Expected, hashed, "hashed value must be as expected") + + // These exist both because they're helpful to understand how something isn't equal above, and for programmer errors. (e.g. what if I manually input a 254 char expected value? or one with too few hash chars?) + assert.LessOrEqual(t, len(hashed), 253, "string after hashing needs to be <=253 chars long") + assert.GreaterOrEqual(t, len(strings.SplitAfterN(hashed, "-", 2)[1]), 12, "hash must be at least 12 characters long") + }) + } +} + +func TestNamesFromHashedRepoPath(t *testing.T) { + dashName, folderName := NamesFromHashedRepoPath("xyz", "path/to/folder/dashboard.json") + assert.Equal(t, "dashboard-fy2kflbmskvt6u-9uecoahd1ekwbb7omrzgmuqppymo", dashName, "dashboard name of dashboard.json") + assert.Equal(t, "path-to-folder-3ehfurpmbvs4yxfp7a0r2uskrrqxhevyyxzp2qnz1pm", folderName, "folder name of dashboard.json") + + name1, f1 := NamesFromHashedRepoPath("xyz", "path/to/folder.json") + name2, f2 := NamesFromHashedRepoPath("xyz", "path/to/folder") + require.Equal(t, folderName, name1, "folder.json should have same folder name as dashboard.json") + require.Equal(t, folderName, name2, "folder should have same folder name as dashboard.json") + require.Equal(t, f1, f2, "folder.json and folder should have same folder name as each other") +} + +func TestParseFolderID(t *testing.T) { + const repoName = "unit-test" // we have other tests verifying the repo name changes the id + + cases := []struct { + Description string + Path string + Title string + KubeName string + }{ + {"Short, simple path", "hello/world", "world", "world-wik-hjayboohlsvzzr2ob3he8cs7ffk0jdfmtlgnuxi"}, + {"Capital letters and punctuation", "Hello, World!", "Hello, World!", "helloworld-sbcnvdmezf0jnvgfhpk5ewaoawbegk5uhmevqi3yp-0"}, + {"Very long name", strings.Repeat("/hello/world", 200), "world", "world-hvogmduf-7llmahwaldpwlgfikarxzej5yuh4ikap4e"}, + {"Leading, trailing, and unnecessary slashes", "/hello///world////", "world", "world-wik-hjayboohlsvzzr2ob3he8cs7ffk0jdfmtlgnuxi"}, + } + + for _, c := range cases { + t.Run(c.Description, func(t *testing.T) { + id := ParseFolder(c.Path, repoName) + assert.Equal(t, c.Path, id.Path) + assert.Equal(t, c.KubeName, id.ID) + assert.Equal(t, c.Title, id.Title) + }) + } +} diff --git a/pkg/registry/apis/provisioning/resources/idmapper.go b/pkg/registry/apis/provisioning/resources/idmapper.go deleted file mode 100644 index c41f462d8cf..00000000000 --- a/pkg/registry/apis/provisioning/resources/idmapper.go +++ /dev/null @@ -1,37 +0,0 @@ -package resources - -import ( - "crypto/sha256" - "encoding/base64" - "path" - "strings" -) - -// Will pick a name based on the hashed repository and path -func NamesFromHashedRepoPath(repo string, fpath string) (objectName string, folderName string) { - // remove the extension - idx := strings.LastIndex(fpath, ".") - if idx > 0 { - fpath = fpath[0:idx] - } - - hasher := func(fpath string) string { - name := path.Base(fpath) - if len(name) > 16 { - name = name[0:16] - } - hash := sha256.New() - _, _ = hash.Write([]byte(repo)) - _, _ = hash.Write([]byte{'/'}) - _, _ = hash.Write([]byte(fpath)) - return name + "-" + base64.URLEncoding.EncodeToString(hash.Sum(nil))[0:12] - } - - objectName = hasher(fpath) - - idx = strings.LastIndex(fpath, "/") - if idx > 0 { - folderName = hasher(fpath[0:idx]) - } - return -} diff --git a/pkg/registry/apis/provisioning/resources/idmapper_test.go b/pkg/registry/apis/provisioning/resources/idmapper_test.go deleted file mode 100644 index 924ef3a6758..00000000000 --- a/pkg/registry/apis/provisioning/resources/idmapper_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package resources - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIDMapper(t *testing.T) { - dashName, folderName := NamesFromHashedRepoPath("xyz", "path/to/folder/dashboard.json") - require.Equal(t, "dashboard-B60LqDCLR6FC", dashName) - require.Equal(t, "folder-aObw9Q2jRwTI", folderName) - - name1, f1 := NamesFromHashedRepoPath("xyz", "path/to/folder.json") - name2, f2 := NamesFromHashedRepoPath("xyz", "path/to/folder") - require.Equal(t, folderName, name1) - require.Equal(t, folderName, name2) - require.Equal(t, f1, f2) -} diff --git a/pkg/registry/apis/provisioning/resources/tree.go b/pkg/registry/apis/provisioning/resources/tree.go new file mode 100644 index 00000000000..ca59ee1999e --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/tree.go @@ -0,0 +1,100 @@ +package resources + +import ( + "context" + "path" + + apiutils "github.com/grafana/grafana/pkg/apimachinery/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// FolderTree contains the entire set of folders (at a given snapshot in time) of the Grafana instance. +// The folders are portrayed as a tree, where a folder has a parent, up until the root folder. +// The root folder is special-cased as a folder that exists, but is not itself stored. It has no ID, no title, and no data, but will return `true` for OK bools. +type FolderTree struct { + tree map[string]string + folders map[string]Folder +} + +// 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?" +// An empty folder string means the root folder, and is special-cased to always return true. +func (t *FolderTree) In(folder string) bool { + _, ok := t.tree[folder] + return ok || folder == "" +} + +// DirPath creates the path to the directory with slashes, up to but not including the baseFolder. +// The baseFolder is expected to be the repository's root folder, as defined by its spec. If this is used in other contexts, it should still function. +// An empty folder string means the root folder, and is special-cased to return no ID data. +// The returned Path field in the ID is relative to the base folder. If it is the base folder, an empty string is returned. +// +// If In(folder) or In(baseFolder) is false, this will return ok=false, because it would be undefined behaviour. +// If baseFolder is not a parent of folder, ok=false is returned. +func (t *FolderTree) DirPath(folder, baseFolder string) (fid Folder, ok bool) { + if !t.In(folder) || !t.In(baseFolder) { + return Folder{}, false + } + if folder == "" && baseFolder != "" { + return Folder{}, false + } else if folder == baseFolder { + // Zero-value: we're fine with the zv if we're working with the root folder here. + // Any other folder ID will have the correct metadata and no path (which is correct). + return t.folders[folder], true + } + + fid = t.folders[folder] + fid.Path = fid.Title + ok = baseFolder == "" + + parent := t.tree[folder] + for parent != "" { + if parent == baseFolder { + ok = true + break + } + fid.Path = path.Join(t.folders[parent].Title, fid.Path) + parent = t.tree[parent] + } + return fid, ok +} + +func FetchRepoFolderTree(ctx context.Context, client *DynamicClient) (*FolderTree, error) { + iface := client.Resource(schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v0alpha1", + Resource: "folders", + }) + + // TODO: handle pagination + rawFolders, err := iface.List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + tree := make(map[string]string, len(rawFolders.Items)) + folders := make(map[string]Folder, len(rawFolders.Items)) + for _, rf := range rawFolders.Items { + name := rf.GetName() + // TODO: Can I use MetaAccessor here? + parent := rf.GetAnnotations()[apiutils.AnnoKeyFolder] + tree[name] = parent + + id := Folder{ + Title: name, + ID: name, + Path: "", // We'll set this later in the DirPath function :) + } + if title, ok, _ := unstructured.NestedString(rf.Object, "spec", "title"); ok { + // If the title doesn't exist (it should), we'll just use the K8s name. + id.Title = title + } + folders[name] = id + } + + return &FolderTree{ + tree: tree, + folders: folders, + }, nil +} diff --git a/pkg/registry/apis/provisioning/resources/tree_test.go b/pkg/registry/apis/provisioning/resources/tree_test.go new file mode 100644 index 00000000000..e19a0980b3e --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/tree_test.go @@ -0,0 +1,92 @@ +package resources + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFolderTree(t *testing.T) { + newFid := func(kube, title string) Folder { + return Folder{ID: kube, Title: title} + } + + t.Run("empty tree", func(t *testing.T) { + tree := &FolderTree{ + tree: make(map[string]string), + folders: make(map[string]Folder), + } + + assert.False(t, tree.In("x"), "x should not be in tree") + assert.False(t, tree.In("z"), "z should not be in tree") + _, ok := tree.DirPath("x", "") + assert.False(t, ok, "x should not have a DirPath") + }) + + t.Run("single directory in tree", func(t *testing.T) { + tree := &FolderTree{ + tree: map[string]string{"x": ""}, + folders: map[string]Folder{"x": newFid("x", "X!")}, + } + + assert.True(t, tree.In("x"), "x should be in tree") + id, ok := tree.DirPath("x", "x") + if assert.True(t, ok, "x should have DirPath with itself as base") { + assert.Equal(t, "x", id.ID, "KubernetesName") + assert.Equal(t, "X!", id.Title, "Title") + assert.Equal(t, "", id.Path, "Path") + } + id, ok = tree.DirPath("x", "") + if assert.True(t, ok, "x should have DirPath with empty base") { + assert.Equal(t, "x", id.ID, "KubernetesName") + assert.Equal(t, "X!", id.Title, "Title") + assert.Equal(t, "X!", id.Path, "Path") + } + }) + + t.Run("simple nesting tree", func(t *testing.T) { + tree := &FolderTree{ + tree: map[string]string{"a": "b", "b": "c", "c": "x", "x": ""}, + folders: map[string]Folder{ + "x": newFid("x", "X!"), + "c": newFid("c", "C :)"), + "b": newFid("b", "!!B#!"), + "a": newFid("a", "[€]@£a"), + }, + } + + assert.True(t, tree.In("x"), "x should be in tree") + assert.True(t, tree.In("a"), "a should be in tree") + assert.False(t, tree.In("z"), "z should not be in tree, for it is undeclared") + + id, ok := tree.DirPath("x", "") + if assert.True(t, ok, "x should have DirPath with empty base") { + assert.Equal(t, "x", id.ID, "KubernetesName") + assert.Equal(t, "X!", id.Title, "Title") + assert.Equal(t, "X!", id.Path, "Path") + } + + id, ok = tree.DirPath("c", "c") + if assert.True(t, ok, "c should have DirPath with itself as base") { + assert.Equal(t, "c", id.ID, "KubernetesName") + assert.Equal(t, "C :)", id.Title, "Title") + assert.Equal(t, "", id.Path, "Path") + } + + id, ok = tree.DirPath("a", "x") + if assert.True(t, ok, "a should have DirPath with x as base") { + assert.Equal(t, "a", id.ID, "KubernetesName") + assert.Equal(t, "[€]@£a", id.Title, "Title") + assert.Equal(t, "C :)/!!B#!/[€]@£a", id.Path, "Path") + } + _, ok = tree.DirPath("x", "a") + assert.False(t, ok, "x should not have DirPath with a as base, because a is a subfolder of x") + + id, ok = tree.DirPath("", "") + if assert.True(t, ok, "the root folder should have a path to itself") { + assert.Empty(t, id.ID) + assert.Empty(t, id.Path) + assert.Empty(t, id.Title) + } + }) +}