HTTP API for pinning plugin versions (#25105)

This commit is contained in:
Tom Proctor
2024-01-30 10:24:33 +00:00
committed by GitHub
parent 1b8bb7e75a
commit 78ef25e70c
11 changed files with 681 additions and 194 deletions

View File

@@ -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 {

View File

@@ -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()

6
changelog/25105.txt Normal file
View File

@@ -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.
```

View File

@@ -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
}

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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 /<type>/<name>
Retrieve the pinned version for the named plugin.
PUT /<type>/<name>
Add or update a pinned version for the named plugin. Does not trigger changes until the plugin is reloaded.
DELETE /<type>/<name>
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",
`

View File

@@ -2051,6 +2051,126 @@ func (b *SystemBackend) pluginsCatalogListPaths() []*framework.Path {
}
}
func (b *SystemBackend) pluginsCatalogPinsCRUDPath() *framework.Path {
return &framework.Path{
Pattern: "plugins/pins/(?P<type>auth|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$",

View File

@@ -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) {

View File

@@ -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)