diff --git a/builtin/logical/database/backend.go b/builtin/logical/database/backend.go index 86b14a537a55..94e20df76fb8 100644 --- a/builtin/logical/database/backend.go +++ b/builtin/logical/database/backend.go @@ -40,9 +40,10 @@ type dbPluginInstance struct { sync.RWMutex database databaseVersionWrapper - id string - name string - closed bool + id string + name string + runningPluginVersion string + closed bool } func (dbi *dbPluginInstance) ID() string { @@ -324,9 +325,10 @@ func (b *databaseBackend) GetConnectionWithConfig(ctx context.Context, name stri } dbi = &dbPluginInstance{ - database: dbw, - id: id, - name: name, + database: dbw, + id: id, + name: name, + runningPluginVersion: pluginVersion, } oldConn := b.connections.Put(name, dbi) if oldConn != nil { diff --git a/builtin/logical/database/path_config_connection.go b/builtin/logical/database/path_config_connection.go index 383a2bd45821..7e05dc6a22f0 100644 --- a/builtin/logical/database/path_config_connection.go +++ b/builtin/logical/database/path_config_connection.go @@ -31,8 +31,9 @@ var ( // DatabaseConfig is used by the Factory function to configure a Database // object. type DatabaseConfig struct { - PluginName string `json:"plugin_name" structs:"plugin_name" mapstructure:"plugin_name"` - PluginVersion string `json:"plugin_version" structs:"plugin_version" mapstructure:"plugin_version"` + PluginName string `json:"plugin_name" structs:"plugin_name" mapstructure:"plugin_name"` + PluginVersion string `json:"plugin_version" structs:"plugin_version" mapstructure:"plugin_version"` + RunningPluginVersion string `json:"running_plugin_version,omitempty" structs:"running_plugin_version,omitempty" mapstructure:"running_plugin_version,omitempty"` // ConnectionDetails stores the database specific connection settings needed // by each database type. ConnectionDetails map[string]interface{} `json:"connection_details" structs:"connection_details" mapstructure:"connection_details"` @@ -376,9 +377,22 @@ func (b *databaseBackend) connectionReadHandler() framework.OperationFunc { delete(config.ConnectionDetails, "private_key") delete(config.ConnectionDetails, "service_account_json") - return &logical.Response{ - Data: structs.New(config).Map(), - }, nil + resp := &logical.Response{} + if dbi, err := b.GetConnection(ctx, req.Storage, name); err == nil { + config.RunningPluginVersion = dbi.runningPluginVersion + if config.PluginVersion != "" && config.PluginVersion != config.RunningPluginVersion { + warning := fmt.Sprintf("Plugin version is configured as %q, but running %q", config.PluginVersion, config.RunningPluginVersion) + if pinnedVersion, _ := b.getPinnedVersion(ctx, config.PluginName); pinnedVersion == config.RunningPluginVersion { + warning += " because that version is pinned" + } else { + warning += " either due to a pinned version or because the plugin was upgraded and not yet reloaded" + } + resp.AddWarning(warning) + } + } + + resp.Data = structs.New(config).Map() + return resp, nil } } @@ -507,9 +521,10 @@ func (b *databaseBackend) connectionWriteHandler() framework.OperationFunc { // Close and remove the old connection oldConn := b.connections.Put(name, &dbPluginInstance{ - database: dbw, - name: name, - id: id, + database: dbw, + name: name, + id: id, + runningPluginVersion: pluginVersion, }) if oldConn != nil { oldConn.Close() diff --git a/changelog/25105.txt b/changelog/25105.txt new file mode 100644 index 000000000000..4a9ae100c3e3 --- /dev/null +++ b/changelog/25105.txt @@ -0,0 +1,6 @@ +```release-note:change +plugins/database: Reading connection config at `database/config/:name` will now return a computed `running_plugin_version` field if a non-builtin version is running. +``` +```release-note:improvement +plugins: Add new pin version APIs to enforce all plugins of a specific type and name to run the same version. +``` diff --git a/scripts/go-helper.sh b/scripts/go-helper.sh index ac2542979aa7..27fc0151cb57 100755 --- a/scripts/go-helper.sh +++ b/scripts/go-helper.sh @@ -38,6 +38,10 @@ check_fmt() { echo "--> The following files need to be reformatted with gofumpt" printf '%s\n' "${malformed[@]}" echo "Run \`make fmt\` to reformat code." + for file in "${malformed[@]}"; do + gofumpt -w "$file" + echo "$(git diff --no-color "$file")" + done exit 1 fi } diff --git a/vault/external_plugin_test.go b/vault/external_plugin_test.go index a332df23b617..03151d985e92 100644 --- a/vault/external_plugin_test.go +++ b/vault/external_plugin_test.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/vault/helper/testhelpers/pluginhelpers" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/consts" - "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/plugin" "github.com/hashicorp/vault/sdk/plugin/mock" @@ -96,98 +95,6 @@ func TestCore_EnableExternalPlugin(t *testing.T) { } } -// TestCore_UpgradePluginUsingPinnedVersion tests a full workflow of upgrading -// an external plugin gated by pinned versions. -func TestCore_UpgradePluginUsingPinnedVersion(t *testing.T) { - cluster := NewTestCluster(t, &CoreConfig{}, &TestClusterOptions{ - Plugins: []*TestPluginConfig{ - { - Typ: consts.PluginTypeCredential, - Versions: []string{""}, - }, - { - Typ: consts.PluginTypeSecrets, - Versions: []string{""}, - }, - }, - }) - - cluster.Start() - t.Cleanup(cluster.Cleanup) - - c := cluster.Cores[0].Core - TestWaitActive(t, c) - - for name, tc := range map[string]struct { - idx int - }{ - "credential plugin": { - idx: 0, - }, - "secrets plugin": { - idx: 1, - }, - } { - t.Run(name, func(t *testing.T) { - plugin := cluster.Plugins[tc.idx] - for _, version := range []string{"v1.0.0", "v1.0.1"} { - registerPlugin(t, c.systemBackend, plugin.Name, plugin.Typ.String(), version, plugin.Sha256, plugin.FileName) - } - - // Mount 1.0.0 then pin to 1.0.1 - mountPlugin(t, c.systemBackend, plugin.Name, plugin.Typ, "v1.0.0", "") - err := c.pluginCatalog.SetPinnedVersion(context.Background(), &pluginutil.PinnedVersion{ - Name: plugin.Name, - Type: plugin.Typ, - Version: "v1.0.1", - }) - if err != nil { - t.Fatal(err) - } - - mountedPath := "foo/" - if plugin.Typ == consts.PluginTypeCredential { - mountedPath = "auth/" + mountedPath - } - expectRunningVersion(t, c, mountedPath, "v1.0.0") - - reloaded, err := c.reloadMatchingPlugin(context.Background(), nil, plugin.Typ, plugin.Name) - if reloaded != 1 || err != nil { - t.Fatal(reloaded, err) - } - - // Pinned version should be in effect after reloading. - expectRunningVersion(t, c, mountedPath, "v1.0.1") - - err = c.pluginCatalog.DeletePinnedVersion(context.Background(), plugin.Typ, plugin.Name) - if err != nil { - t.Fatal(err) - } - - reloaded, err = c.reloadMatchingPlugin(context.Background(), nil, plugin.Typ, plugin.Name) - if reloaded != 1 || err != nil { - t.Fatal(reloaded, err) - } - - // After pin is deleted, the previously configured version should stand. - expectRunningVersion(t, c, mountedPath, "v1.0.0") - }) - } -} - -func expectRunningVersion(t *testing.T, c *Core, path, expectedVersion string) { - t.Helper() - match := c.router.MatchingMount(namespace.RootContext(context.Background()), path) - if match != path { - t.Fatalf("missing mount for %s, match: %q", path, match) - } - - raw, _ := c.router.root.Get(match) - if actual := raw.(*routeEntry).mountEntry.RunningVersion; expectedVersion != actual { - t.Fatalf("expected running_plugin_version to be %s but got %s", expectedVersion, actual) - } -} - func TestCore_EnableExternalPlugin_MultipleVersions(t *testing.T) { for name, tc := range map[string]struct { pluginType consts.PluginType diff --git a/vault/external_tests/plugin/external_plugin_test.go b/vault/external_tests/plugin/external_plugin_test.go index 1e78cf0be148..83d1d477fafd 100644 --- a/vault/external_tests/plugin/external_plugin_test.go +++ b/vault/external_tests/plugin/external_plugin_test.go @@ -25,13 +25,14 @@ import ( postgreshelper "github.com/hashicorp/vault/helper/testhelpers/postgresql" vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" _ "github.com/jackc/pgx/v4/stdlib" ) -func getClusterWithFileAuditBackend(t *testing.T, typ consts.PluginType, numCores int) *vault.TestCluster { +func getCluster(t *testing.T, numCores int, types ...consts.PluginType) *vault.TestCluster { pluginDir := corehelpers.MakeTestPluginDir(t) coreConfig := &vault.CoreConfig{ PluginDirectory: pluginDir, @@ -46,39 +47,16 @@ func getClusterWithFileAuditBackend(t *testing.T, typ consts.PluginType, numCore cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ TempDir: pluginDir, NumCores: numCores, - Plugins: []*vault.TestPluginConfig{ - { - Typ: typ, - Versions: []string{""}, - }, - }, - HandlerFunc: vaulthttp.Handler, - }) - - cluster.Start() - vault.TestWaitActive(t, cluster.Cores[0].Core) - - return cluster -} - -func getCluster(t *testing.T, typ consts.PluginType, numCores int) *vault.TestCluster { - pluginDir := corehelpers.MakeTestPluginDir(t) - coreConfig := &vault.CoreConfig{ - PluginDirectory: pluginDir, - LogicalBackends: map[string]logical.Factory{ - "database": database.Factory, - }, - } - - cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ - TempDir: pluginDir, - NumCores: numCores, - Plugins: []*vault.TestPluginConfig{ - { - Typ: typ, - Versions: []string{""}, - }, - }, + Plugins: func() []*vault.TestPluginConfig { + var plugins []*vault.TestPluginConfig + for _, typ := range types { + plugins = append(plugins, &vault.TestPluginConfig{ + Typ: typ, + Versions: []string{""}, + }) + } + return plugins + }(), HandlerFunc: vaulthttp.Handler, }) @@ -127,34 +105,49 @@ func TestExternalPlugin_RollbackAndReload(t *testing.T) { } } -func testRegisterAndEnable(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin) { +func testRegisterVersion(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin, version string) { t.Helper() if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ Name: plugin.Name, Type: api.PluginType(plugin.Typ), Command: plugin.Name, SHA256: plugin.Sha256, - Version: plugin.Version, + Version: version, }); err != nil { t.Fatal(err) } +} +func testEnableVersion(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin, version string) { + t.Helper() switch plugin.Typ { case consts.PluginTypeSecrets: if err := client.Sys().Mount(plugin.Name, &api.MountInput{ Type: plugin.Name, + Config: api.MountConfigInput{ + PluginVersion: version, + }, }); err != nil { t.Fatal(err) } case consts.PluginTypeCredential: if err := client.Sys().EnableAuthWithOptions(plugin.Name, &api.EnableAuthOptions{ Type: plugin.Name, + Config: api.MountConfigInput{ + PluginVersion: version, + }, }); err != nil { t.Fatal(err) } } } +func testRegisterAndEnable(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin) { + t.Helper() + testRegisterVersion(t, client, plugin, plugin.Version) + testEnableVersion(t, client, plugin, plugin.Version) +} + // TestExternalPlugin_ContinueOnError tests that vault can recover from a // sha256 mismatch or missing plugin binary scenario func TestExternalPlugin_ContinueOnError(t *testing.T) { @@ -186,7 +179,7 @@ func TestExternalPlugin_ContinueOnError(t *testing.T) { } func testExternalPlugin_ContinueOnError(t *testing.T, mismatch bool, pluginType consts.PluginType) { - cluster := getCluster(t, pluginType, 1) + cluster := getCluster(t, 1, pluginType) defer cluster.Cleanup() core := cluster.Cores[0] @@ -222,7 +215,7 @@ func testExternalPlugin_ContinueOnError(t *testing.T, mismatch bool, pluginType t.Fatalf("err:%v resp:%#v", err, resp) } } else { - err := os.Remove(filepath.Join(cluster.TempDir, filepath.Base(command))) + err := os.Remove(filepath.Join(cluster.Cores[0].CoreConfig.PluginDirectory, filepath.Base(command))) if err != nil { t.Fatal(err) } @@ -294,7 +287,7 @@ func testExternalPlugin_ContinueOnError(t *testing.T, mismatch bool, pluginType // TestExternalPlugin_AuthMethod tests that we can build, register and use an // external auth method func TestExternalPlugin_AuthMethod(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeCredential, 5) + cluster := getCluster(t, 5, consts.PluginTypeCredential) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -302,15 +295,7 @@ func TestExternalPlugin_AuthMethod(t *testing.T) { client.SetToken(cluster.RootToken) // Register - if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ - Name: plugin.Name, - Type: api.PluginType(plugin.Typ), - Command: plugin.Name, - SHA256: plugin.Sha256, - Version: plugin.Version, - }); err != nil { - t.Fatal(err) - } + testRegisterVersion(t, client, plugin, plugin.Version) // define a group of parallel tests so we wait for their execution before // continuing on to cleanup @@ -413,7 +398,7 @@ func TestExternalPlugin_AuthMethod(t *testing.T) { // TestExternalPlugin_AuthMethodReload tests that we can use an external auth // method after reload func TestExternalPlugin_AuthMethodReload(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeCredential, 1) + cluster := getCluster(t, 1, consts.PluginTypeCredential) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -488,7 +473,7 @@ func TestExternalPlugin_AuthMethodReload(t *testing.T) { // TestExternalPlugin_SecretsEngine tests that we can build, register and use an // external secrets engine func TestExternalPlugin_SecretsEngine(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeSecrets, 1) + cluster := getCluster(t, 1, consts.PluginTypeSecrets) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -496,15 +481,7 @@ func TestExternalPlugin_SecretsEngine(t *testing.T) { client.SetToken(cluster.RootToken) // Register - if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ - Name: plugin.Name, - Type: api.PluginType(plugin.Typ), - Command: plugin.Name, - SHA256: plugin.Sha256, - Version: plugin.Version, - }); err != nil { - t.Fatal(err) - } + testRegisterVersion(t, client, plugin, plugin.Version) // define a group of parallel tests so we wait for their execution before // continuing on to cleanup @@ -568,7 +545,7 @@ func TestExternalPlugin_SecretsEngine(t *testing.T) { // TestExternalPlugin_SecretsEngineReload tests that we can use an external // secrets engine after reload func TestExternalPlugin_SecretsEngineReload(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeSecrets, 1) + cluster := getCluster(t, 1, consts.PluginTypeSecrets) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -634,7 +611,7 @@ func TestExternalPlugin_SecretsEngineReload(t *testing.T) { // TestExternalPlugin_Database tests that we can build, register and use an // external database secrets engine func TestExternalPlugin_Database(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeDatabase, 1) + cluster := getCluster(t, 1, consts.PluginTypeDatabase) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -642,15 +619,7 @@ func TestExternalPlugin_Database(t *testing.T) { client.SetToken(cluster.RootToken) // Register - if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ - Name: plugin.Name, - Type: api.PluginType(consts.PluginTypeDatabase), - Command: plugin.Name, - SHA256: plugin.Sha256, - Version: plugin.Version, - }); err != nil { - t.Fatal(err) - } + testRegisterVersion(t, client, plugin, plugin.Version) // Enable if err := client.Sys().Mount(consts.PluginTypeDatabase.String(), &api.MountInput{ @@ -749,7 +718,7 @@ func TestExternalPlugin_Database(t *testing.T) { client.SetToken(cluster.RootToken) // Lookup - expect FAILURE - resp, err = client.Sys().Lookup(revokeLease) + _, err = client.Sys().Lookup(revokeLease) if err == nil { t.Fatalf("expected error, got nil") } @@ -770,7 +739,7 @@ func TestExternalPlugin_Database(t *testing.T) { // TestExternalPlugin_DatabaseReload tests that we can use an external database // secrets engine after reload func TestExternalPlugin_DatabaseReload(t *testing.T) { - cluster := getCluster(t, consts.PluginTypeDatabase, 1) + cluster := getCluster(t, 1, consts.PluginTypeDatabase) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -778,15 +747,7 @@ func TestExternalPlugin_DatabaseReload(t *testing.T) { client.SetToken(cluster.RootToken) // Register - if err := client.Sys().RegisterPlugin(&api.RegisterPluginInput{ - Name: plugin.Name, - Type: api.PluginType(consts.PluginTypeDatabase), - Command: plugin.Name, - SHA256: plugin.Sha256, - Version: plugin.Version, - }); err != nil { - t.Fatal(err) - } + testRegisterVersion(t, client, plugin, plugin.Version) // Enable if err := client.Sys().Mount(consts.PluginTypeDatabase.String(), &api.MountInput{ @@ -884,7 +845,7 @@ func testExternalPluginMetadataAuditLog(t *testing.T, log map[string]interface{} // TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Auth tests that we have plugin metadata of an auth plugin // in audit log when it is enabled func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Auth(t *testing.T) { - cluster := getClusterWithFileAuditBackend(t, consts.PluginTypeCredential, 1) + cluster := getCluster(t, 1, consts.PluginTypeCredential) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -899,6 +860,7 @@ func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Auth(t *testing.T) if err != nil { t.Fatal(err) } + defer auditLogFile.Close() err = client.Sys().EnableAuditWithOptions("file", &api.EnableAuditOptions{ Type: "file", @@ -954,7 +916,7 @@ func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Auth(t *testing.T) // TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Secret tests that we have plugin metadata of a secret plugin // in audit log when it is enabled func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Secret(t *testing.T) { - cluster := getClusterWithFileAuditBackend(t, consts.PluginTypeSecrets, 1) + cluster := getCluster(t, 1, consts.PluginTypeSecrets) defer cluster.Cleanup() plugin := cluster.Plugins[0] @@ -969,6 +931,7 @@ func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Secret(t *testing.T if err != nil { t.Fatal(err) } + defer auditLogFile.Close() err = client.Sys().EnableAuditWithOptions("file", &api.EnableAuditOptions{ Type: "file", @@ -1023,3 +986,190 @@ func TestExternalPlugin_AuditEnabled_ShouldLogPluginMetadata_Secret(t *testing.T t.Fatal(err) } } + +func testPin(t *testing.T, client *api.Client, op logical.Operation, pin *pluginutil.PinnedVersion) *api.Secret { + t.Helper() + switch op { + case logical.CreateOperation, logical.UpdateOperation: + resp, err := client.Logical().Write(fmt.Sprintf("sys/plugins/pins/%s/%s", pin.Type.String(), pin.Name), map[string]any{ + "version": pin.Version, + }) + if err != nil { + t.Fatal(err) + } + return resp + case logical.DeleteOperation: + resp, err := client.Logical().Delete(fmt.Sprintf("sys/plugins/pins/%s/%s", pin.Type.String(), pin.Name)) + if err != nil { + t.Fatal(err) + } + return resp + default: + t.Fatal("unsupported operation") + // Satisfy the compiler that there's no escape from the switch statement. + return nil + } +} + +func testReload(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin) { + _, err := client.Sys().RootReloadPlugin(context.Background(), &api.RootReloadPluginInput{ + Plugin: plugin.Name, + Type: api.PluginType(plugin.Typ), + }) + if err != nil { + t.Fatal(err) + } +} + +func expectRunningVersion(t *testing.T, client *api.Client, plugin pluginhelpers.TestPlugin, expectedVersion string) { + t.Helper() + switch plugin.Typ { + case consts.PluginTypeCredential: + auth, err := client.Logical().Read("sys/auth/" + plugin.Name) + if err != nil { + t.Fatal(err) + } + if auth.Data["running_plugin_version"] != expectedVersion { + t.Fatalf("expected running_plugin_version to be %s but got %s", expectedVersion, auth.Data["running_plugin_version"]) + } + case consts.PluginTypeSecrets: + mount, err := client.Logical().Read("sys/mounts/" + plugin.Name) + if err != nil { + t.Fatal(err) + } + if mount.Data["running_plugin_version"] != expectedVersion { + t.Fatalf("expected running_plugin_version to be %s but got %s", expectedVersion, mount.Data["running_plugin_version"]) + } + case consts.PluginTypeDatabase: + resp, err := client.Logical().Read("database/config/" + plugin.Name) + if err != nil { + t.Fatal(err) + } + if resp.Data["running_plugin_version"] != expectedVersion { + t.Fatalf("expected running_plugin_version to be %s but got %s", expectedVersion, resp.Data["running_plugin_version"]) + } + expectedWarnings := 0 + if resp.Data["plugin_version"] != resp.Data["running_plugin_version"] { + expectedWarnings = 1 + } + + if expectedWarnings != len(resp.Warnings) { + t.Fatalf("expected %d warning(s) but got %v", expectedWarnings, resp.Warnings) + } + default: + t.Fatal("unsupported plugin type") + } +} + +// TestCore_UpgradePluginUsingPinnedVersion_AuthAndSecret tests a full workflow +// of upgrading an external plugin gated by pinned versions. +func TestCore_UpgradePluginUsingPinnedVersion_AuthAndSecret(t *testing.T) { + cluster := getCluster(t, 1, consts.PluginTypeCredential, consts.PluginTypeSecrets) + t.Cleanup(cluster.Cleanup) + + client := cluster.Cores[0].Client + + for name, idx := range map[string]int{ + "credential plugin": 0, + "secrets plugin": 1, + } { + t.Run(name, func(t *testing.T) { + plugin := cluster.Plugins[idx] + + // Register the same plugin with two versions. + for _, version := range []string{"v1.0.0", "v1.0.1"} { + testRegisterVersion(t, client, plugin, version) + } + + pin101 := &pluginutil.PinnedVersion{ + Name: plugin.Name, + Type: plugin.Typ, + Version: "v1.0.1", + } + + // Mount 1.0.0 then pin to 1.0.1 + testEnableVersion(t, client, plugin, "v1.0.0") + testPin(t, client, logical.CreateOperation, pin101) + expectRunningVersion(t, client, plugin, "v1.0.0") + + // Pinned version should be in effect after reloading. + testReload(t, client, plugin) + expectRunningVersion(t, client, plugin, "v1.0.1") + + // Deregistering a pinned plugin should fail. + if err := client.Sys().DeregisterPlugin(&api.DeregisterPluginInput{ + Name: plugin.Name, + Type: api.PluginType(plugin.Typ), + Version: "v1.0.1", + }); err == nil { + t.Fatal("expected error, got nil") + } + + // Now delete, reload, and we should be back to 1.0.0 + testPin(t, client, logical.DeleteOperation, pin101) + testReload(t, client, plugin) + expectRunningVersion(t, client, plugin, "v1.0.0") + }) + } +} + +// TestCore_UpgradePluginUsingPinnedVersion_Database tests a full workflow +// of upgrading an external database plugin gated by pinned versions. +func TestCore_UpgradePluginUsingPinnedVersion_Database(t *testing.T) { + cluster := getCluster(t, 3, consts.PluginTypeDatabase) + t.Cleanup(cluster.Cleanup) + + client := cluster.Cores[0].Client + plugin := cluster.Plugins[0] + + // Register the same plugin with two versions. + for _, version := range []string{"v1.0.0", "v1.0.1"} { + testRegisterVersion(t, client, plugin, version) + } + + pin101 := &pluginutil.PinnedVersion{ + Name: plugin.Name, + Type: plugin.Typ, + Version: "v1.0.1", + } + + // Enable the combined db engine first. + if err := client.Sys().Mount(consts.PluginTypeDatabase.String(), &api.MountInput{ + Type: consts.PluginTypeDatabase.String(), + }); err != nil { + t.Fatal(err) + } + + cleanupPG, connURL := postgreshelper.PrepareTestContainerWithVaultUser(t, context.Background(), "13.4-buster") + t.Cleanup(cleanupPG) + + // Mount 1.0.0 then pin to 1.0.1 + _, err := client.Logical().Write("database/config/"+plugin.Name, map[string]interface{}{ + "plugin_name": plugin.Name, + "plugin_version": "v1.0.0", + "connection_url": connURL, + "username": "vaultadmin", + "password": "vaultpass", + }) + if err != nil { + t.Fatal(err) + } + testPin(t, client, logical.CreateOperation, pin101) + expectRunningVersion(t, client, plugin, "v1.0.0") + + // Pinned version should be in effect after reloading. + testReload(t, client, plugin) + // All nodes in the cluster should report the same info, because although + // the running_plugin_version info is local to the leader, the standbys + // should forward the request to the leader. + for i := 0; i < 3; i++ { + expectRunningVersion(t, cluster.Cores[i].Client, plugin, "v1.0.1") + } + + // Now delete, reload, and we should be back to 1.0.0 + testPin(t, client, logical.DeleteOperation, pin101) + testReload(t, client, plugin) + for i := 0; i < 3; i++ { + expectRunningVersion(t, client, plugin, "v1.0.0") + } +} diff --git a/vault/external_tests/plugin/plugin_test.go b/vault/external_tests/plugin/plugin_test.go index d0ad56b34a68..a35dbe2ea525 100644 --- a/vault/external_tests/plugin/plugin_test.go +++ b/vault/external_tests/plugin/plugin_test.go @@ -202,7 +202,7 @@ func TestSystemBackend_Plugin_MissingBinary(t *testing.T) { // since that's how we create the file for catalog registration in the test // helper. pluginFileName := filepath.Base(os.Args[0]) - err = os.Remove(filepath.Join(cluster.TempDir, pluginFileName)) + err = os.Remove(filepath.Join(cluster.Cores[0].CoreConfig.PluginDirectory, pluginFileName)) if err != nil { t.Fatal(err) } diff --git a/vault/logical_system.go b/vault/logical_system.go index 52ddcea80965..90ef973a4ca2 100644 --- a/vault/logical_system.go +++ b/vault/logical_system.go @@ -198,6 +198,8 @@ func NewSystemBackend(core *Core, logger log.Logger, config *logical.BackendConf b.Backend.Paths = append(b.Backend.Paths, b.statusPaths()...) 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.pluginsCatalogPinsListPath()) + b.Backend.Paths = append(b.Backend.Paths, b.pluginsCatalogPinsCRUDPath()) 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()) @@ -849,6 +851,118 @@ func (b *SystemBackend) handleRootPluginReloadUpdate(ctx context.Context, req *l return &resp, nil } +func (b *SystemBackend) handlePluginCatalogPinUpdate(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + pluginType, pluginName, resp := requirePluginTypeAndName(d) + if resp != nil { + return resp, nil + } + + pluginVersion, builtin, err := getVersion(d) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + if pluginVersion == "" { + return logical.ErrorResponse("missing plugin version"), nil + } + if builtin { + return logical.ErrorResponse("cannot pin a builtin plugin: %q", pluginVersion), nil + } + + err = b.Core.pluginCatalog.SetPinnedVersion(ctx, &pluginutil.PinnedVersion{ + Name: pluginName, + Type: pluginType, + Version: pluginVersion, + }) + if err != nil { + if errors.Is(err, plugincatalog.ErrPluginNotFound) { + return logical.ErrorResponse(err.Error()), nil + } + return nil, err + } + + return &logical.Response{}, nil +} + +func (b *SystemBackend) handlePluginCatalogPinRead(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + pluginType, pluginName, resp := requirePluginTypeAndName(d) + if resp != nil { + return resp, nil + } + + pin, err := b.Core.pluginCatalog.GetPinnedVersion(ctx, pluginType, pluginName) + if errors.Is(err, pluginutil.ErrPinnedVersionNotFound) { + return nil, logical.CodedError(http.StatusNotFound, "no pinned version for this plugin") + } + if err != nil { + return nil, err + } + + return &logical.Response{ + Data: map[string]interface{}{ + "name": pin.Name, + "type": pin.Type.String(), + "version": pin.Version, + }, + }, nil +} + +func (b *SystemBackend) handlePluginCatalogPinDelete(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { + pluginType, pluginName, resp := requirePluginTypeAndName(d) + if resp != nil { + return resp, nil + } + + if err := b.Core.pluginCatalog.DeletePinnedVersion(ctx, pluginType, pluginName); err != nil { + if errors.Is(err, pluginutil.ErrPinnedVersionNotFound) { + return &logical.Response{}, nil + } + + return nil, err + } + + return &logical.Response{}, nil +} + +func requirePluginTypeAndName(d *framework.FieldData) (consts.PluginType, string, *logical.Response) { + pluginName := d.Get("name").(string) + if pluginName == "" { + return consts.PluginTypeUnknown, "", logical.ErrorResponse("missing plugin name") + } + + pluginTypeStr := d.Get("type").(string) + if pluginTypeStr == "" { + return consts.PluginTypeUnknown, "", logical.ErrorResponse("missing plugin type") + } + pluginType, err := consts.ParsePluginType(pluginTypeStr) + if err != nil { + return consts.PluginTypeUnknown, "", logical.ErrorResponse("invalid plugin type: %s", err) + } + + return pluginType, pluginName, nil +} + +func (b *SystemBackend) handlePluginCatalogPinList(ctx context.Context, _ *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + pins, err := b.Core.pluginCatalog.ListPinnedVersions(ctx) + if err != nil { + return nil, err + } + + pinnedVersions := []map[string]any{} + for _, pin := range pins { + pinnedVersions = append(pinnedVersions, map[string]any{ + "name": pin.Name, + "type": pin.Type.String(), + "version": pin.Version, + }) + } + + return &logical.Response{ + Data: map[string]interface{}{ + "pinned_versions": pinnedVersions, + }, + }, nil +} + func (b *SystemBackend) handlePluginRuntimeCatalogUpdate(ctx context.Context, _ *logical.Request, d *framework.FieldData) (*logical.Response, error) { runtimeName := d.Get("name").(string) if runtimeName == "" { @@ -1599,9 +1713,20 @@ func (b *SystemBackend) handleReadMount(ctx context.Context, req *logical.Reques return logical.ErrorResponse("No secret engine mount at %s", path), nil } - return &logical.Response{ + resp := &logical.Response{ Data: b.mountInfo(ctx, entry), - }, nil + } + if entry.Version != "" && entry.Version != entry.RunningVersion { + warning := fmt.Sprintf("Plugin version is configured as %q, but running %q", entry.Version, entry.RunningVersion) + if pin, _ := b.Core.pluginCatalog.GetPinnedVersion(ctx, consts.PluginTypeSecrets, entry.Type); pin != nil && pin.Version == entry.RunningVersion { + warning += " because that version is pinned" + } else { + warning += " either due to a pinned version or because the plugin was upgraded and not yet reloaded" + } + resp.AddWarning(warning) + } + + return resp, nil } // used to intercept an HTTPCodedError so it goes back to callee @@ -6530,6 +6655,28 @@ Must already be present on the machine.`, `The Vault plugin runtime to use when running the plugin.`, "", }, + "plugin-catalog-pins": { + "Configures pinned plugin versions from the plugin catalog", + ` +This path responds to the following HTTP methods. + GET // + Retrieve the pinned version for the named plugin. + + PUT // + Add or update a pinned version for the named plugin. Does not trigger changes until the plugin is reloaded. + + DELETE // + Delete the pinned version for the named plugin. Does not trigger changes until the plugin is reloaded. + `, + }, + "plugin-catalog-pins-list-all": { + "Lists all the pinned plugin versions known to Vault", + ` +This path responds to the following HTTP methods. + LIST / + Returns a list of configured pinned versions. + `, + }, "plugin-runtime-catalog": { "Configures plugin runtimes", ` diff --git a/vault/logical_system_paths.go b/vault/logical_system_paths.go index 5d1a23dccec6..e82c4ef6f10c 100644 --- a/vault/logical_system_paths.go +++ b/vault/logical_system_paths.go @@ -2051,6 +2051,126 @@ func (b *SystemBackend) pluginsCatalogListPaths() []*framework.Path { } } +func (b *SystemBackend) pluginsCatalogPinsCRUDPath() *framework.Path { + return &framework.Path{ + Pattern: "plugins/pins/(?Pauth|database|secret)/" + framework.GenericNameRegex("name") + "$", + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "plugins-catalog-pins", + }, + + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_name"][0]), + }, + "type": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_type"][0]), + }, + "version": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_version"][0]), + }, + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.handlePluginCatalogPinUpdate, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "create", + OperationSuffix: "pinned-version", + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + }}, + }, + Summary: "Create or update the pinned version for a plugin with a given type and name.", + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.handlePluginCatalogPinDelete, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "remove", + OperationSuffix: "pinned-version", + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + Fields: map[string]*framework.FieldSchema{}, + }}, + }, + Summary: "Remove any pinned version for the plugin with the given type and name.", + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: b.handlePluginCatalogPinRead, + DisplayAttrs: &framework.DisplayAttributes{ + OperationVerb: "read", + OperationSuffix: "pinned-version", + }, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + Fields: map[string]*framework.FieldSchema{ + "name": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_name"][0]), + Required: true, + }, + "type": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_type"][0]), + Required: true, + }, + "version": { + Type: framework.TypeString, + Description: strings.TrimSpace(sysHelp["plugin-catalog_version"][0]), + Required: true, + }, + }, + }}, + }, + Summary: "Return the pinned version for the plugin with the given type and name.", + }, + }, + + HelpSynopsis: strings.TrimSpace(sysHelp["plugin-catalog-pins"][0]), + HelpDescription: strings.TrimSpace(sysHelp["plugin-catalog-pins"][1]), + } +} + +func (b *SystemBackend) pluginsCatalogPinsListPath() *framework.Path { + return &framework.Path{ + Pattern: "plugins/pins/?$", + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: "plugins-catalog-pins", + OperationVerb: "list", + OperationSuffix: "pinned-versions", + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.handlePluginCatalogPinList, + Responses: map[int][]framework.Response{ + http.StatusOK: {{ + Description: "OK", + Fields: map[string]*framework.FieldSchema{ + "pinned_versions": { + Type: framework.TypeMap, + Required: true, + }, + }, + }}, + }, + }, + }, + + HelpSynopsis: strings.TrimSpace(sysHelp["plugin-catalog-pins-list-all"][0]), + HelpDescription: strings.TrimSpace(sysHelp["plugin-catalog-pins-list-all"][1]), + } +} + func (b *SystemBackend) pluginsReloadPath() *framework.Path { return &framework.Path{ Pattern: "plugins/reload/backend$", diff --git a/vault/logical_system_test.go b/vault/logical_system_test.go index 4029830887ac..03d2a313f158 100644 --- a/vault/logical_system_test.go +++ b/vault/logical_system_test.go @@ -3740,6 +3740,142 @@ func TestSystemBackend_PluginCatalog_CRUD(t *testing.T) { } } +// TestSystemBackend_PluginCatalogPins_CRUD tests CRUD operations for pinning +// plugin versions. +func TestSystemBackend_PluginCatalogPins_CRUD(t *testing.T) { + sym, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + c, _, _ := TestCoreUnsealedWithConfig(t, &CoreConfig{ + PluginDirectory: sym, + }) + b := c.systemBackend + ctx := namespace.RootContext(context.Background()) + + // List pins. + req := logical.TestRequest(t, logical.ReadOperation, "plugins/pins") + resp, err := b.HandleRequest(ctx, req) + if err != nil || resp.IsError() { + t.Fatal(resp, err) + } + + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.Route(req.Path), req.Operation), + resp, + true, + ) + + if len(resp.Data["pinned_versions"].([]map[string]any)) != 0 { + t.Fatalf("Wrong number of plugins, expected %d, got %d", 0, len(resp.Data["pins"].([]string))) + } + + // Set a plugin so we can pin to it. + file, err := os.CreateTemp(sym, "temp") + if err != nil { + t.Fatal(err) + } + defer file.Close() + req = logical.TestRequest(t, logical.UpdateOperation, "plugins/catalog/database/test-plugin") + req.Data["sha_256"] = hex.EncodeToString([]byte{'1'}) + req.Data["command"] = filepath.Base(file.Name()) + req.Data["version"] = "v1.0.0" + resp, err = b.HandleRequest(ctx, req) + if err != nil || resp.IsError() { + t.Fatal(resp, err) + } + + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.Route(req.Path), req.Operation), + resp, + true, + ) + + // Now create a pin. + req = logical.TestRequest(t, logical.UpdateOperation, "plugins/pins/database/test-plugin") + req.Data["version"] = "v1.0.0" + resp, err = b.HandleRequest(ctx, req) + if err != nil || resp.IsError() { + t.Fatal(resp, err) + } + + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.Route(req.Path), req.Operation), + resp, + true, + ) + + // Read the pin. + req = logical.TestRequest(t, logical.ReadOperation, "plugins/pins/database/test-plugin") + resp, err = b.HandleRequest(ctx, req) + if err != nil || resp.Error() != nil { + t.Fatal(resp, err) + } + + expected := map[string]interface{}{ + "name": "test-plugin", + "type": "database", + "version": "v1.0.0", + } + + actual := resp.Data + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected did not match actual, got %#v\n expected %#v\n", actual, expected) + } + + // List pins again. + req = logical.TestRequest(t, logical.ReadOperation, "plugins/pins/") + resp, err = b.HandleRequest(ctx, req) + if err != nil || resp.IsError() { + t.Fatal(resp, err) + } + + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.Route(req.Path), req.Operation), + resp, + true, + ) + + pinnedVersions := resp.Data["pinned_versions"].([]map[string]any) + if len(pinnedVersions) != 1 { + t.Fatalf("Wrong number of plugins, expected %d, got %d", 1, len(resp.Data["pins"].([]string))) + } + // Check the pin is correct. + actual = pinnedVersions[0] + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("expected did not match actual, got %#v\n expected %#v\n", actual, expected) + } + + // Delete the pin. + req = logical.TestRequest(t, logical.DeleteOperation, "plugins/pins/database/test-plugin") + resp, err = b.HandleRequest(ctx, req) + if err != nil || resp.IsError() { + t.Fatal(resp, err) + } + + schema.ValidateResponse( + t, + schema.GetResponseSchema(t, b.Route(req.Path), req.Operation), + resp, + true, + ) + + // Should now get a 404 when reading the pin. + req = logical.TestRequest(t, logical.ReadOperation, "plugins/pins/database/test-plugin") + _, err = b.HandleRequest(ctx, req) + var codedErr logical.HTTPCodedError + if !errors.As(err, &codedErr) { + t.Fatal(err) + } + if codedErr.Code() != http.StatusNotFound { + t.Fatal(codedErr) + } +} + // TestSystemBackend_PluginCatalog_ContainerCRUD tests that plugins registered // with oci_image set get recorded properly in the catalog. func TestSystemBackend_PluginCatalog_ContainerCRUD(t *testing.T) { diff --git a/vault/plugincatalog/pin.go b/vault/plugincatalog/pin.go index 981efa0bcd22..473584374ca9 100644 --- a/vault/plugincatalog/pin.go +++ b/vault/plugincatalog/pin.go @@ -33,7 +33,7 @@ func (c *PluginCatalog) SetPinnedVersion(ctx context.Context, pin *pluginutil.Pi return err } if plugin == nil { - return fmt.Errorf("%s plugin %q version %s does not exist", pin.Type.String(), pin.Name, pin.Version) + return fmt.Errorf("%w; %s plugin %q version %s does not exist", ErrPluginNotFound, pin.Type.String(), pin.Name, pin.Version) } bytes, err := json.Marshal(pin)