Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1050,11 +1050,16 @@ type Secret struct {
Name string `hcl:"name,label"`
Provider string `hcl:"provider,optional"`
Path string `hcl:"path,optional"`
Timeout time.Duration `hcl:"timeout,optional"`
Config map[string]any `hcl:"config,block"`
Env map[string]string `hcl:"env,block"`
}

func (s *Secret) Canonicalize() {
if s.Timeout == 0 {
s.Timeout = 10 * time.Second // Kept in sync with commonplugins.SecretsCmdTimeout
}

if len(s.Config) == 0 {
s.Config = nil
}
Expand Down
1 change: 1 addition & 0 deletions api/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ func TestTask_Canonicalize_Secret(t *testing.T) {
Name: "test-secret",
Provider: "test-provider",
Path: "/test/path",
Timeout: 10 * time.Second,
Config: nil,
Env: nil,
}
Expand Down
6 changes: 5 additions & 1 deletion client/allocrunner/taskrunner/secrets_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,11 @@ func (h *secretsHook) buildSecretProviders(secretDir string) ([]TemplateProvider
tmplProvider = append(tmplProvider, p)
}
default:
plug, err := commonplugins.NewExternalSecretsPlugin(h.clientConfig.CommonPluginDir, s.Provider)
plug, err := commonplugins.NewExternalSecretsPlugin(
h.clientConfig.CommonPluginDir,
s.Provider,
commonplugins.WithTimeout(s.Timeout),
)
if err != nil {
multierror.Append(mErr, err)
continue
Expand Down
35 changes: 31 additions & 4 deletions client/commonplugins/secrets_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ const (
SecretsKillTimeout = 2 * time.Second
)

// SecretsPluginOption is a functional option for configuring an externalSecretsPlugin
type SecretsPluginOption func(*externalSecretsPlugin)

// WithTimeout sets a custom timeout for plugin execution.
// If not specified or set to 0, defaults to SecretsCmdTimeout (10 seconds).
func WithTimeout(timeout time.Duration) SecretsPluginOption {
return func(p *externalSecretsPlugin) {
if timeout > 0 {
p.timeout = timeout
}
}
}

type SecretsPlugin interface {
CommonPlugin
Fetch(ctx context.Context, path string, env map[string]string) (*SecretResponse, error)
Expand All @@ -42,12 +55,15 @@ type externalSecretsPlugin struct {

// pluginPath is the path on the host to the plugin executable
pluginPath string

// timeout is the duration after which the plugin command is sent SIGTERM
timeout time.Duration
}

// NewExternalSecretsPlugin creates an instance of a secrets plugin by validating the plugin
// binary exists and is executable, and parsing any string key/value pairs out of the config
// which will be used as environment variables for Fetch.
func NewExternalSecretsPlugin(commonPluginDir string, name string) (*externalSecretsPlugin, error) {
func NewExternalSecretsPlugin(commonPluginDir string, name string, opts ...SecretsPluginOption) (*externalSecretsPlugin, error) {
// validate plugin
if runtime.GOOS == "windows" {
name += ".exe"
Expand All @@ -64,11 +80,22 @@ func NewExternalSecretsPlugin(commonPluginDir string, name string) (*externalSec
return nil, fmt.Errorf("%w: %q", ErrPluginNotExecutable, name)
}

return &externalSecretsPlugin{pluginPath: executable}, nil
// Create plugin with default timeout
plugin := &externalSecretsPlugin{
pluginPath: executable,
timeout: SecretsCmdTimeout,
}

// Apply options
for _, opt := range opts {
opt(plugin)
}

return plugin, nil
}

func (e *externalSecretsPlugin) Fingerprint(ctx context.Context) (*PluginFingerprint, error) {
plugCtx, cancel := context.WithTimeout(ctx, SecretsCmdTimeout)
plugCtx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()

cmd := exec.CommandContext(plugCtx, e.pluginPath, "fingerprint")
Expand All @@ -94,7 +121,7 @@ func (e *externalSecretsPlugin) Fingerprint(ctx context.Context) (*PluginFingerp
}

func (e *externalSecretsPlugin) Fetch(ctx context.Context, path string, env map[string]string) (*SecretResponse, error) {
plugCtx, cancel := context.WithTimeout(ctx, SecretsCmdTimeout)
plugCtx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()

cmd := exec.CommandContext(plugCtx, e.pluginPath, "fetch", path)
Expand Down
46 changes: 46 additions & 0 deletions client/commonplugins/secrets_plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,52 @@ func TestExternalSecretsPlugin_Fetch(t *testing.T) {
})
}

func TestExternalSecretsPlugin_CustomTimeout(t *testing.T) {
ci.Parallel(t)

t.Run("uses custom timeout when specified", func(t *testing.T) {
// Plugin sleeps for 2 seconds
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\nsleep 2\ncat <<EOF\n%s\nEOF\n", `{"result": {"key": "value"}}`))

// With 1 second timeout, should fail
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName, WithTimeout(1*time.Second))
must.NoError(t, err)

_, err = plugin.Fetch(context.Background(), "test-path", nil)
must.Error(t, err)
must.ErrorContains(t, err, "signal: terminated")
})

t.Run("succeeds with sufficient timeout", func(t *testing.T) {
// Plugin sleeps for 1 second
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\nsleep 1\ncat <<EOF\n%s\nEOF\n", `{"result": {"key": "value"}}`))

// With 5 second timeout, should succeed
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName, WithTimeout(5*time.Second))
must.NoError(t, err)

res, err := plugin.Fetch(context.Background(), "test-path", nil)
must.NoError(t, err)

exp := map[string]string{"key": "value"}
must.Eq(t, res.Result, exp)
})

t.Run("defaults to 10s when no timeout specified", func(t *testing.T) {
pluginDir, pluginName := setupTestPlugin(t, fmt.Appendf([]byte{}, "#!/bin/sh\ncat <<EOF\n%s\nEOF\n", `{"result": {"key": "value"}}`))

// No timeout option, should use default 10s
plugin, err := NewExternalSecretsPlugin(pluginDir, pluginName)
must.NoError(t, err)

res, err := plugin.Fetch(context.Background(), "test-path", nil)
must.NoError(t, err)

exp := map[string]string{"key": "value"}
must.Eq(t, res.Result, exp)
})
}

func setupTestPlugin(t *testing.T, b []byte) (string, string) {
dir := t.TempDir()
plugin := "test-plugin"
Expand Down
1 change: 1 addition & 0 deletions command/agent/job_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,7 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup,
Name: s.Name,
Provider: s.Provider,
Path: s.Path,
Timeout: s.Timeout,
Config: s.Config,
Env: s.Env,
})
Expand Down
1 change: 1 addition & 0 deletions e2e/secret/input/custom_secret.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ job "custom_secret" {
secret "testsecret" {
provider = "test_secret_plugin"
path = "some/path"
timeout = "30s"
env {
// The custom plugin will output this as part of the result field
TEST_ENV = "${var.secret_value}"
Expand Down
4 changes: 4 additions & 0 deletions nomad/structs/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -10427,6 +10427,7 @@ type Secret struct {
Name string
Provider string
Path string
Timeout time.Duration
Config map[string]any
Env map[string]string
}
Expand All @@ -10443,6 +10444,8 @@ func (s *Secret) Equal(o *Secret) bool {
return false
case s.Path != o.Path:
return false
case s.Timeout != o.Timeout:
return false
case !maps.Equal(s.Config, o.Config):
return false
case !maps.Equal(s.Env, o.Env):
Expand All @@ -10468,6 +10471,7 @@ func (s *Secret) Copy() *Secret {
Name: s.Name,
Provider: s.Provider,
Path: s.Path,
Timeout: s.Timeout,
Config: confCopy.(map[string]any),
Env: maps.Clone(s.Env),
}
Expand Down