diff --git a/builtin/logical/database/backend.go b/builtin/logical/database/backend.go index 0fc6f615a5..693a22ff97 100644 --- a/builtin/logical/database/backend.go +++ b/builtin/logical/database/backend.go @@ -107,6 +107,7 @@ func Backend(conf *logical.BackendConfig) *databaseBackend { pathListPluginConnection(&b), pathConfigurePluginConnection(&b), pathResetConnection(&b), + pathReloadPlugin(&b), }, pathListRoles(&b), pathRoles(&b), diff --git a/builtin/logical/database/backend_test.go b/builtin/logical/database/backend_test.go index 173a6eea93..0554365e1c 100644 --- a/builtin/logical/database/backend_test.go +++ b/builtin/logical/database/backend_test.go @@ -626,6 +626,23 @@ func TestBackend_connectionCrud(t *testing.T) { t.Fatalf("err:%s resp:%#v\n", err, resp) } + // Configure a second connection to confirm below it doesn't get restarted. + data = map[string]interface{}{ + "connection_url": "test", + "plugin_name": "hana-database-plugin", + "verify_connection": false, + } + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/plugin-test-hana", + Storage: config.StorageView, + Data: data, + } + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + // Create a role data = map[string]interface{}{ "db_name": "plugin-test", @@ -717,17 +734,49 @@ func TestBackend_connectionCrud(t *testing.T) { t.Fatal(diff) } - // Reset Connection - data = map[string]interface{}{} - req = &logical.Request{ - Operation: logical.UpdateOperation, - Path: "reset/plugin-test", - Storage: config.StorageView, - Data: data, - } - resp, err = b.HandleRequest(namespace.RootContext(nil), req) - if err != nil || (resp != nil && resp.IsError()) { - t.Fatalf("err:%s resp:%#v\n", err, resp) + // Test endpoints for reloading plugins. + for _, reloadPath := range []string{ + "reset/plugin-test", + "reload/postgresql-database-plugin", + } { + getConnectionID := func(name string) string { + t.Helper() + dbBackend, ok := b.(*databaseBackend) + if !ok { + t.Fatal("could not convert logical.Backend to databaseBackend") + } + dbi := dbBackend.connections.Get(name) + if dbi == nil { + t.Fatal("no plugin-test dbi") + } + return dbi.ID() + } + initialID := getConnectionID("plugin-test") + hanaID := getConnectionID("plugin-test-hana") + req = &logical.Request{ + Operation: logical.UpdateOperation, + Path: reloadPath, + Storage: config.StorageView, + Data: map[string]interface{}{}, + } + resp, err = b.HandleRequest(namespace.RootContext(nil), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + if initialID == getConnectionID("plugin-test") { + t.Fatal("ID unchanged after connection reset") + } + if hanaID != getConnectionID("plugin-test-hana") { + t.Fatal("hana plugin got restarted but shouldn't have been") + } + if strings.HasPrefix(reloadPath, "reload/") { + if expected := 1; expected != resp.Data["count"] { + t.Fatalf("expected %d but got %d", expected, resp.Data["count"]) + } + if expected := []string{"plugin-test"}; !reflect.DeepEqual(expected, resp.Data["connections"]) { + t.Fatalf("expected %v but got %v", expected, resp.Data["connections"]) + } + } } // Get creds diff --git a/builtin/logical/database/path_config_connection.go b/builtin/logical/database/path_config_connection.go index 9c813ef2af..b017009da0 100644 --- a/builtin/logical/database/path_config_connection.go +++ b/builtin/logical/database/path_config_connection.go @@ -9,6 +9,7 @@ import ( "fmt" "net/url" "sort" + "strings" "github.com/fatih/structs" "github.com/hashicorp/go-uuid" @@ -94,13 +95,7 @@ func (b *databaseBackend) pathConnectionReset() framework.OperationFunc { return logical.ErrorResponse(respErrEmptyName), nil } - // Close plugin and delete the entry in the connections cache. - if err := b.ClearConnection(name); err != nil { - return nil, err - } - - // Execute plugin again, we don't need the object so throw away. - if _, err := b.GetConnection(ctx, req.Storage, name); err != nil { + if err := b.reloadConnection(ctx, req.Storage, name); err != nil { return nil, err } @@ -108,6 +103,103 @@ func (b *databaseBackend) pathConnectionReset() framework.OperationFunc { } } +func (b *databaseBackend) reloadConnection(ctx context.Context, storage logical.Storage, name string) error { + // Close plugin and delete the entry in the connections cache. + if err := b.ClearConnection(name); err != nil { + return err + } + + // Execute plugin again, we don't need the object so throw away. + if _, err := b.GetConnection(ctx, storage, name); err != nil { + return err + } + + return nil +} + +// pathReloadPlugin reloads all connections using a named plugin. +func pathReloadPlugin(b *databaseBackend) *framework.Path { + return &framework.Path{ + Pattern: fmt.Sprintf("reload/%s", framework.GenericNameRegex("plugin_name")), + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixDatabase, + OperationVerb: "reload", + OperationSuffix: "plugin", + }, + + Fields: map[string]*framework.FieldSchema{ + "plugin_name": { + Type: framework.TypeString, + Description: "Name of the database plugin", + }, + }, + + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.UpdateOperation: b.reloadPlugin(), + }, + + HelpSynopsis: pathReloadPluginHelpSyn, + HelpDescription: pathReloadPluginHelpDesc, + } +} + +// reloadPlugin reloads all instances of a named plugin by closing the existing +// instances and creating new ones. +func (b *databaseBackend) reloadPlugin() framework.OperationFunc { + return func(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { + pluginName := data.Get("plugin_name").(string) + if pluginName == "" { + return logical.ErrorResponse(respErrEmptyPluginName), nil + } + + connNames, err := req.Storage.List(ctx, "config/") + if err != nil { + return nil, err + } + reloaded := []string{} + for _, connName := range connNames { + entry, err := req.Storage.Get(ctx, fmt.Sprintf("config/%s", connName)) + if err != nil { + return nil, fmt.Errorf("failed to read connection configuration: %w", err) + } + if entry == nil { + continue + } + + var config DatabaseConfig + if err := entry.DecodeJSON(&config); err != nil { + return nil, err + } + if config.PluginName == pluginName { + if err := b.reloadConnection(ctx, req.Storage, connName); err != nil { + var successfullyReloaded string + if len(reloaded) > 0 { + successfullyReloaded = fmt.Sprintf("successfully reloaded %d connection(s): %s; ", + len(reloaded), + strings.Join(reloaded, ", ")) + } + return nil, fmt.Errorf("%sfailed to reload connection %q: %w", successfullyReloaded, connName, err) + } + reloaded = append(reloaded, connName) + } + } + + resp := &logical.Response{ + Data: map[string]interface{}{ + "connections": reloaded, + "count": len(reloaded), + }, + } + + if len(reloaded) == 0 { + resp.AddWarning(fmt.Sprintf("no connections were found with plugin_name %q", pluginName)) + } + + return resp, nil + } +} + // pathConfigurePluginConnection returns a configured framework.Path setup to // operate on plugins. func pathConfigurePluginConnection(b *databaseBackend) *framework.Path { @@ -551,3 +643,12 @@ const pathResetConnectionHelpDesc = ` This path resets the database connection by closing the existing database plugin instance and running a new one. ` + +const pathReloadPluginHelpSyn = ` +Reloads all connections using a named database plugin. +` + +const pathReloadPluginHelpDesc = ` +This path resets each database connection using a named plugin by closing each +existing database plugin instance and running a new one. +` diff --git a/changelog/24472.txt b/changelog/24472.txt new file mode 100644 index 0000000000..538bb2b4b7 --- /dev/null +++ b/changelog/24472.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/database: Add new reload/:plugin_name API to reload database plugins by name for a specific mount. +``` diff --git a/website/content/api-docs/secret/databases/index.mdx b/website/content/api-docs/secret/databases/index.mdx index 7ab3d4e592..2373410a04 100644 --- a/website/content/api-docs/secret/databases/index.mdx +++ b/website/content/api-docs/secret/databases/index.mdx @@ -250,6 +250,42 @@ $ curl \ http://127.0.0.1:8200/v1/database/reset/mysql ``` +## Reload plugin + +This endpoint performs the same operation as +[reset connection](/vault/api-docs/secret/databases#reset-connection) but for +all connections that reference a specific plugin name. This can be useful to +restart a specific plugin after it's been upgraded in the plugin catalog. + +| Method | Path | +| :----- | :------------------------------ | +| `POST` | `/database/reload/:plugin_name` | + +### Parameters + +- `plugin_name` `(string: )` – Specifies the name of the plugin for + which all connections should be reset. This is specified as part of the URL. + +### Sample request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + --request POST \ + http://127.0.0.1:8200/v1/database/reload/postgresql-database-plugin +``` + +### Sample response + +```json +{ + "data": { + "connections": ["pg1", "pg2"], + "count": 2 + } +} +``` + ## Rotate root credentials This endpoint is used to rotate the "root" user credentials stored for