diff --git a/client/allocrunner/taskrunner/secrets/vault_provider.go b/client/allocrunner/taskrunner/secrets/vault_provider.go new file mode 100644 index 000000000..6a60b6cc2 --- /dev/null +++ b/client/allocrunner/taskrunner/secrets/vault_provider.go @@ -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) +} diff --git a/client/allocrunner/taskrunner/secrets/vault_provider_test.go b/client/allocrunner/taskrunner/secrets/vault_provider_test.go new file mode 100644 index 000000000..a212db7ea --- /dev/null +++ b/client/allocrunner/taskrunner/secrets/vault_provider_test.go @@ -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") +} diff --git a/client/allocrunner/taskrunner/secrets_hook.go b/client/allocrunner/taskrunner/secrets_hook.go index c53cccadf..3979c023c 100644 --- a/client/allocrunner/taskrunner/secrets_hook.go +++ b/client/allocrunner/taskrunner/secrets_hook.go @@ -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)) } diff --git a/client/allocrunner/taskrunner/secrets_hook_test.go b/client/allocrunner/taskrunner/secrets_hook_test.go index ba6c47d0d..98ec9b3b5 100644 --- a/client/allocrunner/taskrunner/secrets_hook_test.go +++ b/client/allocrunner/taskrunner/secrets_hook_test.go @@ -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) +}