diff --git a/ci/test-core.json b/ci/test-core.json index d117e2a0b..7bb97c929 100644 --- a/ci/test-core.json +++ b/ci/test-core.json @@ -11,6 +11,7 @@ "client/allocdir/...", "client/allochealth/...", "client/allocwatcher/...", + "client/commonplugins/...", "client/config/...", "client/consul/...", "client/devicemanager/...", diff --git a/client/allocrunner/taskrunner/secrets/plugin_provider.go b/client/allocrunner/taskrunner/secrets/plugin_provider.go new file mode 100644 index 000000000..64d5bad7c --- /dev/null +++ b/client/allocrunner/taskrunner/secrets/plugin_provider.go @@ -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 +} diff --git a/client/allocrunner/taskrunner/secrets/plugin_provider_test.go b/client/allocrunner/taskrunner/secrets/plugin_provider_test.go new file mode 100644 index 000000000..b135d7f8b --- /dev/null +++ b/client/allocrunner/taskrunner/secrets/plugin_provider_test.go @@ -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) + }) +} diff --git a/client/allocrunner/taskrunner/secrets_hook.go b/client/allocrunner/taskrunner/secrets_hook.go index 68449a0df..b11947273 100644 --- a/client/allocrunner/taskrunner/secrets_hook.go +++ b/client/allocrunner/taskrunner/secrets_hook.go @@ -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)) } } diff --git a/client/commonplugins/commonplugins.go b/client/commonplugins/commonplugins.go new file mode 100644 index 000000000..58aa69349 --- /dev/null +++ b/client/commonplugins/commonplugins.go @@ -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 +} diff --git a/client/commonplugins/secrets_plugin.go b/client/commonplugins/secrets_plugin.go new file mode 100644 index 000000000..530bf1348 --- /dev/null +++ b/client/commonplugins/secrets_plugin.go @@ -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 +} diff --git a/client/commonplugins/secrets_plugin_test.go b/client/commonplugins/secrets_plugin_test.go new file mode 100644 index 000000000..6c92e123f --- /dev/null +++ b/client/commonplugins/secrets_plugin_test.go @@ -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 <