diff --git a/api/tasks.go b/api/tasks.go index efc1b87af9d..998e86b10c0 100644 --- a/api/tasks.go +++ b/api/tasks.go @@ -832,11 +832,12 @@ func (tmpl *Template) Canonicalize() { } type Vault struct { - Policies []string `hcl:"policies,optional"` - Namespace *string `mapstructure:"namespace" hcl:"namespace,optional"` - Env *bool `hcl:"env,optional"` - ChangeMode *string `mapstructure:"change_mode" hcl:"change_mode,optional"` - ChangeSignal *string `mapstructure:"change_signal" hcl:"change_signal,optional"` + Policies []string `hcl:"policies,optional"` + Namespace *string `mapstructure:"namespace" hcl:"namespace,optional"` + Env *bool `hcl:"env,optional"` + ChangeMode *string `mapstructure:"change_mode" hcl:"change_mode,optional"` + ChangeSignal *string `mapstructure:"change_signal" hcl:"change_signal,optional"` + Secrets []*VaultSecret `hcl:"secret,block"` } func (v *Vault) Canonicalize() { @@ -854,6 +855,11 @@ func (v *Vault) Canonicalize() { } } +type VaultSecret struct { + Name string `hcl:"name,label"` + Path string `hcl:"path"` +} + // NewTask creates and initializes a new Task. func NewTask(name, driver string) *Task { return &Task{ diff --git a/client/allocrunner/taskrunner/artifact_hook_test.go b/client/allocrunner/taskrunner/artifact_hook_test.go index 121370867db..884ebb41d2d 100644 --- a/client/allocrunner/taskrunner/artifact_hook_test.go +++ b/client/allocrunner/taskrunner/artifact_hook_test.go @@ -94,7 +94,7 @@ func TestTaskRunner_ArtifactHook_PartialDone(t *testing.T) { }() req := &interfaces.TaskPrestartRequest{ - TaskEnv: taskenv.NewTaskEnv(nil, nil, nil, nil, destdir, ""), + TaskEnv: taskenv.NewTaskEnv(nil, nil, nil, nil, nil, destdir, ""), TaskDir: &allocdir.TaskDir{Dir: destdir}, Task: &structs.Task{ Artifacts: []*structs.TaskArtifact{ diff --git a/client/allocrunner/taskrunner/envoy_version_hook_test.go b/client/allocrunner/taskrunner/envoy_version_hook_test.go index 26d739bd7c7..82dd05c3955 100644 --- a/client/allocrunner/taskrunner/envoy_version_hook_test.go +++ b/client/allocrunner/taskrunner/envoy_version_hook_test.go @@ -20,7 +20,7 @@ var ( taskEnvDefault = taskenv.NewTaskEnv(nil, nil, nil, map[string]string{ "meta.connect.sidecar_image": envoy.ImageFormat, "meta.connect.gateway_image": envoy.ImageFormat, - }, "", "") + }, nil, "", "") ) func TestEnvoyVersionHook_semver(t *testing.T) { @@ -142,7 +142,7 @@ func TestEnvoyVersionHook_interpolateImage(t *testing.T) { "MY_ENVOY": "my/envoy", }, map[string]string{ "MY_ENVOY": "my/envoy", - }, nil, nil, "", "")) + }, nil, nil, nil, "", "")) require.Equal(t, "my/envoy", task.Config["image"]) }) diff --git a/client/allocrunner/taskrunner/task_runner_hooks.go b/client/allocrunner/taskrunner/task_runner_hooks.go index c9ff34441ed..2deee5b4b6c 100644 --- a/client/allocrunner/taskrunner/task_runner_hooks.go +++ b/client/allocrunner/taskrunner/task_runner_hooks.go @@ -65,7 +65,6 @@ func (tr *TaskRunner) initHooks() { newLogMonHook(tr, hookLogger), newDispatchHook(alloc, hookLogger), newVolumeHook(tr, hookLogger), - newArtifactHook(tr, hookLogger), newStatsHook(tr, tr.clientConfig.StatsCollectionInterval, hookLogger), newDeviceHook(tr.devicemanager, hookLogger), } @@ -79,6 +78,7 @@ func (tr *TaskRunner) initHooks() { if task.Vault != nil { tr.runnerHooks = append(tr.runnerHooks, newVaultHook(&vaultHookConfig{ vaultStanza: task.Vault, + envBuilder: tr.envBuilder, client: tr.vaultClient, events: tr, lifecycle: tr, @@ -89,6 +89,8 @@ func (tr *TaskRunner) initHooks() { })) } + tr.runnerHooks = append(tr.runnerHooks, newArtifactHook(tr, hookLogger)) + // Get the consul namespace for the TG of the allocation consulNamespace := tr.alloc.ConsulNamespace() diff --git a/client/allocrunner/taskrunner/template/template_test.go b/client/allocrunner/taskrunner/template/template_test.go index 84fca0a9d25..8e598a85024 100644 --- a/client/allocrunner/taskrunner/template/template_test.go +++ b/client/allocrunner/taskrunner/template/template_test.go @@ -1339,6 +1339,7 @@ func TestTaskTemplateManager_Env_InterpolatedDest(t *testing.T) { map[string]string{"NOMAD_META_path": "exists"}, map[string]string{}, map[string]string{}, + map[string]string{}, d, "") vars, err := loadTemplateEnv(templates, taskEnv) diff --git a/client/allocrunner/taskrunner/vault_hook.go b/client/allocrunner/taskrunner/vault_hook.go index 016fbf6108f..77aad9016c8 100644 --- a/client/allocrunner/taskrunner/vault_hook.go +++ b/client/allocrunner/taskrunner/vault_hook.go @@ -3,6 +3,7 @@ package taskrunner import ( "context" "fmt" + "github.com/hashicorp/nomad/client/taskenv" "io/ioutil" "os" "path/filepath" @@ -30,6 +31,8 @@ const ( // vaultTokenFile is the name of the file holding the Vault token inside the // task's secret directory vaultTokenFile = "vault_token" + + vaultHookName = "vault" ) type vaultTokenUpdateHandler interface { @@ -45,31 +48,27 @@ func (tr *TaskRunner) updatedVaultToken(token string) { } type vaultHookConfig struct { + // vaultStanza is the vault stanza for the task vaultStanza *structs.Vault + // client is the Vault client to retrieve and renew the Vault token client vaultclient.VaultClient + // eventEmitter is used to emit events to the task events ti.EventEmitter + // lifecycle is used to signal, restart and kill a task lifecycle ti.TaskLifecycle + // updater is used to update the Vault token updater vaultTokenUpdateHandler + // envBuilder is used to set secrets from Vault + envBuilder *taskenv.Builder logger log.Logger + // alloc is the allocation alloc *structs.Allocation + // taskName is the name of the task task string } type vaultHook struct { - // vaultStanza is the vault stanza for the task - vaultStanza *structs.Vault - - // eventEmitter is used to emit events to the task - eventEmitter ti.EventEmitter - - // lifecycle is used to signal, restart and kill a task - lifecycle ti.TaskLifecycle - - // updater is used to update the Vault token - updater vaultTokenUpdateHandler - - // client is the Vault client to retrieve and renew the Vault token - client vaultclient.VaultClient + config *vaultHookConfig // logger is used to log logger log.Logger @@ -81,12 +80,6 @@ type vaultHook struct { // tokenPath is the path in which to read and write the token tokenPath string - // alloc is the allocation - alloc *structs.Allocation - - // taskName is the name of the task - taskName string - // firstRun stores whether it is the first run for the hook firstRun bool @@ -97,27 +90,23 @@ type vaultHook struct { func newVaultHook(config *vaultHookConfig) *vaultHook { ctx, cancel := context.WithCancel(context.Background()) h := &vaultHook{ - vaultStanza: config.vaultStanza, - client: config.client, - eventEmitter: config.events, - lifecycle: config.lifecycle, - updater: config.updater, - alloc: config.alloc, - taskName: config.task, + config: config, + logger: config.logger.Named(vaultHookName), firstRun: true, ctx: ctx, cancel: cancel, future: newTokenFuture(), } - h.logger = config.logger.Named(h.Name()) return h } func (*vaultHook) Name() string { - return "vault" + return vaultHookName } func (h *vaultHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error { + c := h.config + // If we have already run prestart before exit early. We do not use the // PrestartDone value because we want to recover the token on restoration. first := h.firstRun @@ -152,7 +141,21 @@ func (h *vaultHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRe return nil } - h.updater.updatedVaultToken(h.future.Get()) + token := h.future.Get() + c.updater.updatedVaultToken(token) + + // Get secrets + for _, secretConfig := range c.vaultStanza.Secrets { + secret, err := c.client.GetSecret(token, secretConfig.Path) + if err != nil { + return err + } + if secret.Data == nil || secret.Data["data"] == nil { + return fmt.Errorf("no data at vault secret %s. Secret warnings: %v", secretConfig.Path, secret.Warnings) + } + c.envBuilder.SetVaultSecret(secretConfig.Name, secret.Data["data"]) + } + return nil } @@ -171,9 +174,10 @@ func (h *vaultHook) Shutdown() { // setting the initial Vault token. This is useful when the Vault token is // recovered off disk. func (h *vaultHook) run(token string) { + c := h.config // Helper for stopping token renewal stopRenewal := func() { - if err := h.client.StopRenewToken(h.future.Get()); err != nil { + if err := c.client.StopRenewToken(h.future.Get()); err != nil { h.logger.Warn("failed to stop token renewal", "error", err) } } @@ -208,7 +212,7 @@ OUTER: if err := h.writeToken(token); err != nil { errorString := "failed to write Vault token to disk" h.logger.Error(errorString, "error", err) - h.lifecycle.Kill(h.ctx, + c.lifecycle.Kill(h.ctx, structs.NewTaskEvent(structs.TaskKilling). SetFailsTask(). SetDisplayMessage(fmt.Sprintf("Vault %v", errorString))) @@ -217,7 +221,7 @@ OUTER: } // Start the renewal process - renewCh, err := h.client.RenewToken(token, 30) + renewCh, err := c.client.RenewToken(token, 30) // An error returned means the token is not being renewed if err != nil { @@ -230,12 +234,12 @@ OUTER: h.future.Set(token) if updatedToken { - switch h.vaultStanza.ChangeMode { + switch c.vaultStanza.ChangeMode { case structs.VaultChangeModeSignal: - s, err := signals.Parse(h.vaultStanza.ChangeSignal) + s, err := signals.Parse(c.vaultStanza.ChangeSignal) if err != nil { h.logger.Error("failed to parse signal", "error", err) - h.lifecycle.Kill(h.ctx, + c.lifecycle.Kill(h.ctx, structs.NewTaskEvent(structs.TaskKilling). SetFailsTask(). SetDisplayMessage(fmt.Sprintf("Vault: failed to parse signal: %v", err))) @@ -243,9 +247,9 @@ OUTER: } event := structs.NewTaskEvent(structs.TaskSignaling).SetTaskSignal(s).SetDisplayMessage("Vault: new Vault token acquired") - if err := h.lifecycle.Signal(event, h.vaultStanza.ChangeSignal); err != nil { + if err := c.lifecycle.Signal(event, c.vaultStanza.ChangeSignal); err != nil { h.logger.Error("failed to send signal", "error", err) - h.lifecycle.Kill(h.ctx, + c.lifecycle.Kill(h.ctx, structs.NewTaskEvent(structs.TaskKilling). SetFailsTask(). SetDisplayMessage(fmt.Sprintf("Vault: failed to send signal: %v", err))) @@ -253,20 +257,20 @@ OUTER: } case structs.VaultChangeModeRestart: const noFailure = false - h.lifecycle.Restart(h.ctx, + c.lifecycle.Restart(h.ctx, structs.NewTaskEvent(structs.TaskRestartSignal). SetDisplayMessage("Vault: new Vault token acquired"), false) case structs.VaultChangeModeNoop: fallthrough default: - h.logger.Error("invalid Vault change mode", "mode", h.vaultStanza.ChangeMode) + h.logger.Error("invalid Vault change mode", "mode", c.vaultStanza.ChangeMode) } // We have handled it updatedToken = false // Call the handler - h.updater.updatedVaultToken(token) + c.updater.updatedVaultToken(token) } // Start watching for renewal errors @@ -278,7 +282,7 @@ OUTER: stopRenewal() // Check if we have to do anything - if h.vaultStanza.ChangeMode != structs.VaultChangeModeNoop { + if c.vaultStanza.ChangeMode != structs.VaultChangeModeNoop { updatedToken = true } case <-h.ctx.Done(): @@ -291,17 +295,19 @@ OUTER: // deriveVaultToken derives the Vault token using exponential backoffs. It // returns the Vault token and whether the manager should exit. func (h *vaultHook) deriveVaultToken() (token string, exit bool) { + c := h.config + attempts := 0 for { - tokens, err := h.client.DeriveToken(h.alloc, []string{h.taskName}) + tokens, err := c.client.DeriveToken(c.alloc, []string{c.task}) if err == nil { - return tokens[h.taskName], false + return tokens[c.task], false } // Check if this is a server side error if structs.IsServerSide(err) { h.logger.Error("failed to derive Vault token", "error", err, "server_side", true) - h.lifecycle.Kill(h.ctx, + c.lifecycle.Kill(h.ctx, structs.NewTaskEvent(structs.TaskKilling). SetFailsTask(). SetDisplayMessage(fmt.Sprintf("Vault: server failed to derive vault token: %v", err))) @@ -311,7 +317,7 @@ func (h *vaultHook) deriveVaultToken() (token string, exit bool) { // Check if we can't recover from the error if !structs.IsRecoverable(err) { h.logger.Error("failed to derive Vault token", "error", err, "recoverable", false) - h.lifecycle.Kill(h.ctx, + c.lifecycle.Kill(h.ctx, structs.NewTaskEvent(structs.TaskKilling). SetFailsTask(). SetDisplayMessage(fmt.Sprintf("Vault: failed to derive vault token: %v", err))) diff --git a/client/allocrunner/taskrunner/vault_hook_test.go b/client/allocrunner/taskrunner/vault_hook_test.go index 20871b581bc..fe9905763cf 100644 --- a/client/allocrunner/taskrunner/vault_hook_test.go +++ b/client/allocrunner/taskrunner/vault_hook_test.go @@ -1,8 +1,127 @@ package taskrunner -import "github.com/hashicorp/nomad/client/allocrunner/interfaces" +import ( + "context" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/nomad/client/allocdir" + "github.com/hashicorp/nomad/client/allocrunner/interfaces" + "github.com/hashicorp/nomad/client/vaultclient" + "github.com/hashicorp/nomad/helper/testlog" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + vapi "github.com/hashicorp/vault/api" + "github.com/stretchr/testify/require" + "testing" +) // Statically assert the stats hook implements the expected interfaces var _ interfaces.TaskPrestartHook = (*vaultHook)(nil) var _ interfaces.TaskStopHook = (*vaultHook)(nil) var _ interfaces.ShutdownHook = (*vaultHook)(nil) + +func newTestVault(t *testing.T, logger hclog.Logger) (*testutil.TestVault, vaultclient.VaultClient, error) { + testVault := testutil.NewTestVault(t) + + vc, err := vaultclient.NewVaultClient(testVault.Config, logger, func(alloc *structs.Allocation, tasks []string, v *vapi.Client) (map[string]string, error) { + tokens := make(map[string]string) + for _, taskName := range tasks { + task := alloc.LookupTask(taskName) + tcr := vapi.TokenCreateRequest{ + Policies: task.Vault.Policies, + Renewable: new(bool), + } + *tcr.Renewable = true + + s, err := testVault.Client.Auth().Token().Create(&tcr) + if err != nil { + return nil, err + } + tokens[taskName] = s.Auth.ClientToken + } + + return tokens, nil + }) + + return testVault, vc, err +} + +func TestVaultHook_Secret(t *testing.T) { + t.Parallel() + r := require.New(t) + logger := testlog.HCLogger(t) + + testVault, vaultClient, err := newTestVault(t, logger) + r.NoError(err) + defer testVault.Stop() + + policy := "test" + vaultPath := "secret/data/password" + key := "password" + content := "bar" + + // Write policy & secret to Vault + sys := testVault.Client.Sys() + r.NoError(sys.PutPolicy(policy, ` + path "secret/data/*" { + capabilities = ["read"] + } + `)) + logical := testVault.Client.Logical() + _, err = logical.Write(vaultPath, map[string]interface{}{"data": map[string]interface{}{key: content}}) + r.NoError(err) + + // Create alloc, task and taskrunner + allocDir, cleanup := allocdir.TestAllocDir(t, logger, "ConnectNative") + defer cleanup() + + alloc := mock.Alloc() + task := alloc.Job.LookupTaskGroup(alloc.TaskGroup).Tasks[0] + + trConfig, cleanup := testTaskRunnerConfig(t, alloc, task.Name) + defer cleanup() + tr, err := NewTaskRunner(trConfig) + r.NoError(err) + + // Build the vault stanza + secretName := "topsecret" + vaultStanza := structs.Vault{ + Policies: []string{policy}, + Secrets: []*structs.VaultSecret{ + {Name: secretName, Path: vaultPath}, + }, + } + task.Vault = &vaultStanza + + h := newVaultHook(&vaultHookConfig{ + vaultStanza: &vaultStanza, + client: vaultClient, + lifecycle: tr, + updater: tr, + envBuilder: tr.envBuilder, + logger: logger, + alloc: alloc, + task: task.Name, + }) + + request := &interfaces.TaskPrestartRequest{ + Task: task, + TaskDir: allocDir.NewTaskDir(task.Name), + } + r.NoError(request.TaskDir.Build(false, nil)) + + response := new(interfaces.TaskPrestartResponse) + + // Run vault client & prestart hook + vaultClient.Start() + defer vaultClient.Stop() + r.NoError(h.Prestart(context.Background(), request, response)) + + // Assert the secret was put into envbuilder + env := tr.envBuilder.Build().All() + r.Equal(env["secret."+secretName+"."+key], content) + values, errs, err := tr.envBuilder.Build().AllValues() + r.Empty(errs) + r.NoError(err) + r.Equal(values["secret"].AsValueMap()[secretName].AsValueMap()[key].AsString(), content) +} diff --git a/client/taskenv/env.go b/client/taskenv/env.go index 06341cada30..b1a36b6e28f 100644 --- a/client/taskenv/env.go +++ b/client/taskenv/env.go @@ -2,6 +2,7 @@ package taskenv import ( "fmt" + "github.com/hashicorp/nomad/helper/flatmap" "net" "os" "path/filepath" @@ -123,6 +124,7 @@ const ( // Prefixes used for lookups. nodeAttributePrefix = "attr." nodeMetaPrefix = "meta." + secretPrefix = "secret." ) // TaskEnv is a task's environment as well as node attribute's for @@ -134,6 +136,9 @@ type TaskEnv struct { // EnvMap is the map of environment variables EnvMap map[string]string + // Secrets is the map of secrets + secretsMap map[string]string + // deviceEnv is the environment variables populated from the device hooks. deviceEnv map[string]string @@ -156,9 +161,10 @@ type TaskEnv struct { // NewTaskEnv creates a new task environment with the given environment, device // environment and node attribute maps. -func NewTaskEnv(env, envClient, deviceEnv, node map[string]string, clientTaskDir, clientAllocDir string) *TaskEnv { +func NewTaskEnv(env, envClient, deviceEnv, node, secrets map[string]string, clientTaskDir, clientAllocDir string) *TaskEnv { return &TaskEnv{ NodeAttrs: node, + secretsMap: secrets, deviceEnv: deviceEnv, EnvMap: env, EnvMapClient: envClient, @@ -220,6 +226,9 @@ func (t *TaskEnv) All() map[string]string { for k, v := range t.NodeAttrs { m[k] = v } + for k, v := range t.secretsMap { + m[k] = v + } return m } @@ -256,6 +265,13 @@ func (t *TaskEnv) AllValues() (map[string]cty.Value, map[string]error, error) { } } + // Prepare secrets (secret.*) + for k, v := range t.secretsMap { + if err := addNestedKey(allMap, k, v); err != nil { + errs[k] = err + } + } + // Add flat envMap as a Map to allMap so users can access any key via // HCL2's indexing syntax: ${env["foo...bar"]} allMap["env"] = cty.MapVal(envMap) @@ -307,7 +323,7 @@ func (t *TaskEnv) ParseAndReplace(args []string) []string { // and Nomad variables. If the variable is found in the passed map it is // replaced, otherwise the original string is returned. func (t *TaskEnv) ReplaceEnv(arg string) string { - return hargs.ReplaceEnv(arg, t.EnvMap, t.NodeAttrs) + return hargs.ReplaceEnv(arg, t.EnvMap, t.NodeAttrs, t.secretsMap) } // replaceEnvClient takes an arg and replaces all occurrences of client-specific @@ -370,6 +386,9 @@ type Builder struct { // nodeAttrs are Node attributes and metadata nodeAttrs map[string]string + // secretAttrs are Vault secrets + secretAttrs map[string]string + // taskMeta are the meta attributes on the task taskMeta map[string]string @@ -457,6 +476,7 @@ func NewEmptyBuilder() *Builder { mu: &sync.RWMutex{}, hookEnvs: map[string]map[string]string{}, envvars: make(map[string]string), + secretAttrs: make(map[string]string), } } @@ -563,7 +583,7 @@ func (b *Builder) buildEnv(allocDir, localDir, secretsDir string, // Copy interpolated task env vars second as they override host env vars for k, v := range b.envvars { - envMap[k] = hargs.ReplaceEnv(v, nodeAttrs, envMap) + envMap[k] = hargs.ReplaceEnv(v, nodeAttrs, envMap, b.secretAttrs) } // Copy hook env vars in the order the hooks were run @@ -622,7 +642,7 @@ func (b *Builder) Build() *TaskEnv { envMap, deviceEnvs := b.buildEnv(b.allocDir, b.localDir, b.secretsDir, nodeAttrs) envMapClient, _ := b.buildEnv(b.clientSharedAllocDir, b.clientTaskLocalDir, b.clientTaskSecretsDir, nodeAttrs) - return NewTaskEnv(envMap, envMapClient, deviceEnvs, nodeAttrs, b.clientTaskRoot, b.clientSharedAllocDir) + return NewTaskEnv(envMap, envMapClient, deviceEnvs, nodeAttrs, b.secretAttrs, b.clientTaskRoot, b.clientSharedAllocDir) } // UpdateTask updates the environment based on a new alloc and task. @@ -996,6 +1016,16 @@ func (b *Builder) SetVaultToken(token, namespace string, inject bool) *Builder { return b } +func (b *Builder) SetVaultSecret(name string, data interface{}) *Builder { + b.mu.Lock() + data = map[string]interface{}{name: data} + for k, v := range flatmap.FlattenDotPrefix(data, nil, false) { + b.secretAttrs[secretPrefix + k] = v + } + b.mu.Unlock() + return b +} + // addPort keys and values for other tasks to an env var map func addPort(m map[string]string, taskName, ip, portLabel string, port int) { key := fmt.Sprintf("%s%s_%s", AddrPrefix, taskName, portLabel) diff --git a/client/taskenv/services_test.go b/client/taskenv/services_test.go index dc6a5593aa1..22ae3cefb1d 100644 --- a/client/taskenv/services_test.go +++ b/client/taskenv/services_test.go @@ -104,7 +104,7 @@ func TestInterpolateServices(t *testing.T) { var testEnv = NewTaskEnv( map[string]string{"foo": "bar", "baz": "blah"}, map[string]string{"foo": "bar", "baz": "blah"}, - nil, nil, "", "") + nil, nil, nil, "", "") func TestInterpolate_interpolateMapStringSliceString(t *testing.T) { t.Parallel() @@ -201,7 +201,7 @@ func TestInterpolate_interpolateConnect(t *testing.T) { "service1": "_service1", "host1": "_host1", } - env := NewTaskEnv(e, e, nil, nil, "", "") + env := NewTaskEnv(e, e, nil, nil, nil, "", "") connect := &structs.ConsulConnect{ Native: false, diff --git a/client/vaultclient/vaultclient.go b/client/vaultclient/vaultclient.go index bd742e6e8b0..9b3915e9e0d 100644 --- a/client/vaultclient/vaultclient.go +++ b/client/vaultclient/vaultclient.go @@ -45,6 +45,9 @@ type VaultClient interface { // StopRenewToken removes the token from the min-heap, stopping its // renewal. StopRenewToken(string) error + + // GetSecret fetches a secret + GetSecret(string, string) (*vaultapi.Secret, error) } // Implementation of VaultClient interface to interact with vault and perform @@ -257,24 +260,10 @@ func (c *vaultClient) DeriveToken(alloc *structs.Allocation, taskNames []string) // GetConsulACL creates a vault API client and reads from vault a consul ACL // token used by the task. func (c *vaultClient) GetConsulACL(token, path string) (*vaultapi.Secret, error) { - if !c.config.IsEnabled() { - return nil, fmt.Errorf("vault client not enabled") - } - if token == "" { - return nil, fmt.Errorf("missing token") - } if path == "" { return nil, fmt.Errorf("missing consul ACL token vault path") } - - c.lock.Lock() - defer c.unlockAndUnset() - - // Use the token supplied to interact with vault - c.client.SetToken(token) - - // Read the consul ACL token and return the secret directly - return c.client.Logical().Read(path) + return c.GetSecret(token, path) } // RenewToken renews the supplied token for a given duration (in seconds) and @@ -459,6 +448,27 @@ func (c *vaultClient) renew(req *vaultClientRenewalRequest) error { return nil } +func (c *vaultClient) GetSecret(token, path string) (*vaultapi.Secret, error) { + if !c.config.IsEnabled() { + return nil, fmt.Errorf("vault client not enabled") + } + if token == "" { + return nil, fmt.Errorf("missing token") + } + if path == "" { + return nil, fmt.Errorf("missing vault secret path") + } + + c.lock.Lock() + defer c.unlockAndUnset() + + // Use the token supplied to interact with vault + c.client.SetToken(token) + + // Read the consul ACL token and return the secret directly + return c.client.Logical().Read(path) +} + // run is the renewal loop which performs the periodic renewals of both the // tokens and the secret leases. func (c *vaultClient) run() { diff --git a/client/vaultclient/vaultclient_testing.go b/client/vaultclient/vaultclient_testing.go index c3c25c90d45..2804c108107 100644 --- a/client/vaultclient/vaultclient_testing.go +++ b/client/vaultclient/vaultclient_testing.go @@ -117,6 +117,8 @@ func (vc *MockVaultClient) Stop() {} func (vc *MockVaultClient) GetConsulACL(string, string) (*vaultapi.Secret, error) { return nil, nil } +func (vc *MockVaultClient) GetSecret(string, string) (*vaultapi.Secret, error) { return nil, nil } + // StoppedTokens tracks the tokens that have stopped renewing func (vc *MockVaultClient) StoppedTokens() []string { vc.mu.Lock() diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index 512331e4ab0..056bb44d275 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -1102,6 +1102,15 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup, ChangeMode: *apiTask.Vault.ChangeMode, ChangeSignal: *apiTask.Vault.ChangeSignal, } + for _, secret := range apiTask.Vault.Secrets { + structsTask.Vault.Secrets = append(structsTask.Vault.Secrets, + &structs.VaultSecret{ + Name: secret.Name, + Path: secret.Path, + }, + ) + } + } if len(apiTask.Templates) > 0 { diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index 470c488f07c..c74d3f12202 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -2278,6 +2278,12 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { Env: helper.BoolToPtr(true), ChangeMode: helper.StringToPtr("c"), ChangeSignal: helper.StringToPtr("sighup"), + Secrets: []*api.VaultSecret{ + { + Name: "name", + Path: "path", + }, + }, }, Templates: []*api.Template{ { @@ -2671,6 +2677,12 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { Env: true, ChangeMode: "c", ChangeSignal: "sighup", + Secrets: []*structs.VaultSecret{ + { + Name: "name", + Path: "path", + }, + }, }, Templates: []*structs.Template{ { diff --git a/helper/flatmap/flatmap.go b/helper/flatmap/flatmap.go index 8cac4dde17c..afabd6b5765 100644 --- a/helper/flatmap/flatmap.go +++ b/helper/flatmap/flatmap.go @@ -3,6 +3,7 @@ package flatmap import ( "fmt" "reflect" + "strconv" ) // Flatten takes an object and returns a flat map of the object. The keys of the @@ -15,18 +16,51 @@ func Flatten(obj interface{}, filter []string, primitiveOnly bool) map[string]st return nil } - flatten("", v, primitiveOnly, false, flat) + flatten("", v, primitiveOnly, false, flat, PrefixMakers{}) for _, f := range filter { delete(flat, f) } return flat } +// FlattenDotPrefix does the same as Flatten, but generates struct-style +// dot access prefixes even for maps +func FlattenDotPrefix(obj interface{}, filter []string, primitiveOnly bool) map[string]string { + flat := make(map[string]string) + v := reflect.ValueOf(obj) + if !v.IsValid() { + return nil + } + + flatten("", v, primitiveOnly, false, flat, PrefixMakers{ + forMap: getSubPrefixStruct, + }) + for _, f := range filter { + delete(flat, f) + } + return flat +} + +type PrefixMakers struct { + forStruct, forMap func(string, string) string + forArray func(string, int) string +} + // flatten recursively calls itself to create a flatmap representation of the // passed value. The results are stored into the output map and the keys are // the fields prepended with the passed prefix. // XXX: A current restriction is that maps only support string keys. -func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, output map[string]string) { +func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, output map[string]string, prefixMakers PrefixMakers) { + if prefixMakers.forMap == nil { + prefixMakers.forMap = getSubPrefixMap + } + if prefixMakers.forStruct == nil { + prefixMakers.forStruct = getSubPrefixStruct + } + if prefixMakers.forArray == nil { + prefixMakers.forArray = getSubPrefixArray + } + switch v.Kind() { case reflect.Bool: output[prefix] = fmt.Sprintf("%v", v.Bool()) @@ -51,7 +85,7 @@ func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, if !e.IsValid() { output[prefix] = "nil" } - flatten(prefix, e, primitiveOnly, enteredStruct, output) + flatten(prefix, e, primitiveOnly, enteredStruct, output, prefixMakers) case reflect.Map: for _, k := range v.MapKeys() { if k.Kind() == reflect.Interface { @@ -62,7 +96,7 @@ func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, panic(fmt.Sprintf("%q: map key is not string: %s", prefix, k)) } - flatten(getSubKeyPrefix(prefix, k.String()), v.MapIndex(k), primitiveOnly, enteredStruct, output) + flatten(prefixMakers.forMap(prefix, k.String()), v.MapIndex(k), primitiveOnly, enteredStruct, output, prefixMakers) } case reflect.Struct: if primitiveOnly && enteredStruct { @@ -78,7 +112,7 @@ func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, val = val.Elem() } - flatten(getSubPrefix(prefix, name), val, primitiveOnly, enteredStruct, output) + flatten(prefixMakers.forStruct(prefix, name), val, primitiveOnly, enteredStruct, output, prefixMakers) } case reflect.Interface: if primitiveOnly { @@ -90,7 +124,7 @@ func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, output[prefix] = "nil" return } - flatten(prefix, e, primitiveOnly, enteredStruct, output) + flatten(prefix, e, primitiveOnly, enteredStruct, output, prefixMakers) case reflect.Array, reflect.Slice: if primitiveOnly { return @@ -101,27 +135,33 @@ func flatten(prefix string, v reflect.Value, primitiveOnly, enteredStruct bool, return } for i := 0; i < v.Len(); i++ { - flatten(fmt.Sprintf("%s[%d]", prefix, i), v.Index(i), primitiveOnly, enteredStruct, output) + flatten(prefixMakers.forArray(prefix, i), v.Index(i), primitiveOnly, enteredStruct, output, prefixMakers) } default: panic(fmt.Sprintf("prefix %q; unsupported type %v", prefix, v.Kind())) } } -// getSubPrefix takes the current prefix and the next subfield and returns an -// appropriate prefix. -func getSubPrefix(curPrefix, subField string) string { +// getSubPrefixStruct takes the current prefix and the next subfield and returns an +// appropriate prefix for a struct member. +func getSubPrefixStruct(curPrefix, subField string) string { if curPrefix != "" { return fmt.Sprintf("%s.%s", curPrefix, subField) } return subField } -// getSubKeyPrefix takes the current prefix and the next subfield and returns an +// getSubPrefixMap takes the current prefix and the next subfield and returns an // appropriate prefix for a map field. -func getSubKeyPrefix(curPrefix, subField string) string { +func getSubPrefixMap(curPrefix, subField string) string { if curPrefix != "" { return fmt.Sprintf("%s[%s]", curPrefix, subField) } return subField } + +// getSubPrefixArray takes the current prefix and the next subfield and +// returns an appropriate prefix for an array element. +func getSubPrefixArray(curPrefix string, index int) string { + return getSubPrefixMap(curPrefix, strconv.Itoa(index)) +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index a773ba1071c..b8b0b602590 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -8669,6 +8669,9 @@ type Vault struct { // ChangeSignal is the signal sent to the task when a new token is // retrieved. This is only valid when using the signal change mode. ChangeSignal string + + // Secrets is the set of secrets that should be made available + Secrets []*VaultSecret } func DefaultVaultBlock() *Vault { @@ -8686,6 +8689,14 @@ func (v *Vault) Copy() *Vault { nv := new(Vault) *nv = *v + + if l := len(v.Secrets); l != 0 { + nv.Secrets = make([]*VaultSecret, l) + for i, s := range v.Secrets { + nv.Secrets[i] = s.Copy() + } + } + return nv } @@ -8725,6 +8736,34 @@ func (v *Vault) Validate() error { return mErr.ErrorOrNil() } +type VaultSecret struct { + // Name is the name the secret should be made available under + Name string + // Path is the Vault path to read the secret from + Path string +} + +func (s *VaultSecret) Copy() *VaultSecret { + if s == nil { + return nil + } + + ns := new(VaultSecret) + *ns = *s + return ns +} + +func (s *VaultSecret) Validate() error { + var mErr multierror.Error + if s.Name == "" { + _ = multierror.Append(&mErr, fmt.Errorf("Missing secret name")) + } + if s.Path == "" { + _ = multierror.Append(&mErr, fmt.Errorf("Missing secret vault path")) + } + return mErr.ErrorOrNil() +} + const ( // DeploymentStatuses are the various states a deployment can be be in DeploymentStatusRunning = "running" diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index b595b419b0a..aa03dee82a5 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -5022,6 +5022,20 @@ func TestVault_Validate(t *testing.T) { } } +func TestVaultSecret_Validate(t *testing.T) { + s := &VaultSecret{} + err := s.Validate() + if err == nil { + t.Fatalf("Expeceted validation errors") + } + if ! strings.Contains(err.Error(), "name") { + t.Fatalf("Expected missing name error") + } + if ! strings.Contains(err.Error(), "path") { + t.Fatalf("Expected missing path error") + } +} + func TestParameterizedJobConfig_Validate(t *testing.T) { d := &ParameterizedJobConfig{ Payload: "foo",