mirror of
https://github.com/openbao/openbao.git
synced 2026-06-01 18:57:37 +02:00
Add Sys() API client helpers for namespaces (#2955)
Signed-off-by: Enrico Fusi <enrico.fusi@sap.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}`
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, "/") {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user