secrets: add vault secrets provider (#26198)

This commit is contained in:
Michael Smithhisler
2025-07-07 14:40:29 -04:00
parent 20a855ea13
commit 2d0ce43c47
4 changed files with 279 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package secrets
import (
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/go-envparse"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/mitchellh/mapstructure"
)
const (
VAULT_KV = "kv"
VAULT_KV_V2 = "kv_v2"
)
type vaultProviderConfig struct {
Engine string `mapstructure:"engine"`
}
func defaultVaultConfig() *vaultProviderConfig {
return &vaultProviderConfig{
Engine: VAULT_KV,
}
}
type VaultProvider struct {
secret *structs.Secret
tmplPath string
conf *vaultProviderConfig
}
// NewVaultProvider takes a task secret and decodes the config, overwriting the default config fields
// with any provided fields, returning an error if the secret or secret's config is invalid.
func NewVaultProvider(s *structs.Secret, path string) (*VaultProvider, error) {
if s == nil {
return nil, fmt.Errorf("empty secret for vault provider")
}
conf := defaultVaultConfig()
if err := mapstructure.Decode(s.Config, conf); err != nil {
return nil, err
}
return &VaultProvider{
secret: s,
tmplPath: path,
conf: conf,
}, nil
}
func (v *VaultProvider) BuildTemplate() *structs.Template {
indexKey := ".Data"
if v.conf.Engine == VAULT_KV_V2 {
indexKey = ".Data.data"
}
data := fmt.Sprintf(`
{{ with secret "%s" }}
{{ range $k, $v := %s }}
secret.%s.{{ $k }}={{ $v }}
{{ end }}
{{ end }}`,
v.secret.Path, indexKey, v.secret.Name)
return &structs.Template{
EmbeddedTmpl: data,
DestPath: v.tmplPath,
ChangeMode: structs.TemplateChangeModeNoop,
Once: true,
}
}
func (v *VaultProvider) Parse() (map[string]string, error) {
// we checked escape before we rendered the file
dest := filepath.Clean(v.tmplPath)
f, err := os.Open(dest)
if err != nil {
return nil, fmt.Errorf("error opening env template: %v", err)
}
defer func() {
f.Close()
os.Remove(dest)
}()
return envparse.Parse(f)
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package secrets
import (
"os"
"path/filepath"
"testing"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/shoenig/test/must"
)
func TestVaultProvider_BuildTemplate(t *testing.T) {
t.Run("kv template succeeds", func(t *testing.T) {
testDir := t.TempDir()
testSecret := &structs.Secret{
Name: "foo",
Provider: "vault",
Path: "/test/path",
}
p, err := NewVaultProvider(testSecret, testDir)
must.NoError(t, err)
tmpl := p.BuildTemplate()
must.NotNil(t, tmpl)
// expected template should have correct path, index, and name
expectedTmpl := `
{{ with secret "/test/path" }}
{{ range $k, $v := .Data }}
secret.foo.{{ $k }}={{ $v }}
{{ end }}
{{ end }}`
// validate template string contains expected data
must.Eq(t, tmpl.EmbeddedTmpl, expectedTmpl)
})
t.Run("kv_v2 template succeeds", func(t *testing.T) {
testDir := t.TempDir()
testSecret := &structs.Secret{
Name: "foo",
Provider: "vault",
Path: "/test/path",
Config: map[string]any{
"engine": VAULT_KV_V2,
},
}
p, err := NewVaultProvider(testSecret, testDir)
must.NoError(t, err)
tmpl := p.BuildTemplate()
must.NotNil(t, tmpl)
// expected template should have correct path, index, and name
expectedTmpl := `
{{ with secret "/test/path" }}
{{ range $k, $v := .Data.data }}
secret.foo.{{ $k }}={{ $v }}
{{ end }}
{{ end }}`
// validate template string contains expected data
must.Eq(t, tmpl.EmbeddedTmpl, expectedTmpl)
})
t.Run("invalid config options errors", func(t *testing.T) {
testDir := t.TempDir()
testSecret := &structs.Secret{
Name: "foo",
Provider: "vault",
Path: "/test/path",
Config: map[string]any{
"engine": 123,
},
}
_, err := NewVaultProvider(testSecret, testDir)
must.Error(t, err)
})
}
func TestVaultProvider_Parse(t *testing.T) {
testDir := t.TempDir()
tmplPath := filepath.Join(testDir, "foo")
data := "foo=bar"
err := os.WriteFile(tmplPath, []byte(data), 0777)
must.NoError(t, err)
p, err := NewVaultProvider(&structs.Secret{}, tmplPath)
must.NoError(t, err)
vars, err := p.Parse()
must.NoError(t, err)
must.Eq(t, vars, map[string]string{"foo": "bar"})
_, err = os.Stat(tmplPath)
must.ErrorContains(t, err, "no such file")
}

View File

@@ -173,7 +173,11 @@ func (h *secretsHook) buildSecretProviders(secretDir string) ([]SecretProvider,
providers = append(providers, p)
}
case "vault":
// Unimplemented
if p, err := secrets.NewVaultProvider(s, tmplPath); err != nil {
multierror.Append(mErr, err)
} else {
providers = append(providers, p)
}
default:
multierror.Append(mErr, fmt.Errorf("unknown secret provider type: %s", s.Provider))
}

View File

@@ -19,9 +19,11 @@ import (
"github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/helper/bufconndialer"
"github.com/hashicorp/nomad/helper/pointer"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
structsc "github.com/hashicorp/nomad/nomad/structs/config"
"github.com/shoenig/test/must"
)
@@ -215,3 +217,84 @@ func TestSecretsHook_Prestart_Nomad(t *testing.T) {
must.Eq(t, expected, secretHook.taskSecrets)
})
}
func TestSecretsHook_Prestart_Vault(t *testing.T) {
ci.Parallel(t)
secretsResp := `
{
"Data": {
"data": {
"secret": "secret"
},
"metadata": {
"created_time": "2023-10-18T15:58:29.65137Z",
"custom_metadata": null,
"deletion_time": "",
"destroyed": false,
"version": 1
}
}
}`
// Start test server to simulate Vault cluster responses.
// reqCh := make(chan any)
defaultVaultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, secretsResp)
}))
t.Cleanup(defaultVaultServer.Close)
// Setup client with Vault config.
clientConfig := config.DefaultConfig()
clientConfig.TemplateConfig.DisableSandbox = true
clientConfig.VaultConfigs = map[string]*structsc.VaultConfig{
structs.VaultDefaultCluster: {
Name: structs.VaultDefaultCluster,
Enabled: pointer.Of(true),
Addr: defaultVaultServer.URL,
},
}
taskDir := t.TempDir()
alloc := mock.MinAlloc()
task := alloc.Job.TaskGroups[0].Tasks[0]
conf := &secretsHookConfig{
// alloc: alloc,
logger: testlog.HCLogger(t),
lifecycle: trtesting.NewMockTaskHooks(),
events: &trtesting.MockEmitter{},
clientConfig: clientConfig,
envBuilder: taskenv.NewBuilder(mock.Node(), alloc, task, clientConfig.Region),
}
secretHook := newSecretsHook(conf, []*structs.Secret{
{
Name: "test_secret",
Provider: "vault",
Path: "/test/path",
Config: map[string]any{
"engine": "kv_v2",
},
},
})
// Start template hook with a timeout context to ensure it exists.
req := &interfaces.TaskPrestartRequest{
Alloc: alloc,
Task: task,
TaskDir: &allocdir.TaskDir{Dir: taskDir, SecretsDir: taskDir},
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
t.Cleanup(cancel)
err := secretHook.Prestart(ctx, req, nil)
must.NoError(t, err)
exp := map[string]string{
"secret.test_secret.secret": "secret",
}
must.Eq(t, exp, secretHook.taskSecrets)
}