Add Sys() API client helpers for namespaces (#2955)

Signed-off-by: Enrico Fusi <enrico.fusi@sap.com>
This commit is contained in:
Enrico Fusi
2026-05-27 10:43:37 +02:00
committed by GitHub
parent 81e48899e2
commit 87d2be0a54
14 changed files with 1189 additions and 87 deletions
+257
View File
@@ -1,9 +1,15 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package api
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/go-viper/mapstructure/v2"
)
type UnsealNamespaceInput struct {
@@ -68,3 +74,254 @@ func (c *Sys) SealNamespaceWithContext(ctx context.Context, name string) error {
}
return nil
}
// CreateNamespaceInput is the input for the CreateNamespace operation.
type CreateNamespaceInput struct {
CustomMetadata map[string]string `json:"custom_metadata"`
// Seal is a HCL string with exactly one seal stanza, e.g.:
// seal "shamir" { shares = 5 threshold = 3 }
// If empty the namespace is not sealable.
Seal string `json:"seal,omitempty"`
PGPKeys []string `json:"pgp_keys,omitempty"`
}
// CreateNamespaceOutput is returned by CreateNamespace.
type CreateNamespaceOutput struct {
UUID string `json:"uuid"`
ID string `json:"id"`
Path string `json:"path"`
Tainted bool `json:"tainted"`
Locked bool `json:"locked"`
CustomMetadata map[string]string `json:"custom_metadata"`
KeyShares []string `json:"key_shares"`
}
// PatchNamespaceInput is the input for the PatchNamespace operation.
// CustomMetadata values can be string to add or modify a key, or nil to remove
// a key.
type PatchNamespaceInput struct {
CustomMetadata map[string]interface{} `json:"custom_metadata"`
}
// PatchNamespaceOutput is returned by PatchNamespace.
type PatchNamespaceOutput struct {
UUID string `json:"uuid"`
ID string `json:"id"`
Path string `json:"path"`
Tainted bool `json:"tainted"`
Locked bool `json:"locked"`
CustomMetadata map[string]string `json:"custom_metadata"`
}
// ReadNamespaceOutput is returned by ReadNamespace.
type ReadNamespaceOutput struct {
UUID string `json:"uuid"`
ID string `json:"id"`
Path string `json:"path"`
Tainted bool `json:"tainted"`
Locked bool `json:"locked"`
CustomMetadata map[string]string `json:"custom_metadata"`
}
// DeleteNamespaceOutput is returned by DeleteNamespace.
type DeleteNamespaceOutput struct {
Status string `json:"status"`
}
// ListNamespaceOutput is returned by ListNamespaces for each namespace entry.
type ListNamespaceOutput struct {
UUID string `json:"uuid"`
ID string `json:"id"`
Path string `json:"path"`
Tainted bool `json:"tainted"`
Locked bool `json:"locked"`
CustomMetadata map[string]string `json:"custom_metadata"`
}
// ListNamespaces lists all child namespaces relative to the current namespace.
func (c *Sys) ListNamespaces() (map[string]*ListNamespaceOutput, error) {
return c.ListNamespacesWithContext(context.Background())
}
// ListNamespacesWithContext lists all child namespaces relative to the current namespace.
func (c *Sys) ListNamespacesWithContext(ctx context.Context) (map[string]*ListNamespaceOutput, error) {
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodGet, "/v1/sys/namespaces/")
r.Params.Set("list", "true")
resp, err := c.c.rawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close() //nolint:errcheck
secret, err := ParseSecret(resp.Body)
if err != nil {
return nil, err
}
if secret == nil || secret.Data == nil {
return nil, errors.New("data from server response is empty")
}
keyInfoRaw, ok := secret.Data["key_info"]
if !ok {
return map[string]*ListNamespaceOutput{}, nil
}
result := map[string]*ListNamespaceOutput{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
Result: &result,
})
if err != nil {
return nil, err
}
if err := decoder.Decode(keyInfoRaw); err != nil {
return nil, err
}
return result, nil
}
// CreateNamespace creates a new namespace with the given name.
func (c *Sys) CreateNamespace(name string, i *CreateNamespaceInput) (*CreateNamespaceOutput, error) {
return c.CreateNamespaceWithContext(context.Background(), name, i)
}
// CreateNamespaceWithContext creates a new namespace with the given name.
func (c *Sys) CreateNamespaceWithContext(ctx context.Context, name string, i *CreateNamespaceInput) (*CreateNamespaceOutput, error) {
if name == "" {
return nil, errors.New("name must not be empty")
}
if i == nil {
i = &CreateNamespaceInput{}
}
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodPost, fmt.Sprintf("/v1/sys/namespaces/%s", name))
if err := r.SetJSONBody(i); err != nil {
return nil, err
}
resp, err := c.c.rawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
Data *CreateNamespaceOutput
}
if err := resp.DecodeJSON(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// PatchNamespace updates the metadata of an existing namespace with the given name.
func (c *Sys) PatchNamespace(name string, i *PatchNamespaceInput) (*PatchNamespaceOutput, error) {
return c.PatchNamespaceWithContext(context.Background(), name, i)
}
// PatchNamespaceWithContext updates the metadata of an existing namespace with the given name.
func (c *Sys) PatchNamespaceWithContext(ctx context.Context, name string, i *PatchNamespaceInput) (*PatchNamespaceOutput, error) {
if name == "" {
return nil, errors.New("name must not be empty")
}
if i == nil {
return nil, errors.New("input must not be nil")
}
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodPatch, fmt.Sprintf("/v1/sys/namespaces/%s", name))
r.Headers.Set("Content-Type", "application/merge-patch+json")
if err := r.SetJSONBody(i); err != nil {
return nil, err
}
resp, err := c.c.rawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
Data *PatchNamespaceOutput
}
if err := resp.DecodeJSON(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// DeleteNamespace removes the namespace with the given name.
func (c *Sys) DeleteNamespace(name string) (*DeleteNamespaceOutput, error) {
return c.DeleteNamespaceWithContext(context.Background(), name)
}
// DeleteNamespaceWithContext removes the namespace with the given name.
func (c *Sys) DeleteNamespaceWithContext(ctx context.Context, name string) (*DeleteNamespaceOutput, error) {
if name == "" {
return nil, errors.New("name must not be empty")
}
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodDelete, fmt.Sprintf("/v1/sys/namespaces/%s", name))
resp, err := c.c.rawRequestWithContext(ctx, r)
if err != nil {
return nil, err
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
Data *DeleteNamespaceOutput
}
if err := resp.DecodeJSON(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// ReadNamespace returns information about the namespace with the given name.
func (c *Sys) ReadNamespace(name string) (*ReadNamespaceOutput, error) {
return c.ReadNamespaceWithContext(context.Background(), name)
}
// ReadNamespaceWithContext returns information about the namespace with the given name.
func (c *Sys) ReadNamespaceWithContext(ctx context.Context, name string) (*ReadNamespaceOutput, error) {
if name == "" {
return nil, errors.New("name must not be empty")
}
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
r := c.c.NewRequest(http.MethodGet, fmt.Sprintf("/v1/sys/namespaces/%s", name))
resp, err := c.c.rawRequestWithContext(ctx, r)
if resp != nil {
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
}
if err != nil {
return nil, err
}
var result struct {
Data *ReadNamespaceOutput
}
if err := resp.DecodeJSON(&result); err != nil {
return nil, err
}
return result.Data, nil
}
+468
View File
@@ -0,0 +1,468 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package api
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
// mockNamespaceHandler returns an HTTP handler that writes the given body.
func mockNamespaceHandler(body string) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}
}
func TestCreateNamespaceValidation(t *testing.T) {
cfg := DefaultConfig()
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
if _, err := client.Sys().CreateNamespace("", nil); err == nil {
t.Error("expected error for empty path, got nil")
}
}
func TestPatchNamespaceValidation(t *testing.T) {
cfg := DefaultConfig()
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
if _, err := client.Sys().PatchNamespace("", nil); err == nil {
t.Error("expected error for empty path, got nil")
}
if _, err := client.Sys().PatchNamespace("ns1", nil); err == nil {
t.Error("expected error for nil input, got nil")
}
}
func TestReadNamespaceValidation(t *testing.T) {
cfg := DefaultConfig()
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
if _, err := client.Sys().ReadNamespace(""); err == nil {
t.Error("expected error for empty path, got nil")
}
}
func TestDeleteNamespaceValidation(t *testing.T) {
cfg := DefaultConfig()
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
if _, err := client.Sys().DeleteNamespace(""); err == nil {
t.Error("expected error for empty path, got nil")
}
}
func TestCreateNamespace(t *testing.T) {
for name, tc := range map[string]struct {
body string
input CreateNamespaceInput
expected CreateNamespaceOutput
}{
"namespace with custom metadata": {
body: createNamespaceResponse,
input: CreateNamespaceInput{
CustomMetadata: map[string]string{"env": "prod"},
},
expected: CreateNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
Tainted: false,
Locked: false,
CustomMetadata: map[string]string{"env": "prod"},
},
},
"namespace without custom metadata": {
body: createNamespaceResponseNoMetadata,
input: CreateNamespaceInput{},
expected: CreateNamespaceOutput{
UUID: "def456",
ID: "ns2",
Path: "ns2/",
Tainted: false,
Locked: false,
CustomMetadata: nil,
},
},
} {
t.Run(name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockNamespaceHandler(tc.body)))
defer mockServer.Close()
cfg := DefaultConfig()
cfg.Address = mockServer.URL
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
resp, err := client.Sys().CreateNamespaceWithContext(t.Context(), "ns1", &tc.input)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.expected, *resp) {
t.Errorf("expected: %#v\ngot: %#v", tc.expected, *resp)
}
})
}
}
func TestReadNamespace(t *testing.T) {
for name, tc := range map[string]struct {
body string
expected ReadNamespaceOutput
}{
"existing namespace": {
body: readNamespaceResponse,
expected: ReadNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
Tainted: false,
Locked: false,
CustomMetadata: map[string]string{"env": "prod"},
},
},
"tainted namespace": {
body: readNamespaceTaintedResponse,
expected: ReadNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
Tainted: true,
Locked: false,
},
},
"locked namespace": {
body: readNamespaceLockedResponse,
expected: ReadNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
Locked: true,
},
},
} {
t.Run(name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockNamespaceHandler(tc.body)))
defer mockServer.Close()
cfg := DefaultConfig()
cfg.Address = mockServer.URL
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
resp, err := client.Sys().ReadNamespaceWithContext(t.Context(), "ns1")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.expected, *resp) {
t.Errorf("expected: %#v\ngot: %#v", tc.expected, *resp)
}
})
}
}
func TestPatchNamespace(t *testing.T) {
for name, tc := range map[string]struct {
body string
input PatchNamespaceInput
expected PatchNamespaceOutput
}{
"add metadata key": {
body: patchNamespaceResponse,
input: PatchNamespaceInput{
CustomMetadata: map[string]interface{}{"env": "staging"},
},
expected: PatchNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
CustomMetadata: map[string]string{"env": "staging"},
},
},
"remove metadata key": {
body: patchNamespaceResponse,
input: PatchNamespaceInput{
CustomMetadata: map[string]interface{}{"env": nil},
},
expected: PatchNamespaceOutput{
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
CustomMetadata: map[string]string{"env": "staging"},
},
},
} {
t.Run(name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockNamespaceHandler(tc.body)))
defer mockServer.Close()
cfg := DefaultConfig()
cfg.Address = mockServer.URL
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
resp, err := client.Sys().PatchNamespaceWithContext(t.Context(), "ns1", &tc.input)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.expected, *resp) {
t.Errorf("expected: %#v\ngot: %#v", tc.expected, *resp)
}
})
}
}
func TestDeleteNamespace(t *testing.T) {
for name, tc := range map[string]struct {
body string
expectedStatus string
}{
"successful delete": {
body: deleteNamespaceResponse,
expectedStatus: "deleting",
},
} {
t.Run(name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockNamespaceHandler(tc.body)))
defer mockServer.Close()
cfg := DefaultConfig()
cfg.Address = mockServer.URL
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
resp, err := client.Sys().DeleteNamespaceWithContext(t.Context(), "ns1")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if resp.Status != tc.expectedStatus {
t.Errorf("expected status %q but got %q", tc.expectedStatus, resp.Status)
}
})
}
}
func TestListNamespaces(t *testing.T) {
for name, tc := range map[string]struct {
body string
expected map[string]*ListNamespaceOutput
}{
"multiple namespaces": {
body: listNamespacesResponse,
expected: map[string]*ListNamespaceOutput{
"ns1/": {
UUID: "abc123",
ID: "ns1",
Path: "ns1/",
CustomMetadata: map[string]string{"env": "prod"},
},
"ns2/": {
UUID: "def456",
ID: "ns2",
Path: "ns2/",
},
},
},
} {
t.Run(name, func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(mockNamespaceHandler(tc.body)))
defer mockServer.Close()
cfg := DefaultConfig()
cfg.Address = mockServer.URL
client, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
resp, err := client.Sys().ListNamespacesWithContext(t.Context())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tc.expected, resp) {
t.Errorf("expected: %#v\ngot: %#v", tc.expected, resp)
}
})
}
}
const createNamespaceResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": false,
"locked": false,
"custom_metadata": {"env": "prod"}
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const createNamespaceResponseNoMetadata = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "def456",
"id": "ns2",
"path": "ns2/",
"tainted": false,
"locked": false,
"custom_metadata": null
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const readNamespaceResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": false,
"locked": false,
"custom_metadata": {"env": "prod"}
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const readNamespaceTaintedResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": true,
"locked": false,
"custom_metadata": null
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const readNamespaceLockedResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": false,
"locked": true,
"custom_metadata": null
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const patchNamespaceResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": false,
"locked": false,
"custom_metadata": {"env": "staging"}
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const deleteNamespaceResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"status": "deleting"
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
const listNamespacesResponse = `{
"request_id": "abc",
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"keys": ["ns1/", "ns2/"],
"key_info": {
"ns1/": {
"uuid": "abc123",
"id": "ns1",
"path": "ns1/",
"tainted": false,
"locked": false,
"custom_metadata": {"env": "prod"}
},
"ns2/": {
"uuid": "def456",
"id": "ns2",
"path": "ns2/",
"tainted": false,
"locked": false,
"custom_metadata": null
}
}
},
"wrap_info": null,
"warnings": null,
"auth": null
}`
+6
View File
@@ -0,0 +1,6 @@
```release-note:improvement
api: Add first-class support for `/sys/namespaces` APIs via `.Sys().CreateNamespace(...)` & co.
```
```release-note:bug
core/namespaces: Fix PATCH on a namespace returning status 500 on missing or nonexistent namespace.
```
+44 -37
View File
@@ -9,7 +9,9 @@ import (
"strings"
"github.com/hashicorp/cli"
"github.com/openbao/openbao/api/v2"
"github.com/openbao/openbao/helper/pgpkeys"
"github.com/openbao/openbao/sdk/v2/helper/structtomap"
"github.com/posener/complete"
)
@@ -48,6 +50,14 @@ Usage: bao namespace create [options] PATH
$ bao namespace create -namespace=ns1 ns2
Create a sealable namespace with Shamir seal:
$ bao namespace create -key-shares=5 -key-threshold=3 ns1
Create a sealable namespace from a HCL seal config file:
$ bao namespace create -seal=seal.hcl ns1
` + c.Flags().Help()
return strings.TrimSpace(helpText)
@@ -65,6 +75,7 @@ func (c *NamespaceCreateCommand) Flags() *FlagSets {
"This can be specified multiple times to add multiple pieces of metadata.",
})
f = set.NewFlagSet("Seal Options")
f.StringVar(&StringVar{
Name: "seal",
Target: &c.flagSealConfigPath,
@@ -138,54 +149,50 @@ func (c *NamespaceCreateCommand) Run(args []string) int {
return 2
}
sealConfig, err := c.readSealConfig()
if err != nil {
c.UI.Error(fmt.Sprintf("Error while parsing seal configs: %s", err))
return 2
input := &api.CreateNamespaceInput{
CustomMetadata: c.flagCustomMetadata,
PGPKeys: c.flagPGPKeys,
}
data := map[string]interface{}{
"custom_metadata": c.flagCustomMetadata,
}
if sealConfig != nil {
data["seal"] = string(sealConfig)
}
if c.flagKeyShares != 0 || c.flagKeyThreshold != 0 {
// if either -key-shares or -key-threshold is given, assume we create a
// shamir-sealed namespace; unless an explicit seal config was given
if _, ok := data["seal"]; !ok {
data["seal"] = fmt.Sprintf(`seal "shamir" {
shares = %d
threshold = %d
}`, c.flagKeyShares, c.flagKeyThreshold)
if c.flagSealConfigPath != "" {
hcl, err := os.ReadFile(c.flagSealConfigPath)
if err != nil {
c.UI.Error(fmt.Sprintf("Error reading seal config file: %s", err))
return 2
}
input.Seal = string(hcl)
} else if c.flagKeyShares != 0 || c.flagKeyThreshold != 0 {
input.Seal = fmt.Sprintf("seal \"shamir\" {\n shares = %d\n threshold = %d\n}",
c.flagKeyShares, c.flagKeyThreshold)
}
if len(c.flagPGPKeys) > 0 {
data["pgp_keys"] = c.flagPGPKeys
}
secret, err := client.Logical().Write("sys/namespaces/"+namespacePath, data)
resp, err := client.Sys().CreateNamespace(namespacePath, input)
if err != nil {
c.UI.Error(fmt.Sprintf("Error creating namespace: %s", err))
return 2
}
// Handle single field output
if resp != nil && len(resp.KeyShares) > 0 {
for i, key := range resp.KeyShares {
c.UI.Output(fmt.Sprintf("Unseal Key %d: %s", i+1, key))
}
c.UI.Output("")
c.UI.Output(wrapAtLength(fmt.Sprintf(
"Namespace initialized with %d key shares and a key threshold of %d. Please "+
"securely distribute the key shares printed above. When the namespace is "+
"re-sealed, you must supply at least %d of these keys to unseal it.",
c.flagKeyShares,
c.flagKeyThreshold,
c.flagKeyThreshold,
)))
c.UI.Output("")
}
out := structtomap.Map(resp)
if c.flagField != "" {
return PrintRawField(c.UI, secret, c.flagField)
return PrintRawField(c.UI, out, c.flagField)
}
return OutputSecret(c.UI, secret)
}
func (c *NamespaceCreateCommand) readSealConfig() ([]byte, error) {
path := c.flagSealConfigPath
if path == "" {
return nil, nil
}
return os.ReadFile(path)
return OutputData(c.UI, out)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/cli"
)
func testNamespaceCreateCommand(tb testing.TB) (*cli.MockUi, *NamespaceCreateCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &NamespaceCreateCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestNamespaceCreateCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"too_many_args",
[]string{"foo", "bar"},
"Too many arguments",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
ui, cmd := testNamespaceCreateCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testNamespaceCreateCommand(t)
assertNoTabs(t, cmd)
})
}
+4 -4
View File
@@ -83,15 +83,15 @@ func (c *NamespaceDeleteCommand) Run(args []string) int {
return 2
}
secret, err := client.Logical().Delete("sys/namespaces/" + namespacePath)
resp, err := client.Sys().DeleteNamespace(namespacePath)
if err != nil {
c.UI.Error(fmt.Sprintf("Error deleting namespace: %s", err))
return 2
}
if secret != nil {
// Likely, we have warnings
return OutputSecret(c.UI, secret)
if resp == nil || resp.Status == "" {
c.UI.Warn("Requested namespace does not exist")
return 0
}
if !strings.HasSuffix(namespacePath, "/") {
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/cli"
)
func testNamespaceDeleteCommand(tb testing.TB) (*cli.MockUi, *NamespaceDeleteCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &NamespaceDeleteCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestNamespaceDeleteCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"too_many_args",
[]string{"foo", "bar"},
"Too many arguments",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
ui, cmd := testNamespaceDeleteCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testNamespaceDeleteCommand(t)
assertNoTabs(t, cmd)
})
}
+8 -26
View File
@@ -82,42 +82,24 @@ func (c *NamespaceListCommand) Run(args []string) int {
return 2
}
secret, err := client.Logical().List("sys/namespaces")
resp, err := client.Sys().ListNamespaces()
if err != nil {
c.UI.Error(fmt.Sprintf("Error listing namespaces: %s", err))
return 2
}
_, ok := extractListData(secret)
if Format(c.UI) != "table" {
if secret == nil || secret.Data == nil || !ok {
OutputData(c.UI, map[string]interface{}{})
return 2
}
}
if secret == nil {
if len(resp) == 0 {
c.UI.Error("No namespaces found")
return 2
}
// There could be e.g. warnings
if secret.Data == nil {
return OutputSecret(c.UI, secret)
}
if secret.WrapInfo != nil && secret.WrapInfo.TTL != 0 {
return OutputSecret(c.UI, secret)
}
if !ok {
c.UI.Error("No entries found")
return 2
}
if c.flagDetailed && Format(c.UI) != "table" {
return OutputData(c.UI, secret.Data["key_info"])
return OutputData(c.UI, resp)
}
return OutputList(c.UI, secret)
keys := make([]string, 0, len(resp))
for k := range resp {
keys = append(keys, k)
}
return OutputData(c.UI, keys)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/cli"
)
func testNamespaceListCommand(tb testing.TB) (*cli.MockUi, *NamespaceListCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &NamespaceListCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestNamespaceListCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"no_namespaces",
[]string{},
"Error listing namespaces",
2,
},
{
"too_many_args",
[]string{"foo"},
"Too many arguments",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
ui, cmd := testNamespaceListCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testNamespaceListCommand(t)
assertNoTabs(t, cmd)
})
}
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/hashicorp/cli"
"github.com/openbao/openbao/sdk/v2/helper/structtomap"
"github.com/posener/complete"
)
@@ -79,15 +80,15 @@ func (c *NamespaceLookupCommand) Run(args []string) int {
return 2
}
secret, err := client.Logical().Read("sys/namespaces/" + namespacePath)
resp, err := client.Sys().ReadNamespace(namespacePath)
if err != nil {
c.UI.Error(fmt.Sprintf("Error looking up namespace: %s", err))
return 2
}
if secret == nil {
if resp == nil {
c.UI.Error("Namespace not found")
return 2
}
return OutputSecret(c.UI, secret)
return OutputData(c.UI, structtomap.Map(resp))
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/cli"
)
func testNamespaceLookupCommand(tb testing.TB) (*cli.MockUi, *NamespaceLookupCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &NamespaceLookupCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestNamespaceLookupCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"too_many_args",
[]string{"foo", "bar"},
"Too many arguments",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
ui, cmd := testNamespaceLookupCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testNamespaceLookupCommand(t)
assertNoTabs(t, cmd)
})
}
+13 -16
View File
@@ -4,13 +4,12 @@
package command
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/cli"
"github.com/openbao/openbao/api/v2"
"github.com/openbao/openbao/sdk/v2/helper/structtomap"
"github.com/posener/complete"
)
@@ -101,41 +100,39 @@ func (c *NamespacePatchCommand) Run(args []string) int {
namespacePath := strings.TrimSpace(args[0])
if len(c.flagCustomMetadata) == 0 && len(c.flagRemoveCustomMetadata) == 0 {
c.UI.Error("Must supply at least one of -custom-metadata or -remove-custom-metadata")
return 1
}
client, err := c.Client()
if err != nil {
c.UI.Error(err.Error())
return 2
}
data := make(map[string]interface{})
customMetadata := make(map[string]interface{})
for key, value := range c.flagCustomMetadata {
customMetadata[key] = value
}
for _, key := range c.flagRemoveCustomMetadata {
// A null in a JSON merge patch payload will remove the associated key
customMetadata[key] = nil
}
data["custom_metadata"] = customMetadata
secret, err := client.Logical().JSONMergePatch(context.Background(), "sys/namespaces/"+namespacePath, data)
resp, err := client.Sys().PatchNamespace(namespacePath, &api.PatchNamespaceInput{
CustomMetadata: customMetadata,
})
if err != nil {
if re, ok := err.(*api.ResponseError); ok && re.StatusCode == http.StatusNotFound {
c.UI.Error("Namespace not found")
return 2
}
c.UI.Error(fmt.Sprintf("Error patching namespace: %s", err))
return 2
}
// Handle single field output
out := structtomap.Map(resp)
if c.flagField != "" {
return PrintRawField(c.UI, secret, c.flagField)
return PrintRawField(c.UI, out, c.flagField)
}
return OutputSecret(c.UI, secret)
return OutputData(c.UI, out)
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright (c) 2026 OpenBao a Series of LF Projects, LLC
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/cli"
)
func testNamespacePatchCommand(tb testing.TB) (*cli.MockUi, *NamespacePatchCommand) {
tb.Helper()
ui := cli.NewMockUi()
return ui, &NamespacePatchCommand{
BaseCommand: &BaseCommand{
UI: ui,
},
}
}
func TestNamespacePatchCommand_Run(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
out string
code int
}{
{
"not_enough_args",
[]string{},
"Not enough arguments",
1,
},
{
"too_many_args",
[]string{"foo", "bar"},
"Too many arguments",
1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client, closer := testVaultServer(t)
defer closer()
ui, cmd := testNamespacePatchCommand(t)
cmd.client = client
code := cmd.Run(tc.args)
if code != tc.code {
t.Errorf("expected %d to be %d", code, tc.code)
}
combined := ui.OutputWriter.String() + ui.ErrorWriter.String()
if !strings.Contains(combined, tc.out) {
t.Errorf("expected %q to contain %q", combined, tc.out)
}
})
}
t.Run("no_tabs", func(t *testing.T) {
t.Parallel()
_, cmd := testNamespacePatchCommand(t)
assertNoTabs(t, cmd)
})
}
+10 -1
View File
@@ -19,6 +19,8 @@ import (
"github.com/openbao/openbao/sdk/v2/logical"
)
var errNamespaceNotFound = errors.New("requested namespace does not exist")
var namespacePathSchema = &framework.FieldSchema{
Type: framework.TypeString,
Required: false,
@@ -443,9 +445,13 @@ func (b *SystemBackend) handleNamespacesPatch() framework.OperationFunc {
return nil, errors.New("path must not contain /")
}
if _, ok := data.Raw["custom_metadata"]; !ok {
return logical.ErrorResponse("request body must include custom_metadata"), logical.ErrInvalidRequest
}
ns, _, err := b.Core.namespaceStore.ModifyNamespaceByPath(ctx, path, nil, func(ctx context.Context, ns *namespace.Namespace) (*namespace.Namespace, error) {
if ns.UUID == "" {
return nil, fmt.Errorf("requested namespace does not exist")
return nil, errNamespaceNotFound
}
current := make(map[string]interface{})
@@ -467,6 +473,9 @@ func (b *SystemBackend) handleNamespacesPatch() framework.OperationFunc {
return ns, nil
})
if err != nil {
if errors.Is(err, errNamespaceNotFound) {
return nil, logical.CodedError(http.StatusNotFound, "namespace not found")
}
return nil, fmt.Errorf("failed to modify namespace: %w", err)
}