mirror of
https://github.com/kemko/nomad.git
synced 2026-01-01 07:55:42 +03:00
secrets: add common secrets plugins impl (#26335)
Co-authored-by: Michael Schurter <mschurter@hashicorp.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"client/allocdir/...",
|
||||
"client/allochealth/...",
|
||||
"client/allocwatcher/...",
|
||||
"client/commonplugins/...",
|
||||
"client/config/...",
|
||||
"client/consul/...",
|
||||
"client/devicemanager/...",
|
||||
|
||||
65
client/allocrunner/taskrunner/secrets/plugin_provider.go
Normal file
65
client/allocrunner/taskrunner/secrets/plugin_provider.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/nomad/client/commonplugins"
|
||||
)
|
||||
|
||||
type ExternalPluginProvider struct {
|
||||
// plugin is the commonplugin to be executed by this secret
|
||||
plugin commonplugins.SecretsPlugin
|
||||
|
||||
// response is the plugin response saved after Fetch is called
|
||||
response *commonplugins.SecretResponse
|
||||
|
||||
// name of the plugin and also the executable
|
||||
name string
|
||||
|
||||
// path is the secret location used in Fetch
|
||||
path string
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Result map[string]string `json:"result"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewExternalPluginProvider(plugin commonplugins.SecretsPlugin, name string, path string) *ExternalPluginProvider {
|
||||
return &ExternalPluginProvider{
|
||||
plugin: plugin,
|
||||
name: name,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExternalPluginProvider) Fetch(ctx context.Context) error {
|
||||
resp, err := p.plugin.Fetch(ctx, p.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch secret from plugin %s: %w", p.name, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("error returned from secret plugin %s: %s", p.name, *resp.Error)
|
||||
}
|
||||
|
||||
p.response = resp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExternalPluginProvider) Parse() (map[string]string, error) {
|
||||
if p.response == nil {
|
||||
return nil, errors.New("no plugin response for provider to parse")
|
||||
}
|
||||
|
||||
formatted := map[string]string{}
|
||||
for k, v := range p.response.Result {
|
||||
formatted[fmt.Sprintf("secret.%s.%s", p.name, k)] = v
|
||||
}
|
||||
|
||||
return formatted, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/client/commonplugins"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockSecretPlugin struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockSecretPlugin) Fingerprint(ctx context.Context) (*commonplugins.PluginFingerprint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockSecretPlugin) Fetch(ctx context.Context, path string) (*commonplugins.SecretResponse, error) {
|
||||
args := m.Called()
|
||||
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
return args.Get(0).(*commonplugins.SecretResponse), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockSecretPlugin) Parse() (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SecretsPlugin is tested in commonplugins package. We can use a mock here to test how
|
||||
// the ExternalPluginProvider handles various error scenarios when calling Fetch.
|
||||
func TestExternalPluginProvider_Fetch(t *testing.T) {
|
||||
t.Run("errors if fetch errors", func(t *testing.T) {
|
||||
mockSecretPlugin := new(MockSecretPlugin)
|
||||
mockSecretPlugin.On("Fetch", mock.Anything).Return(nil, errors.New("something bad"))
|
||||
|
||||
testProvider := NewExternalPluginProvider(mockSecretPlugin, "test", "test")
|
||||
|
||||
err := testProvider.Fetch(context.Background())
|
||||
must.ErrorContains(t, err, "something bad")
|
||||
})
|
||||
|
||||
t.Run("errors if fetch response contains error", func(t *testing.T) {
|
||||
mockSecretPlugin := new(MockSecretPlugin)
|
||||
testError := "something bad"
|
||||
mockSecretPlugin.On("Fetch", mock.Anything).Return(&commonplugins.SecretResponse{
|
||||
Result: nil,
|
||||
Error: &testError,
|
||||
}, nil)
|
||||
|
||||
testProvider := NewExternalPluginProvider(mockSecretPlugin, "test", "test")
|
||||
|
||||
err := testProvider.Fetch(context.Background())
|
||||
must.ErrorContains(t, err, "error returned from secret plugin")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExternalPluginProvider_Parse(t *testing.T) {
|
||||
t.Run("formats response correctly", func(t *testing.T) {
|
||||
testProvider := NewExternalPluginProvider(nil, "test", "test")
|
||||
testProvider.response = &commonplugins.SecretResponse{
|
||||
Result: map[string]string{
|
||||
"testkey": "testvalue",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := testProvider.Parse()
|
||||
must.NoError(t, err)
|
||||
|
||||
exp := map[string]string{
|
||||
"secret.test.testkey": "testvalue",
|
||||
}
|
||||
must.Eq(t, exp, result)
|
||||
|
||||
})
|
||||
t.Run("errors if response is nil", func(t *testing.T) {
|
||||
testProvider := NewExternalPluginProvider(nil, "test", "test")
|
||||
testProvider.response = nil
|
||||
|
||||
result, err := testProvider.Parse()
|
||||
must.Error(t, err)
|
||||
must.Nil(t, result)
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
ti "github.com/hashicorp/nomad/client/allocrunner/taskrunner/interfaces"
|
||||
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/secrets"
|
||||
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/template"
|
||||
"github.com/hashicorp/nomad/client/commonplugins"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/client/taskenv"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
@@ -22,14 +23,20 @@ import (
|
||||
// work can modify this interface to include custom providers using a plugin
|
||||
// interface.
|
||||
type SecretProvider interface {
|
||||
// BuildTemplate should construct a template appropriate for that provider
|
||||
// and append it to the templateManager's templates.
|
||||
BuildTemplate() *structs.Template
|
||||
|
||||
// Parse allows each provider implementation to parse its "response" object.
|
||||
Parse() (map[string]string, error)
|
||||
}
|
||||
|
||||
type TemplateProvider interface {
|
||||
SecretProvider
|
||||
BuildTemplate() *structs.Template
|
||||
}
|
||||
|
||||
type PluginProvider interface {
|
||||
SecretProvider
|
||||
Fetch(context.Context) error
|
||||
}
|
||||
|
||||
type secretsHookConfig struct {
|
||||
// logger is used to log
|
||||
logger log.Logger
|
||||
@@ -98,7 +105,14 @@ func (h *secretsHook) Prestart(ctx context.Context, req *interfaces.TaskPrestart
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
templates = append(templates, p.BuildTemplate())
|
||||
switch v := p.(type) {
|
||||
case TemplateProvider:
|
||||
templates = append(templates, v.BuildTemplate())
|
||||
case PluginProvider:
|
||||
if err := v.Fetch(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vaultCluster := req.Task.GetVaultClusterName()
|
||||
@@ -176,7 +190,12 @@ func (h *secretsHook) buildSecretProviders(secretDir string) ([]SecretProvider,
|
||||
providers = append(providers, p)
|
||||
}
|
||||
default:
|
||||
multierror.Append(mErr, fmt.Errorf("unknown secret provider type: %s", s.Provider))
|
||||
plug, err := commonplugins.NewExternalSecretsPlugin(h.clientConfig.CommonPluginDir, s.Provider)
|
||||
if err != nil {
|
||||
multierror.Append(mErr, err)
|
||||
continue
|
||||
}
|
||||
providers = append(providers, secrets.NewExternalPluginProvider(plug, s.Name, s.Path))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
client/commonplugins/commonplugins.go
Normal file
63
client/commonplugins/commonplugins.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package commonplugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPluginNotExists error = errors.New("plugin not found")
|
||||
ErrPluginNotExecutable error = errors.New("plugin not executable")
|
||||
)
|
||||
|
||||
type CommonPlugin interface {
|
||||
Fingerprint(ctx context.Context) (*PluginFingerprint, error)
|
||||
}
|
||||
|
||||
// CommonPlugins are expected to respond to 'fingerprint' calls with json that
|
||||
// unmarshals to this struct.
|
||||
type PluginFingerprint struct {
|
||||
Version *version.Version `json:"version"`
|
||||
Type *string `json:"type"`
|
||||
}
|
||||
|
||||
// runPlugin is a helper for executing the provided Cmd and capturing stdout/stderr.
|
||||
// This helper implements both the soft and hard timeouts defined by the common
|
||||
// plugins interface.
|
||||
func runPlugin(cmd *exec.Cmd, killTimeout time.Duration) (stdout, stderr []byte, err error) {
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stderr = &errBuf
|
||||
|
||||
done := make(chan error, 1)
|
||||
cmd.Cancel = func() error {
|
||||
var cancelErr error
|
||||
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
killTimer := time.NewTimer(killTimeout)
|
||||
defer killTimer.Stop()
|
||||
|
||||
select {
|
||||
case <-killTimer.C:
|
||||
cancelErr = cmd.Process.Kill()
|
||||
case <-done:
|
||||
}
|
||||
|
||||
return cancelErr
|
||||
}
|
||||
|
||||
// start the command
|
||||
stdout, err = cmd.Output()
|
||||
done <- err
|
||||
|
||||
stderr = errBuf.Bytes()
|
||||
return
|
||||
}
|
||||
114
client/commonplugins/secrets_plugin.go
Normal file
114
client/commonplugins/secrets_plugin.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package commonplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
)
|
||||
|
||||
const (
|
||||
SecretsPluginDir = "secrets"
|
||||
|
||||
// The timeout for the plugin command before it is send SIGTERM
|
||||
SecretsCmdTimeout = 10 * time.Second
|
||||
|
||||
// The timeout before the command is sent SIGKILL after being SIGTERM'd
|
||||
SecretsKillTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type SecretsPlugin interface {
|
||||
CommonPlugin
|
||||
Fetch(ctx context.Context, path string) (*SecretResponse, error)
|
||||
}
|
||||
|
||||
type SecretResponse struct {
|
||||
Result map[string]string `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
}
|
||||
|
||||
type externalSecretsPlugin struct {
|
||||
logger log.Logger
|
||||
|
||||
// pluginPath is the path on the host to the plugin executable
|
||||
pluginPath string
|
||||
}
|
||||
|
||||
func NewExternalSecretsPlugin(commonPluginDir string, name string) (*externalSecretsPlugin, error) {
|
||||
executable := filepath.Join(commonPluginDir, SecretsPluginDir, name)
|
||||
f, err := os.Stat(executable)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !helper.IsExecutable(f) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrPluginNotExecutable, name)
|
||||
}
|
||||
|
||||
return &externalSecretsPlugin{
|
||||
pluginPath: executable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *externalSecretsPlugin) Fingerprint(ctx context.Context) (*PluginFingerprint, error) {
|
||||
plugCtx, cancel := context.WithTimeout(ctx, SecretsCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(plugCtx, e.pluginPath, "fingerprint")
|
||||
cmd.Env = []string{
|
||||
"CPI_OPERATION=fingerprint",
|
||||
}
|
||||
|
||||
stdout, stderr, err := runPlugin(cmd, SecretsKillTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(stderr) > 0 {
|
||||
e.logger.Info("fingerprint command stderr output", "msg", string(stderr))
|
||||
}
|
||||
|
||||
res := &PluginFingerprint{}
|
||||
if err := json.Unmarshal(stdout, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (e *externalSecretsPlugin) Fetch(ctx context.Context, path string) (*SecretResponse, error) {
|
||||
plugCtx, cancel := context.WithTimeout(ctx, SecretsCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(plugCtx, e.pluginPath, "fetch", path)
|
||||
cmd.Env = []string{
|
||||
"CPI_OPERATION=fetch",
|
||||
}
|
||||
|
||||
stdout, stderr, err := runPlugin(cmd, SecretsKillTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(stderr) > 0 {
|
||||
e.logger.Info("fetch command stderr output", "msg", string(stderr))
|
||||
}
|
||||
|
||||
res := &SecretResponse{}
|
||||
if err := json.Unmarshal(stdout, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
132
client/commonplugins/secrets_plugin_test.go
Normal file
132
client/commonplugins/secrets_plugin_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package commonplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestExternalSecretsPlugin_Fingerprint(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
t.Run("runs successfully", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\ncat <<EOF\n%s\nEOF\n", `{"type": "secrets", "version": "1.0.0"}`))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
res, err := plugin.Fingerprint(context.Background())
|
||||
must.NoError(t, err)
|
||||
|
||||
must.Eq(t, *res.Type, "secrets")
|
||||
must.Eq(t, res.Version.String(), "1.0.0")
|
||||
})
|
||||
|
||||
t.Run("errors on non-zero exit code", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Append([]byte{}, "#!/bin/sh\nexit 1\n"))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
res, err := plugin.Fingerprint(context.Background())
|
||||
must.Error(t, err)
|
||||
must.Nil(t, res)
|
||||
})
|
||||
|
||||
t.Run("errors on timeout", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\nleep .5\n"))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = plugin.Fingerprint(ctx)
|
||||
must.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("errors on invalid json", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Append([]byte{}, "#!/bin/sh\ncat <<EOF\ninvalid\nEOF\n"))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
res, err := plugin.Fingerprint(context.Background())
|
||||
must.Error(t, err)
|
||||
must.Nil(t, res)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExternalSecretsPlugin_Fetch(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
t.Run("runs successfully", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\ncat <<EOF\n%s\nEOF\n", `{"result": {"key": "value"}}`))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
res, err := plugin.Fetch(context.Background(), "test-path")
|
||||
must.NoError(t, err)
|
||||
|
||||
exp := map[string]string{"key": "value"}
|
||||
must.Eq(t, res.Result, exp)
|
||||
})
|
||||
|
||||
t.Run("errors on non-zero exit code", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Append([]byte{}, "#!/bin/sh\nexit 1\n"))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
_, err = plugin.Fetch(context.Background(), "test-path")
|
||||
must.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("errors on timeout", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Append([]byte{}, "#!/bin/sh\nsleep .5\n"))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err = plugin.Fetch(ctx, "dummy-path")
|
||||
must.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("errors on timeout", func(t *testing.T) {
|
||||
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\ncat <<EOF\n%s\nEOF\n", `invalid`))
|
||||
|
||||
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
|
||||
must.NoError(t, err)
|
||||
|
||||
_, err = plugin.Fetch(context.Background(), "dummy-path")
|
||||
must.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func setupTestPlugin(t *testing.T, b []byte) (string, string) {
|
||||
dir := t.TempDir()
|
||||
plugin := "test-plugin"
|
||||
|
||||
// NewExternalSecretsPlugin expects the subdir "secrets" to be present
|
||||
secretDir := filepath.Join(dir, SecretsPluginDir)
|
||||
must.NoError(t, os.Mkdir(secretDir, 0755))
|
||||
|
||||
path := filepath.Join(secretDir, plugin)
|
||||
must.NoError(t, os.WriteFile(path, b, 0755))
|
||||
|
||||
return dir, plugin
|
||||
}
|
||||
@@ -340,6 +340,10 @@ type Config struct {
|
||||
// HostNetworks is a map of the conigured host networks by name.
|
||||
HostNetworks map[string]*structs.ClientHostNetworkConfig
|
||||
|
||||
// CommonPluginDir is the root directory for plugins that implement
|
||||
// the common plugin interface
|
||||
CommonPluginDir string
|
||||
|
||||
// BindWildcardDefaultHostNetwork toggles if the default host network should accept all
|
||||
// destinations (true) or only filter on the IP of the default host network (false) when
|
||||
// port mapping. This allows Nomad clients with no defined host networks to accept and
|
||||
|
||||
@@ -43,7 +43,7 @@ var (
|
||||
"nomad": NewNomadFingerprint,
|
||||
"plugins_cni": NewPluginsCNIFingerprint,
|
||||
"host_volume_plugins": NewPluginsHostVolumeFingerprint,
|
||||
"secrets": NewPluginsSecretsFingerprint,
|
||||
"secrets_plugins": NewPluginsSecretsFingerprint,
|
||||
"signal": NewSignalFingerprint,
|
||||
"storage": NewStorageFingerprint,
|
||||
"vault": NewVaultFingerprint,
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/nomad/client/commonplugins"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
)
|
||||
|
||||
type SecretsPluginFingerprint struct {
|
||||
@@ -21,13 +27,70 @@ func NewPluginsSecretsFingerprint(logger hclog.Logger) Fingerprint {
|
||||
|
||||
func (s *SecretsPluginFingerprint) Fingerprint(request *FingerprintRequest, response *FingerprintResponse) error {
|
||||
// Add builtin secrets providers
|
||||
response.AddAttribute("plugins.secrets.nomad.version", "1.0.0")
|
||||
response.AddAttribute("plugins.secrets.vault.version", "1.0.0")
|
||||
defer func() {
|
||||
response.AddAttribute("plugins.secrets.nomad.version", "1.0.0")
|
||||
response.AddAttribute("plugins.secrets.vault.version", "1.0.0")
|
||||
}()
|
||||
response.Detected = true
|
||||
|
||||
pluginDir := request.Config.CommonPluginDir
|
||||
if pluginDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
secretsDir := filepath.Join(pluginDir, commonplugins.SecretsPluginDir)
|
||||
|
||||
files, err := helper.FindExecutableFiles(secretsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
s.logger.Trace("secrets plugin dir does not exist", "dir", secretsDir)
|
||||
} else {
|
||||
s.logger.Warn("error finding secrets plugins", "dir", secretsDir, "error", err)
|
||||
}
|
||||
return nil // don't halt agent start
|
||||
}
|
||||
|
||||
// map of plugin names to fingerprinted versions
|
||||
plugins := map[string]string{}
|
||||
for name := range files {
|
||||
plug, err := commonplugins.NewExternalSecretsPlugin(request.Config.CommonPluginDir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fprint, err := plug.Fingerprint(context.Background())
|
||||
if err != nil {
|
||||
s.logger.Error("secrets plugin failed fingerprint", "plugin", name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if fprint.Version == nil || fprint.Type == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
plugins[name] = fprint.Version.Original()
|
||||
}
|
||||
|
||||
// if this was a reload, wipe what was there before
|
||||
for k := range request.Node.Attributes {
|
||||
if strings.HasPrefix(k, "plugins.secrets.") {
|
||||
response.RemoveAttribute(k)
|
||||
}
|
||||
}
|
||||
|
||||
// set the attribute(s)
|
||||
for plugin, version := range plugins {
|
||||
s.logger.Debug("detected plugin", "plugin_id", plugin, "version", version)
|
||||
response.AddAttribute("plugins.secrets."+plugin+".version", version)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SecretsPluginFingerprint) Periodic() (bool, time.Duration) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func (s *SecretsPluginFingerprint) Reload() {
|
||||
// secrets plugins are re-detected on agent reload
|
||||
}
|
||||
|
||||
@@ -4,21 +4,86 @@
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestPluginsSecretsFingerprint(t *testing.T) {
|
||||
fp := NewPluginsSecretsFingerprint(testlog.HCLogger(t))
|
||||
|
||||
resp := FingerprintResponse{}
|
||||
err := fp.Fingerprint(&FingerprintRequest{}, &resp)
|
||||
node := &structs.Node{Attributes: map[string]string{}}
|
||||
cfg := &config.Config{CommonPluginDir: ""}
|
||||
req := &FingerprintRequest{Config: cfg, Node: node}
|
||||
|
||||
for name, path := range map[string]string{
|
||||
"empty": "",
|
||||
"non-existent": "/nowhere",
|
||||
"impossible": "secrets_plugins_test.go",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp := FingerprintResponse{}
|
||||
cfg.CommonPluginDir = path
|
||||
err := fp.Fingerprint(req, &resp)
|
||||
must.NoError(t, err)
|
||||
must.True(t, resp.Detected) // always true due to "mkdir" built-in
|
||||
})
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test scripts not built for windows") // db TODO(1.10.0)
|
||||
}
|
||||
|
||||
// happy path: dir exists. this one will contain a single valid plugin.
|
||||
tmp := t.TempDir()
|
||||
secretsDir := filepath.Join(tmp, "secrets")
|
||||
os.Mkdir(secretsDir, 0777)
|
||||
cfg.CommonPluginDir = tmp
|
||||
|
||||
files := []struct {
|
||||
name string
|
||||
contents string
|
||||
perm os.FileMode
|
||||
}{
|
||||
// only this first one should be detected as a valid plugin
|
||||
{"happy-plugin", "#!/usr/bin/env sh\necho '{\"type\": \"secrets\", \"version\": \"0.0.1\"}'", 0700},
|
||||
{"not-a-plugin", "#!/usr/bin/env sh\necho 'not a version'", 0700},
|
||||
{"unhappy-plugin", "#!/usr/bin/env sh\necho 'sad plugin is sad'; exit 1", 0700},
|
||||
{"not-executable", "do not execute me", 0400},
|
||||
}
|
||||
for _, f := range files {
|
||||
must.NoError(t, os.WriteFile(filepath.Join(secretsDir, f.name), []byte(f.contents), f.perm))
|
||||
}
|
||||
// directories should be ignored
|
||||
must.NoError(t, os.Mkdir(filepath.Join(secretsDir, "a-directory"), 0700))
|
||||
|
||||
// do the fingerprint
|
||||
resp := &FingerprintResponse{}
|
||||
err := fp.Fingerprint(req, resp)
|
||||
must.NoError(t, err)
|
||||
must.True(t, resp.Detected)
|
||||
must.MapContainsKeys(t, resp.Attributes, []string{
|
||||
"plugins.secrets.nomad.version",
|
||||
"plugins.secrets.vault.version",
|
||||
})
|
||||
must.Eq(t, map[string]string{
|
||||
"plugins.secrets.happy-plugin.version": "0.0.1",
|
||||
"plugins.secrets.nomad.version": "1.0.0",
|
||||
"plugins.secrets.vault.version": "1.0.0",
|
||||
}, resp.Attributes)
|
||||
|
||||
// do it again after deleting our one good plugin.
|
||||
// repeat runs should wipe attributes, so nothing should remain.
|
||||
node.Attributes = resp.Attributes
|
||||
must.NoError(t, os.Remove(filepath.Join(secretsDir, "happy-plugin")))
|
||||
|
||||
resp = &FingerprintResponse{}
|
||||
err = fp.Fingerprint(req, resp)
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, map[string]string{
|
||||
"plugins.secrets.happy-plugin.version": "", // empty value means removed
|
||||
"plugins.secrets.nomad.version": "1.0.0",
|
||||
"plugins.secrets.vault.version": "1.0.0",
|
||||
}, resp.Attributes)
|
||||
}
|
||||
|
||||
@@ -828,6 +828,7 @@ func convertClientConfig(agentConfig *Config) (*clientconfig.Config, error) {
|
||||
conf.AllocDir = filepath.Join(agentConfig.DataDir, "alloc")
|
||||
conf.HostVolumesDir = filepath.Join(agentConfig.DataDir, "host_volumes")
|
||||
conf.HostVolumePluginDir = filepath.Join(agentConfig.DataDir, "host_volume_plugins")
|
||||
conf.CommonPluginDir = filepath.Join(agentConfig.DataDir, "common_plugins")
|
||||
dataParent := filepath.Dir(agentConfig.DataDir)
|
||||
conf.AllocMountsDir = filepath.Join(dataParent, "alloc_mounts")
|
||||
}
|
||||
@@ -846,6 +847,9 @@ func convertClientConfig(agentConfig *Config) (*clientconfig.Config, error) {
|
||||
if agentConfig.Client.HostVolumesDir != "" {
|
||||
conf.HostVolumesDir = agentConfig.Client.HostVolumesDir
|
||||
}
|
||||
if agentConfig.Client.CommonPluginDir != "" {
|
||||
conf.CommonPluginDir = agentConfig.Client.CommonPluginDir
|
||||
}
|
||||
if agentConfig.Client.NetworkInterface != "" {
|
||||
conf.NetworkInterface = agentConfig.Client.NetworkInterface
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user