New root namespace plugin reload API sys/plugins/reload/:type/:name (#24878)

This commit is contained in:
Tom Proctor
2024-01-17 15:46:27 +00:00
committed by GitHub
parent cadef7b2cd
commit 80f85a05f6
21 changed files with 875 additions and 177 deletions

View File

@@ -7,7 +7,10 @@ package api
// https://github.com/hashicorp/vault/blob/main/sdk/helper/consts/plugin_types.go
// Any changes made should be made to both files at the same time.
import "fmt"
import (
"encoding/json"
"fmt"
)
var PluginTypes = []PluginType{
PluginTypeUnknown,
@@ -64,3 +67,34 @@ func ParsePluginType(pluginType string) (PluginType, error) {
return PluginTypeUnknown, fmt.Errorf("%q is not a supported plugin type", pluginType)
}
}
// UnmarshalJSON implements json.Unmarshaler. It supports unmarshaling either a
// string or a uint32. All new serialization will be as a string, but we
// previously serialized as a uint32 so we need to support that for backwards
// compatibility.
func (p *PluginType) UnmarshalJSON(data []byte) error {
var asString string
err := json.Unmarshal(data, &asString)
if err == nil {
*p, err = ParsePluginType(asString)
return err
}
var asUint32 uint32
err = json.Unmarshal(data, &asUint32)
if err != nil {
return err
}
*p = PluginType(asUint32)
switch *p {
case PluginTypeUnknown, PluginTypeCredential, PluginTypeDatabase, PluginTypeSecrets:
return nil
default:
return fmt.Errorf("%d is not a supported plugin type", asUint32)
}
}
// MarshalJSON implements json.Marshaler.
func (p PluginType) MarshalJSON() ([]byte, error) {
return json.Marshal(p.String())
}

101
api/plugin_types_test.go Normal file
View File

@@ -0,0 +1,101 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package api
// NOTE: this file was copied from
// https://github.com/hashicorp/vault/blob/main/sdk/helper/consts/plugin_types_test.go
// Any changes made should be made to both files at the same time.
import (
"encoding/json"
"testing"
)
type testType struct {
PluginType PluginType `json:"plugin_type"`
}
func TestPluginTypeJSONRoundTrip(t *testing.T) {
for _, pluginType := range PluginTypes {
original := testType{
PluginType: pluginType,
}
asBytes, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var roundTripped testType
err = json.Unmarshal(asBytes, &roundTripped)
if err != nil {
t.Fatal(err)
}
if original != roundTripped {
t.Fatalf("expected %v, got %v", original, roundTripped)
}
}
}
func TestPluginTypeJSONUnmarshal(t *testing.T) {
// Failure/unsupported cases.
for name, tc := range map[string]string{
"unsupported": `{"plugin_type":"unsupported"}`,
"random string": `{"plugin_type":"foo"}`,
"boolean": `{"plugin_type":true}`,
"empty": `{"plugin_type":""}`,
"negative": `{"plugin_type":-1}`,
"out of range": `{"plugin_type":10}`,
} {
t.Run(name, func(t *testing.T) {
var result testType
err := json.Unmarshal([]byte(tc), &result)
if err == nil {
t.Fatal("expected error")
}
})
}
// Valid cases.
for name, tc := range map[string]struct {
json string
expected PluginType
}{
"unknown": {`{"plugin_type":"unknown"}`, PluginTypeUnknown},
"auth": {`{"plugin_type":"auth"}`, PluginTypeCredential},
"secret": {`{"plugin_type":"secret"}`, PluginTypeSecrets},
"database": {`{"plugin_type":"database"}`, PluginTypeDatabase},
"absent": {`{}`, PluginTypeUnknown},
"integer unknown": {`{"plugin_type":0}`, PluginTypeUnknown},
"integer auth": {`{"plugin_type":1}`, PluginTypeCredential},
"integer db": {`{"plugin_type":2}`, PluginTypeDatabase},
"integer secret": {`{"plugin_type":3}`, PluginTypeSecrets},
} {
t.Run(name, func(t *testing.T) {
var result testType
err := json.Unmarshal([]byte(tc.json), &result)
if err != nil {
t.Fatal(err)
}
if tc.expected != result.PluginType {
t.Fatalf("expected %v, got %v", tc.expected, result.PluginType)
}
})
}
}
func TestUnknownTypeExcludedWithOmitEmpty(t *testing.T) {
type testTypeOmitEmpty struct {
Type PluginType `json:"type,omitempty"`
}
bytes, err := json.Marshal(testTypeOmitEmpty{})
if err != nil {
t.Fatal(err)
}
m := map[string]any{}
json.Unmarshal(bytes, &m)
if _, exists := m["type"]; exists {
t.Fatal("type should not be present")
}
}

View File

@@ -274,6 +274,22 @@ func (c *Sys) DeregisterPluginWithContext(ctx context.Context, i *DeregisterPlug
return err
}
// RootReloadPluginInput is used as input to the RootReloadPlugin function.
type RootReloadPluginInput struct {
Plugin string `json:"-"` // Plugin name, as registered in the plugin catalog.
Type PluginType `json:"-"` // Plugin type: auth, secret, or database.
Scope string `json:"scope,omitempty"` // Empty to reload on current node, "global" for all nodes.
}
// RootReloadPlugin reloads plugins, possibly returning reloadID for a global
// scoped reload. This is only available in the root namespace, and reloads
// plugins across all namespaces, whereas ReloadPlugin is available in all
// namespaces but only reloads plugins in use in the request's namespace.
func (c *Sys) RootReloadPlugin(ctx context.Context, i *RootReloadPluginInput) (string, error) {
path := fmt.Sprintf("/v1/sys/plugins/reload/%s/%s", i.Type.String(), i.Plugin)
return c.reloadPluginInternal(ctx, path, i, i.Scope == "global")
}
// ReloadPluginInput is used as input to the ReloadPlugin function.
type ReloadPluginInput struct {
// Plugin is the name of the plugin to reload, as registered in the plugin catalog
@@ -292,15 +308,20 @@ func (c *Sys) ReloadPlugin(i *ReloadPluginInput) (string, error) {
}
// ReloadPluginWithContext reloads mounted plugin backends, possibly returning
// reloadId for a cluster scoped reload
// reloadID for a cluster scoped reload. It is limited to reloading plugins that
// are in use in the request's namespace. See RootReloadPlugin for an API that
// can reload plugins across all namespaces.
func (c *Sys) ReloadPluginWithContext(ctx context.Context, i *ReloadPluginInput) (string, error) {
return c.reloadPluginInternal(ctx, "/v1/sys/plugins/reload/backend", i, i.Scope == "global")
}
func (c *Sys) reloadPluginInternal(ctx context.Context, path string, body any, global bool) (string, error) {
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
defer cancelFunc()
path := "/v1/sys/plugins/reload/backend"
req := c.c.NewRequest(http.MethodPut, path)
if err := req.SetJSONBody(i); err != nil {
if err := req.SetJSONBody(body); err != nil {
return "", err
}
@@ -310,7 +331,7 @@ func (c *Sys) ReloadPluginWithContext(ctx context.Context, i *ReloadPluginInput)
}
defer resp.Body.Close()
if i.Scope == "global" {
if global {
// Get the reload id
secret, parseErr := ParseSecret(resp.Body)
if parseErr != nil {

6
changelog/24878.txt Normal file
View File

@@ -0,0 +1,6 @@
```release-note:improvement
plugins: New API `sys/plugins/reload/:type/:name` available in the root namespace for reloading a specific plugin across all namespaces.
```
```release-note:change
cli: Using `vault plugin reload` with `-plugin` in the root namespace will now reload the plugin across all namespaces instead of just the root namespace.
```

View File

@@ -249,7 +249,7 @@ func TestFlagParsing(t *testing.T) {
pluginType: api.PluginTypeUnknown,
name: "foo",
sha256: "abc123",
expectedPayload: `{"type":0,"command":"foo","sha256":"abc123"}`,
expectedPayload: `{"type":"unknown","command":"foo","sha256":"abc123"}`,
},
"full": {
pluginType: api.PluginTypeCredential,
@@ -261,14 +261,14 @@ func TestFlagParsing(t *testing.T) {
sha256: "abc123",
args: []string{"--a=b", "--b=c", "positional"},
env: []string{"x=1", "y=2"},
expectedPayload: `{"type":1,"args":["--a=b","--b=c","positional"],"command":"cmd","sha256":"abc123","version":"v1.0.0","oci_image":"image","runtime":"runtime","env":["x=1","y=2"]}`,
expectedPayload: `{"type":"auth","args":["--a=b","--b=c","positional"],"command":"cmd","sha256":"abc123","version":"v1.0.0","oci_image":"image","runtime":"runtime","env":["x=1","y=2"]}`,
},
"command remains empty if oci_image specified": {
pluginType: api.PluginTypeCredential,
name: "name",
ociImage: "image",
sha256: "abc123",
expectedPayload: `{"type":1,"sha256":"abc123","oci_image":"image"}`,
expectedPayload: `{"type":"auth","sha256":"abc123","oci_image":"image"}`,
},
} {
tc := tc

View File

@@ -4,6 +4,7 @@
package command
import (
"context"
"fmt"
"strings"
@@ -19,9 +20,10 @@ var (
type PluginReloadCommand struct {
*BaseCommand
plugin string
mounts []string
scope string
plugin string
mounts []string
scope string
pluginType string
}
func (c *PluginReloadCommand) Synopsis() string {
@@ -36,9 +38,16 @@ Usage: vault plugin reload [options]
mount(s) must be provided, but not both. In case the plugin name is provided,
all of its corresponding mounted paths that use the plugin backend will be reloaded.
Reload the plugin named "my-custom-plugin":
If run with a Vault namespace other than the root namespace, only plugins
running in the same namespace will be reloaded.
$ vault plugin reload -plugin=my-custom-plugin
Reload the secret plugin named "my-custom-plugin" on the current node:
$ vault plugin reload -type=secret -plugin=my-custom-plugin
Reload the secret plugin named "my-custom-plugin" across all nodes and replicated clusters:
$ vault plugin reload -type=secret -plugin=my-custom-plugin -scope=global
` + c.Flags().Help()
@@ -68,7 +77,15 @@ func (c *PluginReloadCommand) Flags() *FlagSets {
Name: "scope",
Target: &c.scope,
Completion: complete.PredictAnything,
Usage: "The scope of the reload, omitted for local, 'global', for replicated reloads",
Usage: "The scope of the reload, omitted for local, 'global', for replicated reloads.",
})
f.StringVar(&StringVar{
Name: "type",
Target: &c.pluginType,
Completion: complete.PredictAnything,
Usage: "The type of plugin to reload, one of auth, secret, or database. Mutually " +
"exclusive with -mounts. If not provided, all plugins with a matching name will be reloaded.",
})
return set
@@ -103,6 +120,10 @@ func (c *PluginReloadCommand) Run(args []string) int {
return 1
case c.scope != "" && c.scope != "global":
c.UI.Error(fmt.Sprintf("Invalid reload scope: %s", c.scope))
return 1
case len(c.mounts) > 0 && c.pluginType != "":
c.UI.Error("Cannot specify -type with -mounts")
return 1
}
client, err := c.Client()
@@ -111,25 +132,46 @@ func (c *PluginReloadCommand) Run(args []string) int {
return 2
}
rid, err := client.Sys().ReloadPlugin(&api.ReloadPluginInput{
Plugin: c.plugin,
Mounts: c.mounts,
Scope: c.scope,
})
var reloadID string
if client.Namespace() == "" {
pluginType := api.PluginTypeUnknown
pluginTypeStr := strings.TrimSpace(c.pluginType)
if pluginTypeStr != "" {
var err error
pluginType, err = api.ParsePluginType(pluginTypeStr)
if err != nil {
c.UI.Error(fmt.Sprintf("Error parsing -type as a plugin type, must be unset or one of auth, secret, or database: %s", err))
return 1
}
}
reloadID, err = client.Sys().RootReloadPlugin(context.Background(), &api.RootReloadPluginInput{
Plugin: c.plugin,
Type: pluginType,
Scope: c.scope,
})
} else {
reloadID, err = client.Sys().ReloadPlugin(&api.ReloadPluginInput{
Plugin: c.plugin,
Mounts: c.mounts,
Scope: c.scope,
})
}
if err != nil {
c.UI.Error(fmt.Sprintf("Error reloading plugin/mounts: %s", err))
return 2
}
if len(c.mounts) > 0 {
if rid != "" {
c.UI.Output(fmt.Sprintf("Success! Reloading mounts: %s, reload_id: %s", c.mounts, rid))
if reloadID != "" {
c.UI.Output(fmt.Sprintf("Success! Reloading mounts: %s, reload_id: %s", c.mounts, reloadID))
} else {
c.UI.Output(fmt.Sprintf("Success! Reloaded mounts: %s", c.mounts))
}
} else {
if rid != "" {
c.UI.Output(fmt.Sprintf("Success! Reloading plugin: %s, reload_id: %s", c.plugin, rid))
if reloadID != "" {
c.UI.Output(fmt.Sprintf("Success! Reloading plugin: %s, reload_id: %s", c.plugin, reloadID))
} else {
c.UI.Output(fmt.Sprintf("Success! Reloaded plugin: %s", c.plugin))
}

View File

@@ -55,6 +55,18 @@ func TestPluginReloadCommand_Run(t *testing.T) {
"Must specify exactly one of -plugin or -mounts",
1,
},
{
"type_and_mounts_mutually_exclusive",
[]string{"-mounts", "bar", "-type", "secret"},
"Cannot specify -type with -mounts",
1,
},
{
"invalid_type",
[]string{"-plugin", "bar", "-type", "unsupported"},
"Error parsing -type as a plugin type",
1,
},
}
for _, tc := range cases {

View File

@@ -7,7 +7,10 @@ package consts
// https://github.com/hashicorp/vault/blob/main/api/plugin_types.go
// Any changes made should be made to both files at the same time.
import "fmt"
import (
"encoding/json"
"fmt"
)
var PluginTypes = []PluginType{
PluginTypeUnknown,
@@ -64,3 +67,34 @@ func ParsePluginType(pluginType string) (PluginType, error) {
return PluginTypeUnknown, fmt.Errorf("%q is not a supported plugin type", pluginType)
}
}
// UnmarshalJSON implements json.Unmarshaler. It supports unmarshaling either a
// string or a uint32. All new serialization will be as a string, but we
// previously serialized as a uint32 so we need to support that for backwards
// compatibility.
func (p *PluginType) UnmarshalJSON(data []byte) error {
var asString string
err := json.Unmarshal(data, &asString)
if err == nil {
*p, err = ParsePluginType(asString)
return err
}
var asUint32 uint32
err = json.Unmarshal(data, &asUint32)
if err != nil {
return err
}
*p = PluginType(asUint32)
switch *p {
case PluginTypeUnknown, PluginTypeCredential, PluginTypeDatabase, PluginTypeSecrets:
return nil
default:
return fmt.Errorf("%d is not a supported plugin type", asUint32)
}
}
// MarshalJSON implements json.Marshaler.
func (p PluginType) MarshalJSON() ([]byte, error) {
return json.Marshal(p.String())
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package consts
// NOTE: this file has been copied to
// https://github.com/hashicorp/vault/blob/main/api/plugin_types_test.go
// Any changes made should be made to both files at the same time.
import (
"encoding/json"
"testing"
)
type testType struct {
PluginType PluginType `json:"plugin_type"`
}
func TestPluginTypeJSONRoundTrip(t *testing.T) {
for _, pluginType := range PluginTypes {
original := testType{
PluginType: pluginType,
}
asBytes, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var roundTripped testType
err = json.Unmarshal(asBytes, &roundTripped)
if err != nil {
t.Fatal(err)
}
if original != roundTripped {
t.Fatalf("expected %v, got %v", original, roundTripped)
}
}
}
func TestPluginTypeJSONUnmarshal(t *testing.T) {
// Failure/unsupported cases.
for name, tc := range map[string]string{
"unsupported": `{"plugin_type":"unsupported"}`,
"random string": `{"plugin_type":"foo"}`,
"boolean": `{"plugin_type":true}`,
"empty": `{"plugin_type":""}`,
"negative": `{"plugin_type":-1}`,
"out of range": `{"plugin_type":10}`,
} {
t.Run(name, func(t *testing.T) {
var result testType
err := json.Unmarshal([]byte(tc), &result)
if err == nil {
t.Fatal("expected error")
}
})
}
// Valid cases.
for name, tc := range map[string]struct {
json string
expected PluginType
}{
"unknown": {`{"plugin_type":"unknown"}`, PluginTypeUnknown},
"auth": {`{"plugin_type":"auth"}`, PluginTypeCredential},
"secret": {`{"plugin_type":"secret"}`, PluginTypeSecrets},
"database": {`{"plugin_type":"database"}`, PluginTypeDatabase},
"absent": {`{}`, PluginTypeUnknown},
"integer unknown": {`{"plugin_type":0}`, PluginTypeUnknown},
"integer auth": {`{"plugin_type":1}`, PluginTypeCredential},
"integer db": {`{"plugin_type":2}`, PluginTypeDatabase},
"integer secret": {`{"plugin_type":3}`, PluginTypeSecrets},
} {
t.Run(name, func(t *testing.T) {
var result testType
err := json.Unmarshal([]byte(tc.json), &result)
if err != nil {
t.Fatal(err)
}
if tc.expected != result.PluginType {
t.Fatalf("expected %v, got %v", tc.expected, result.PluginType)
}
})
}
}
func TestUnknownTypeExcludedWithOmitEmpty(t *testing.T) {
type testTypeOmitEmpty struct {
Type PluginType `json:"type,omitempty"`
}
bytes, err := json.Marshal(testTypeOmitEmpty{})
if err != nil {
t.Fatal(err)
}
m := map[string]any{}
json.Unmarshal(bytes, &m)
if _, exists := m["type"]; exists {
t.Fatal("type should not be present")
}
}

View File

@@ -5,13 +5,19 @@ package mock
import (
"context"
"fmt"
"os"
"testing"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const MockPluginVersionEnv = "TESTING_MOCK_VAULT_PLUGIN_VERSION"
const (
MockPluginVersionEnv = "TESTING_MOCK_VAULT_PLUGIN_VERSION"
MockPluginDefaultInternalValue = "bar"
)
// New returns a new backend as an interface. This func
// is only necessary for builtin backend plugins.
@@ -64,7 +70,7 @@ func Backend() *backend {
Invalidate: b.invalidate,
BackendType: logical.TypeLogical,
}
b.internal = "bar"
b.internal = MockPluginDefaultInternalValue
b.RunningVersion = "v0.0.0+mock"
if version := os.Getenv(MockPluginVersionEnv); version != "" {
b.RunningVersion = version
@@ -75,7 +81,7 @@ func Backend() *backend {
type backend struct {
*framework.Backend
// internal is used to test invalidate
// internal is used to test invalidate and reloads.
internal string
}
@@ -85,3 +91,39 @@ func (b *backend) invalidate(ctx context.Context, key string) {
b.internal = ""
}
}
// WriteInternalValue is a helper to set an in-memory value in the plugin,
// allowing tests to later assert that the plugin either has or hasn't been
// restarted.
func WriteInternalValue(t *testing.T, client *api.Client, mountPath, value string) {
t.Helper()
resp, err := client.Logical().Write(fmt.Sprintf("%s/internal", mountPath), map[string]interface{}{
"value": value,
})
if err != nil {
t.Fatalf("err: %v", err)
}
if resp != nil {
t.Fatalf("bad: %v", resp)
}
}
// ExpectInternalValue checks the internal in-memory value.
func ExpectInternalValue(t *testing.T, client *api.Client, mountPath, expected string) {
t.Helper()
expectInternalValue(t, client, mountPath, expected)
}
func expectInternalValue(t *testing.T, client *api.Client, mountPath, expected string) {
t.Helper()
resp, err := client.Logical().Read(fmt.Sprintf("%s/internal", mountPath))
if err != nil {
t.Fatalf("err: %v", err)
}
if resp == nil {
t.Fatalf("bad: response should not be nil")
}
if resp.Data["value"].(string) != expected {
t.Fatalf("expected %q but got %q", expected, resp.Data["value"].(string))
}
}

View File

@@ -466,14 +466,21 @@ func TestSystemBackend_Plugin_SealUnseal(t *testing.T) {
}
func TestSystemBackend_Plugin_reload(t *testing.T) {
// Paths being tested.
const (
reloadBackendPath = "sys/plugins/reload/backend"
rootReloadPath = "sys/plugins/reload/%s/%s"
)
testCases := []struct {
name string
backendType logical.BackendType
path string
data map[string]interface{}
}{
{
name: "test plugin reload for type credential",
backendType: logical.TypeCredential,
path: reloadBackendPath,
data: map[string]interface{}{
"plugin": "mock-plugin",
},
@@ -481,6 +488,7 @@ func TestSystemBackend_Plugin_reload(t *testing.T) {
{
name: "test mount reload for type credential",
backendType: logical.TypeCredential,
path: reloadBackendPath,
data: map[string]interface{}{
"mounts": "sys/auth/mock-0/,auth/mock-1/",
},
@@ -488,6 +496,7 @@ func TestSystemBackend_Plugin_reload(t *testing.T) {
{
name: "test plugin reload for type secret",
backendType: logical.TypeLogical,
path: reloadBackendPath,
data: map[string]interface{}{
"plugin": "mock-plugin",
},
@@ -495,21 +504,38 @@ func TestSystemBackend_Plugin_reload(t *testing.T) {
{
name: "test mount reload for type secret",
backendType: logical.TypeLogical,
path: reloadBackendPath,
data: map[string]interface{}{
"mounts": "mock-0/,mock-1",
},
},
{
name: "root plugin reload for type auth",
backendType: logical.TypeCredential,
path: fmt.Sprintf(rootReloadPath, "auth", "mock-plugin"),
},
{
name: "root plugin reload for type secret",
backendType: logical.TypeLogical,
path: fmt.Sprintf(rootReloadPath, "secret", "mock-plugin"),
},
{
name: "root plugin reload for unknown type",
backendType: logical.TypeUnknown,
path: fmt.Sprintf(rootReloadPath, "unknown", "mock-plugin"),
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
testSystemBackend_PluginReload(t, tc.data, tc.backendType)
testSystemBackend_PluginReload(t, tc.path, tc.data, tc.backendType)
})
}
}
// Helper func to test different reload methods on plugin reload endpoint
func testSystemBackend_PluginReload(t *testing.T, reqData map[string]interface{}, backendType logical.BackendType) {
func testSystemBackend_PluginReload(t *testing.T, path string, reqData map[string]interface{}, backendType logical.BackendType) {
testCases := []struct {
pluginVersion string
}{
@@ -532,25 +558,25 @@ func testSystemBackend_PluginReload(t *testing.T, reqData map[string]interface{}
core := cluster.Cores[0]
client := core.Client
pathPrefix := "mock-"
var mountPaths []string
if backendType == logical.TypeCredential {
pathPrefix = "auth/" + pathPrefix
}
for i := 0; i < 2; i++ {
// Update internal value in the backend
resp, err := client.Logical().Write(fmt.Sprintf("%s%d/internal", pathPrefix, i), map[string]interface{}{
"value": "baz",
})
if err != nil {
t.Fatalf("err: %v", err)
}
if resp != nil {
t.Fatalf("bad: %v", resp)
}
mountPaths = []string{"auth/mock-0", "auth/mock-1"}
} else {
mountPaths = []string{"mock-0", "mock-1"}
}
// Perform plugin reload
resp, err := client.Logical().Write("sys/plugins/reload/backend", reqData)
for _, mountPath := range mountPaths {
// Update internal value in the backend
mock.WriteInternalValue(t, client, mountPath, "baz")
}
// Verify our precondition that the write succeeded.
for _, mountPath := range mountPaths {
mock.ExpectInternalValue(t, client, mountPath, "baz")
}
// Perform plugin reload which should reset the value.
resp, err := client.Logical().Write(path, reqData)
if err != nil {
t.Fatalf("err: %v", err)
}
@@ -564,18 +590,9 @@ func testSystemBackend_PluginReload(t *testing.T, reqData map[string]interface{}
t.Fatal(resp.Warnings)
}
for i := 0; i < 2; i++ {
// Ensure internal backed value is reset
resp, err := client.Logical().Read(fmt.Sprintf("%s%d/internal", pathPrefix, i))
if err != nil {
t.Fatalf("err: %v", err)
}
if resp == nil {
t.Fatalf("bad: response should not be nil")
}
if resp.Data["value"].(string) == "baz" {
t.Fatal("did not expect backend internal value to be 'baz'")
}
// Ensure internal backed value is reset
for _, mountPath := range mountPaths {
mock.ExpectInternalValue(t, client, mountPath, mock.MockPluginDefaultInternalValue)
}
})
}
@@ -643,7 +660,7 @@ func testSystemBackendMock(t *testing.T, numCores, numMounts int, backendType lo
env := []string{pluginutil.PluginCACertPEMEnv + "=" + cluster.CACertPEMFile}
switch backendType {
case logical.TypeLogical:
case logical.TypeLogical, logical.TypeUnknown:
plugin := logicalVersionMap[pluginVersion]
vault.TestAddTestPlugin(t, core.Core, "mock-plugin", consts.PluginTypeSecrets, "", plugin, env)
for i := 0; i < numMounts; i++ {

View File

@@ -199,6 +199,7 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf
b.Backend.Paths = append(b.Backend.Paths, b.pluginsCatalogListPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.pluginsCatalogCRUDPath())
b.Backend.Paths = append(b.Backend.Paths, b.pluginsReloadPath())
b.Backend.Paths = append(b.Backend.Paths, b.pluginsRootReloadPath())
b.Backend.Paths = append(b.Backend.Paths, b.pluginsRuntimesCatalogCRUDPath())
b.Backend.Paths = append(b.Backend.Paths, b.pluginsRuntimesCatalogListPaths()...)
b.Backend.Paths = append(b.Backend.Paths, b.auditPaths()...)
@@ -744,8 +745,13 @@ func (b *SystemBackend) handlePluginReloadUpdate(ctx context.Context, req *logic
},
}
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, err
}
if pluginName != "" {
reloaded, err := b.Core.reloadMatchingPlugin(ctx, pluginName)
reloaded, err := b.Core.reloadMatchingPlugin(ctx, ns, consts.PluginTypeUnknown, pluginName)
if err != nil {
return nil, err
}
@@ -757,14 +763,84 @@ func (b *SystemBackend) handlePluginReloadUpdate(ctx context.Context, req *logic
}
}
} else if len(pluginMounts) > 0 {
err := b.Core.reloadMatchingPluginMounts(ctx, pluginMounts)
err := b.Core.reloadMatchingPluginMounts(ctx, ns, pluginMounts)
if err != nil {
return nil, err
}
}
if scope == globalScope {
err := handleGlobalPluginReload(ctx, b.Core, req.ID, pluginName, pluginMounts)
reloadRequest := pluginReloadRequest{
Namespace: ns,
Timestamp: time.Now(),
ReloadID: req.ID,
PluginType: consts.PluginTypeUnknown,
}
if pluginName != "" {
reloadRequest.Type = pluginReloadPluginsType
reloadRequest.Subjects = []string{pluginName}
} else {
reloadRequest.Type = pluginReloadMountsType
reloadRequest.Subjects = pluginMounts
}
err = handleGlobalPluginReload(ctx, b.Core, reloadRequest)
if err != nil {
return nil, err
}
return logical.RespondWithStatusCode(&resp, req, http.StatusAccepted)
}
return &resp, nil
}
func (b *SystemBackend) handleRootPluginReloadUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
pluginName := d.Get("name").(string)
pluginTypeStr := d.Get("type").(string)
scope := d.Get("scope").(string)
if pluginName == "" {
return logical.ErrorResponse("'plugin' must be provided"), nil
}
if pluginTypeStr == "" {
return logical.ErrorResponse("'type' must be provided"), nil
}
pluginType, err := consts.ParsePluginType(pluginTypeStr)
if err != nil {
return logical.ErrorResponse(`error parsing %q as a plugin type, must be one of "auth", "secret", "database", or "unknown"`, pluginTypeStr), nil
}
if scope != "" && scope != globalScope {
return logical.ErrorResponse("reload scope must be omitted or 'global'"), nil
}
resp := logical.Response{
Data: map[string]interface{}{
"reload_id": req.ID,
},
}
reloaded, err := b.Core.reloadMatchingPlugin(ctx, nil, pluginType, pluginName)
if err != nil {
return nil, err
}
if reloaded == 0 {
if scope == globalScope {
resp.AddWarning("no plugins were reloaded locally (but they may be reloaded on other nodes)")
} else {
resp.AddWarning("no plugins were reloaded")
}
}
if scope == globalScope {
reloadRequest := pluginReloadRequest{
Type: pluginReloadPluginsType,
Subjects: []string{pluginName},
PluginType: pluginType,
Namespace: nil,
Timestamp: time.Now(),
ReloadID: req.ID,
}
err = handleGlobalPluginReload(ctx, b.Core, reloadRequest)
if err != nil {
return nil, err
}
@@ -6401,6 +6477,36 @@ This path responds to the following HTTP methods.
`The mount paths of the plugin backends to reload.`,
"",
},
"plugin-backend-reload-scope": {
`The scope for the reload operation. May be empty or "global".`,
`The scope for the reload operation. May be empty or "global". If empty,
the plugin(s) will be reloaded only on the local node. If "global", the
plugin(s) will be reloaded on all nodes in the cluster and in all replicated
clusters.`,
},
"root-plugin-reload": {
"Reload all instances of a specific plugin.",
`Reload all plugins of a specific name and type across all namespaces. If
"scope" is provided and is "global", the plugin is reloaded across all
nodes and clusters. If a new plugin version has been pinned, this will
ensure all instances start using the new version.`,
},
"root-plugin-reload-name": {
`The name of the plugin to reload, as registered in the plugin catalog.`,
"",
},
"root-plugin-reload-type": {
`The type of the plugin to reload, as registered in the plugin catalog.`,
"",
},
"root-plugin-reload-scope": {
`The scope for the reload operation. May be empty or "global".`,
`The scope for the reload operation. May be empty or "global". If empty,
the plugin will be reloaded only on the local node. If "global", the
plugin will be reloaded on all nodes in the cluster and in all replicated
clusters. A "global" reload will ensure that any pinned version specified
is in full effect.`,
},
"hash": {
"Generate a hash sum for input data",
"Generates a hash sum of the given algorithm against the given input data.",

View File

@@ -13,6 +13,7 @@ import (
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/logical"
)
@@ -263,7 +264,7 @@ var (
return paths
}
handleGlobalPluginReload = func(context.Context, *Core, string, string, []string) error {
handleGlobalPluginReload = func(context.Context, *Core, pluginReloadRequest) error {
return nil
}
handleSetupPluginReload = func(*Core) error {
@@ -277,6 +278,16 @@ var (
checkRaw = func(b *SystemBackend, path string) error { return nil }
)
// Contains the config for a global plugin reload
type pluginReloadRequest struct {
Type string `json:"type"` // Either 'plugins' or 'mounts'
PluginType consts.PluginType `json:"plugin_type"`
Subjects []string `json:"subjects"` // The plugin names or mount points for the reload
ReloadID string `json:"reload_id"` // a UUID for the request
Timestamp time.Time `json:"timestamp"`
Namespace *namespace.Namespace
}
// tuneMount is used to set config on a mount point
func (b *SystemBackend) tuneMountTTLs(ctx context.Context, path string, me *MountEntry, newDefault, newMax time.Duration) error {
zero := time.Duration(0)

View File

@@ -2109,6 +2109,66 @@ func (b *SystemBackend) pluginsReloadPath() *framework.Path {
}
}
func (b *SystemBackend) pluginsRootReloadPath() *framework.Path {
return &framework.Path{
// Unknown plugin type is allowed to make it easier for the CLI changes to be more backwards compatible.
Pattern: "plugins/reload/(?P<type>auth|database|secret|unknown)/" + framework.GenericNameRegex("name") + "$",
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "reload",
OperationSuffix: "plugins",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: strings.TrimSpace(sysHelp["root-plugin-reload-name"][0]),
Required: true,
},
"type": {
Type: framework.TypeString,
Description: strings.TrimSpace(sysHelp["root-plugin-reload-type"][0]),
Required: true,
},
"scope": {
Type: framework.TypeString,
Description: strings.TrimSpace(sysHelp["root-plugin-reload-scope"][0]),
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.handleRootPluginReloadUpdate,
Responses: map[int][]framework.Response{
http.StatusOK: {{
Description: "OK",
Fields: map[string]*framework.FieldSchema{
"reload_id": {
Type: framework.TypeString,
Required: true,
},
},
}},
http.StatusAccepted: {{
Description: "OK",
Fields: map[string]*framework.FieldSchema{
"reload_id": {
Type: framework.TypeString,
Required: true,
},
},
}},
},
Summary: "Reload all instances of a specific plugin.",
Description: `Reload all plugins of a specific name and type across all namespaces. If "scope" is provided and is "global", the plugin is reloaded across all nodes and clusters. If a new plugin version has been pinned, this will ensure all instances start using the new version.`,
},
},
HelpSynopsis: strings.TrimSpace(sysHelp["root-plugin-reload"][0]),
HelpDescription: strings.TrimSpace(sysHelp["root-plugin-reload"][1]),
}
}
func (b *SystemBackend) pluginsRuntimesCatalogCRUDPath() *framework.Path {
return &framework.Path{
Pattern: "plugins/runtimes/catalog/(?P<type>container)/" + framework.GenericNameRegex("name"),

View File

@@ -18,19 +18,19 @@ import (
"github.com/hashicorp/vault/sdk/plugin"
)
const (
pluginReloadPluginsType = "plugins"
pluginReloadMountsType = "mounts"
)
// reloadMatchingPluginMounts reloads provided mounts, regardless of
// plugin name, as long as the backend type is plugin.
func (c *Core) reloadMatchingPluginMounts(ctx context.Context, mounts []string) error {
func (c *Core) reloadMatchingPluginMounts(ctx context.Context, ns *namespace.Namespace, mounts []string) error {
c.mountsLock.RLock()
defer c.mountsLock.RUnlock()
c.authLock.RLock()
defer c.authLock.RUnlock()
ns, err := namespace.FromContext(ctx)
if err != nil {
return err
}
var errors error
for _, mount := range mounts {
var isAuth bool
@@ -73,70 +73,87 @@ func (c *Core) reloadMatchingPluginMounts(ctx context.Context, mounts []string)
// reloadMatchingPlugin reloads all mounted backends that are named pluginName
// (name of the plugin as registered in the plugin catalog). It returns the
// number of plugins that were reloaded and an error if any.
func (c *Core) reloadMatchingPlugin(ctx context.Context, pluginName string) (reloaded int, err error) {
c.mountsLock.RLock()
defer c.mountsLock.RUnlock()
c.authLock.RLock()
defer c.authLock.RUnlock()
ns, err := namespace.FromContext(ctx)
if err != nil {
return reloaded, err
func (c *Core) reloadMatchingPlugin(ctx context.Context, ns *namespace.Namespace, pluginType consts.PluginType, pluginName string) (reloaded int, err error) {
var secrets, auth, database bool
switch pluginType {
case consts.PluginTypeSecrets:
secrets = true
case consts.PluginTypeCredential:
auth = true
case consts.PluginTypeDatabase:
database = true
case consts.PluginTypeUnknown:
secrets = true
auth = true
database = true
default:
return reloaded, fmt.Errorf("unsupported plugin type %q", pluginType.String())
}
for _, entry := range c.mounts.Entries {
// We dont reload mounts that are not in the same namespace
if ns.ID != entry.Namespace().ID {
continue
}
if secrets || database {
c.mountsLock.RLock()
defer c.mountsLock.RUnlock()
if entry.Type == pluginName || (entry.Type == "plugin" && entry.Config.PluginName == pluginName) {
err := c.reloadBackendCommon(ctx, entry, false)
if err != nil {
return reloaded, err
}
reloaded++
c.logger.Info("successfully reloaded plugin", "plugin", pluginName, "namespace", entry.Namespace(), "path", entry.Path, "version", entry.Version)
} else if entry.Type == "database" {
// The combined database plugin is itself a secrets engine, but
// knowledge of whether a database plugin is in use within a particular
// mount is internal to the combined database plugin's storage, so
// we delegate the reload request with an internally routed request.
req := &logical.Request{
Operation: logical.UpdateOperation,
Path: entry.Path + "reload/" + pluginName,
}
resp, err := c.router.Route(ctx, req)
if err != nil {
return reloaded, err
}
if resp == nil {
return reloaded, fmt.Errorf("failed to reload %q database plugin(s) mounted under %s", pluginName, entry.Path)
}
if resp.IsError() {
return reloaded, fmt.Errorf("failed to reload %q database plugin(s) mounted under %s: %s", pluginName, entry.Path, resp.Error())
for _, entry := range c.mounts.Entries {
// We don't reload mounts that are not in the same namespace
if ns != nil && ns.ID != entry.Namespace().ID {
continue
}
if count, ok := resp.Data["count"].(int); ok && count > 0 {
c.logger.Info("successfully reloaded database plugin(s)", "plugin", pluginName, "namespace", entry.Namespace(), "path", entry.Path, "connections", resp.Data["connections"])
reloaded += count
if secrets && (entry.Type == pluginName || (entry.Type == "plugin" && entry.Config.PluginName == pluginName)) {
err := c.reloadBackendCommon(ctx, entry, false)
if err != nil {
return reloaded, err
}
reloaded++
c.logger.Info("successfully reloaded plugin", "plugin", pluginName, "namespace", entry.Namespace(), "path", entry.Path, "version", entry.Version)
} else if database && entry.Type == "database" {
// The combined database plugin is itself a secrets engine, but
// knowledge of whether a database plugin is in use within a particular
// mount is internal to the combined database plugin's storage, so
// we delegate the reload request with an internally routed request.
reqCtx := namespace.ContextWithNamespace(ctx, entry.namespace)
req := &logical.Request{
Operation: logical.UpdateOperation,
Path: entry.Path + "reload/" + pluginName,
}
resp, err := c.router.Route(reqCtx, req)
if err != nil {
return reloaded, err
}
if resp == nil {
return reloaded, fmt.Errorf("failed to reload %q database plugin(s) mounted under %s", pluginName, entry.Path)
}
if resp.IsError() {
return reloaded, fmt.Errorf("failed to reload %q database plugin(s) mounted under %s: %s", pluginName, entry.Path, resp.Error())
}
if count, ok := resp.Data["count"].(int); ok && count > 0 {
c.logger.Info("successfully reloaded database plugin(s)", "plugin", pluginName, "namespace", entry.Namespace(), "path", entry.Path, "connections", resp.Data["connections"])
reloaded += count
}
}
}
}
for _, entry := range c.auth.Entries {
// We dont reload mounts that are not in the same namespace
if ns.ID != entry.Namespace().ID {
continue
}
if auth {
c.authLock.RLock()
defer c.authLock.RUnlock()
if entry.Type == pluginName || (entry.Type == "plugin" && entry.Config.PluginName == pluginName) {
err := c.reloadBackendCommon(ctx, entry, true)
if err != nil {
return reloaded, err
for _, entry := range c.auth.Entries {
// We don't reload mounts that are not in the same namespace
if ns != nil && ns.ID != entry.Namespace().ID {
continue
}
if entry.Type == pluginName || (entry.Type == "plugin" && entry.Config.PluginName == pluginName) {
err := c.reloadBackendCommon(ctx, entry, true)
if err != nil {
return reloaded, err
}
reloaded++
c.logger.Info("successfully reloaded plugin", "plugin", entry.Accessor, "path", entry.Path, "version", entry.Version)
}
reloaded++
c.logger.Info("successfully reloaded plugin", "plugin", entry.Accessor, "path", entry.Path, "version", entry.Version)
}
}

View File

@@ -1,50 +0,0 @@
---
layout: api
page_title: /sys/plugins/reload/backend - HTTP API
description: The `/sys/plugins/reload/backend` endpoint is used to reload plugin backends.
---
# `/sys/plugins/reload/backend`
The `/sys/plugins/reload/backend` endpoint is used to reload mounted plugin
backends. Either the plugin name (`plugin`) or the desired plugin backend mounts
(`mounts`) must be provided, but not both. In the case that the plugin name is
provided, all mounted paths that use that plugin backend will be reloaded.
## Reload plugins
This endpoint reloads mounted plugin backends.
| Method | Path - |
| :----- | :---------------------------- |
| `POST` | `/sys/plugins/reload/backend` |
### Parameters
- `plugin` `(string: "")`  The name of the plugin to reload, as
registered in the plugin catalog.
- `mounts` `(array: [])`  Array or comma-separated string mount paths
of the plugin backends to reload.
- `scope` `(string: "")` - The scope of the reload. If omitted, reloads the
plugin or mounts on this Vault instance. If 'global', will begin reloading the
plugin on all instances of a cluster.
### Sample payload
```json
{
"plugin": "mock-plugin"
}
```
### Sample request
```shell-session
$ curl \
--header "X-Vault-Token: ..." \
--request POST \
--data @payload.json \
http://127.0.0.1:8200/v1/sys/plugins/reload/backend
```

View File

@@ -0,0 +1,122 @@
---
layout: api
page_title: /sys/plugins/reload - HTTP API
description: The `/sys/plugins/reload` endpoints are used to reload plugins.
---
# `/sys/plugins/reload`
## Reload plugin
The `/sys/plugins/reload/:type/:name` endpoint reloads a named plugin across all
namespaces. It is only available in the root namespace. All instances of the plugin
will be killed, and any newly pinned version of the plugin will be started in
their place.
| Method | Path |
| :----- | :-------------------------------- |
| `POST` | `/sys/plugins/reload/:type/:name` |
### Parameters
- `type` `(string: <required>)`  The type of the plugin, as registered in the
plugin catalog. One of "auth", "secret", "database", or "unknown". If "unknown",
all plugin types with the provided name will be reloaded.
- `name` `(string: <required>)`  The name of the plugin to reload, as registered
in the plugin catalog.
- `scope` `(string: "")` - The scope of the reload. If omitted, reloads the
plugin or mounts on this Vault instance. If 'global', will begin reloading the
plugin on all instances of a cluster.
### Sample payload
```json
{
"scope": "global"
}
```
### Sample request
```shell-session
$ curl \
--header "X-Vault-Token: ..." \
--request POST \
--data @payload.json \
http://127.0.0.1:8200/v1/sys/plugins/reload/auth/mock-plugin
```
### Sample response
```json
{
"data": {
"reload_id": "bdddb8df-ccb6-1b09-670d-efa9d3f2c11b"
},
...
}
```
-> Note: If no plugins are reloaded on the node that serviced the request, a
warning will also be returned in the response.
## Reload plugins within a namespace
The `/sys/plugins/reload/backend` endpoint is used to reload mounted plugin
backends. Either the plugin name (`plugin`) or the desired plugin backend mounts
(`mounts`) must be provided, but not both. In the case that the plugin name is
provided, all mounted paths that use that plugin backend will be reloaded.
This API is available in all namespaces, and is limited to reloading plugins in
use within the request's namespace.
| Method | Path - |
| :----- | :---------------------------- |
| `POST` | `/sys/plugins/reload/backend` |
### Parameters
- `plugin` `(string: "")`  The name of the plugin to reload, as
registered in the plugin catalog.
- `mounts` `(array: [])`  Array or comma-separated string mount paths
of the plugin backends to reload.
- `scope` `(string: "")` - The scope of the reload. If omitted, reloads the
plugin or mounts on this Vault instance. If 'global', will begin reloading the
plugin on all instances of a cluster.
### Sample payload
```json
{
"plugin": "mock-plugin",
"scope": "global"
}
```
### Sample request
```shell-session
$ curl \
--header "X-Vault-Token: ..." \
--request POST \
--data @payload.json \
http://127.0.0.1:8200/v1/sys/plugins/reload/backend
```
### Sample response
```json
{
"data": {
"reload_id": "bdddb8df-ccb6-1b09-670d-efa9d3f2c11b"
},
...
}
```
-> Note: If no plugins are reloaded on the node that serviced the request, a
warning will also be returned in the response.

View File

@@ -9,14 +9,18 @@ description: |-
The `plugin reload` command is used to reload mounted plugin backends. Either
the plugin name (`plugin`) or the desired plugin backend mounts (`mounts`)
must be provided, but not both. In the case that the plugin name is provided, all mounted paths that use that plugin backend will be reloaded.
must be provided, but not both. In the case that the plugin name is provided,
all mounted paths that use that plugin backend will be reloaded.
If run with a Vault namespace other than the root namespace, only plugins
running in the same namespace will be reloaded.
## Examples
Reload a plugin by name:
Reload a plugin by type and name:
```shell-session
$ vault plugin reload -plugin my-custom-plugin
$ vault plugin reload -type=auth -plugin my-custom-plugin
Success! Reloaded plugin: my-custom-plugin
```
@@ -38,6 +42,15 @@ $ vault plugin reload \
Success! Reloaded mounts: [my-custom-plugin-1/ my-custom-plugin-2/]
```
Reload a secrets plugin named "my-custom-plugin" across all nodes and replicated clusters:
```shell-session
$ vault plugin reload \
-type=secret \
-plugin=my-custom-plugin \
-scope=global
```
## Usage
The following flags are available in addition to the [standard set of
@@ -48,6 +61,10 @@ flags](/vault/docs/commands) included on all commands.
- `-plugin` `(string: "")` - The name of the plugin to reload, as registered in
the plugin catalog.
- `-type` `(string: "")` - The type of plugin to reload, one of auth, secret, or
database. Mutually exclusive with -mounts. If not provided, all plugins
with a matching name will be reloaded.
- `-mounts` `(array: [])` - Array or comma-separated string mount paths of the
plugin backends to reload.

View File

@@ -184,6 +184,6 @@ transit v1.12.0+builtin.vault
of leases and tokens is handled by core systems within Vault. The plugin itself only
handles renewal and revocation of them when its requested by those core systems.
[plugin_reload_api]: /vault/api-docs/system/plugins-reload-backend
[plugin_reload_api]: /vault/api-docs/system/plugins-reload
[plugin_registration]: /vault/docs/plugins/plugin-architecture#plugin-registration
[plugin_management]: /vault/docs/plugins/plugin-management#enabling-disabling-external-plugins

View File

@@ -602,8 +602,8 @@
"path": "system/namespaces"
},
{
"title": "<code>/sys/plugins/reload/backend</code>",
"path": "system/plugins-reload-backend"
"title": "<code>/sys/plugins/reload</code>",
"path": "system/plugins-reload"
},
{
"title": "<code>/sys/plugins/catalog</code>",

View File

@@ -104,5 +104,10 @@ module.exports = [
source: '/vault/docs/v1.13.x/agent-and-proxy/agent/apiproxy',
destination: '/vault/docs/v1.13.x/agent/apiproxy',
permanent: true,
},
{
source: '/vault/api-docs/system/plugins-reload-backend',
destination: '/vault/api-docs/system/plugins-reload',
permanent: true,
}
]