Provisioning: Use folder titles as their Kubernetes name (#99451)

* feat: initial folder ID concept

* feat: parse and use folder ID for import

* feat: export with sensible folder titles

* fix: use path pkg, not filepath

* fix: use title in path

* feat: use only the dir for kube name

* feat: align on one strategy for creating names

* refactor: FolderID is now Folder, our representation of the whole concept

* refactor: move FolderTree to resources package
This commit is contained in:
Mariell Hoversholm authored and GitHub committed 2025-01-27 16:01:26 +01:00
1 parent 574a39d7a4
commit 90042f65d0
10 files changed
+466 -188

No files matched your search

@@ -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
}
@@ -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")
}
@@ -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
}
@@ -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"))
})
}
@@ -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:]
}
@@ -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)
})
}
}
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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)
}
})
}