diff --git a/changelog/25270.txt b/changelog/25270.txt new file mode 100644 index 000000000000..60ccf37dc69f --- /dev/null +++ b/changelog/25270.txt @@ -0,0 +1,3 @@ +```release-note:improvement +sdk/helper/testhelpers: add namespace helpers +``` diff --git a/sdk/helper/testhelpers/namespaces/namespaces.go b/sdk/helper/testhelpers/namespaces/namespaces.go new file mode 100644 index 000000000000..9981104f2b56 --- /dev/null +++ b/sdk/helper/testhelpers/namespaces/namespaces.go @@ -0,0 +1,165 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package namespaces + +import ( + "context" + "errors" + "fmt" + "path" + "slices" + "strings" + "time" + + "github.com/hashicorp/vault/api" +) + +// RootNamespacePath is the path of the root namespace. +const RootNamespacePath = "" + +// RootNamespaceID is the ID of the root namespace. +const RootNamespaceID = "root" + +// ErrNotFound is returned by funcs in this package when something isn't found, +// instead of returning (nil, nil). +var ErrNotFound = errors.New("no namespaces found") + +// folderPath transforms an input path that refers to a namespace or mount point, +// such that it adheres to the norms Vault prefers. The result will have any +// leading "/" stripped, and, except for the root namespace which is always +// RootNamespacePath, will always end in a "/". +func folderPath(path string) string { + if !strings.HasSuffix(path, "/") { + path += "/" + } + + return strings.TrimPrefix(path, "/") +} + +// joinPath concatenates its inputs using "/" as a delimiter. The result will +// adhere to folderPath conventions. +func joinPath(s ...string) string { + return folderPath(path.Join(s...)) +} + +// GetNamespaceIDPaths does a namespace list and extracts the resulting paths +// and namespace IDs, returning a map from namespace ID to path. Returns +// ErrNotFound if no namespaces exist beneath the current namespace set on the +// client. +func GetNamespaceIDPaths(client *api.Client) (map[string]string, error) { + secret, err := client.Logical().List("sys/namespaces") + if err != nil { + return nil, err + } + if secret == nil { + return nil, ErrNotFound + } + if _, ok := secret.Data["key_info"]; !ok { + return nil, ErrNotFound + } + + ret := map[string]string{} + for relNsPath, infoAny := range secret.Data["key_info"].(map[string]any) { + info := infoAny.(map[string]any) + id := info["id"].(string) + ret[id] = relNsPath + } + return ret, err +} + +// WalkNamespaces does recursive namespace list commands to discover the complete +// namespace hierarchy. This may yield an error or inconsistent results if +// namespaces change while we're querying them. +// The callback f is invoked for every namespace discovered. Namespace traversal +// is pre-order depth-first. If f returns an error, traversal is aborted and the +// error is returned. Otherwise, an error is only returned if a request results +// in an error. +func WalkNamespaces(client *api.Client, f func(id, apiPath string) error) error { + return walkNamespacesRecursive(client, RootNamespaceID, RootNamespacePath, f) +} + +func walkNamespacesRecursive(client *api.Client, startID, startApiPath string, f func(id, apiPath string) error) error { + if err := f(startID, startApiPath); err != nil { + return err + } + + idpaths, err := GetNamespaceIDPaths(client.WithNamespace(startApiPath)) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil + } + return err + } + + for id, path := range idpaths { + fullPath := joinPath(startApiPath, path) + + if err = walkNamespacesRecursive(client, id, fullPath, f); err != nil { + return err + } + } + return nil +} + +// PollDeleteNamespace issues a namespace delete request and waits for it +// to complete (since namespace deletes are asynchronous), at least until +// ctx expires. +func PollDeleteNamespace(ctx context.Context, client *api.Client, nsPath string) error { + _, err := client.Logical().Delete("sys/namespaces/" + nsPath) + if err != nil { + return err + } + +LOOP: + for ctx.Err() == nil { + resp, err := client.Logical().Delete("sys/namespaces/" + nsPath) + if err != nil { + return err + } + for _, warn := range resp.Warnings { + if strings.HasPrefix(warn, "Namespace is already being deleted") { + time.Sleep(10 * time.Millisecond) + continue LOOP + } + } + break + } + + return ctx.Err() +} + +// DeleteAllNamespaces uses WalkNamespaces to delete all namespaces, +// waiting for deletion to complete before returning. The same caveats about +// namespaces changing underneath us apply as in WalkNamespaces. +// Traversal is depth-first pre-order, but we must do the deletion in the reverse +// order, since a namespace containing namespaces cannot be deleted. +func DeleteAllNamespaces(ctx context.Context, client *api.Client) error { + var nss []string + err := WalkNamespaces(client, func(id, apiPath string) error { + if apiPath != RootNamespacePath { + nss = append(nss, apiPath) + } + return nil + }) + if err != nil { + return err + } + slices.Reverse(nss) + for _, apiPath := range nss { + if err := PollDeleteNamespace(ctx, client, apiPath); err != nil { + return fmt.Errorf("error deleting namespace %q: %v", apiPath, err) + } + } + + // Do a final check to make sure that we got everything, and so that the + // caller doesn't assume that all namespaces are deleted when a glitch + // occurred due to namespaces changing while we were traversing or deleting + // them. + _, err = GetNamespaceIDPaths(client) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + + return nil +}