Files
nomad/client/allocrunner/consul_hook_test.go
Tim Gross 6d58acd897 WI: ensure tasks within same alloc get different Consul tokens (#20411)
The `consul_hook` in the allocrunner gets a separate Consul token for each task,
even if the tasks' identities have the same name, but used the identity name as
the key to the alloc hook resources map. This means the last task in the group
overwrites the Consul tokens of all other tasks.

Fix this by adding the task name to the key in the allocrunner's
`consul_hook`. And update the taskrunner's `consul_hook` to expect the task
name in the key.

Fixes: https://github.com/hashicorp/nomad/issues/20374
Fixes: https://hashicorp.atlassian.net/browse/NOMAD-614
2024-04-17 11:29:58 -04:00

287 lines
8.0 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package allocrunner
import (
"crypto/md5"
"encoding/hex"
"fmt"
"testing"
consulapi "github.com/hashicorp/consul/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/client/allocrunner/interfaces"
"github.com/hashicorp/nomad/client/consul"
cstate "github.com/hashicorp/nomad/client/state"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/client/widmgr"
"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"
)
// statically assert consul hook implements the expected interfaces
var _ interfaces.RunnerPrerunHook = (*consulHook)(nil)
func consulHookTestHarness(t *testing.T) *consulHook {
logger := testlog.HCLogger(t)
alloc := mock.Alloc()
task1 := alloc.LookupTask("web")
task1.Consul = &structs.Consul{
Cluster: "default",
}
task1.Identities = []*structs.WorkloadIdentity{
{Name: fmt.Sprintf("%s_default", structs.ConsulTaskIdentityNamePrefix)},
}
task2 := task1.Copy()
task2.Name = "extra"
task2.Services = nil
alloc.Job.TaskGroups[0].Tasks = append(alloc.Job.TaskGroups[0].Tasks, task2)
task1.Services = []*structs.Service{
{
Provider: structs.ServiceProviderConsul,
Identity: &structs.WorkloadIdentity{Name: "consul-service_webservice", Audience: []string{"consul.io"}},
Cluster: "default",
Name: "${NOMAD_TASK_NAME}service",
TaskName: "web", // note: this doesn't interpolate
},
}
// setup mock signer but don't sign identities, as we're going to want them
// interpolated by the WIDMgr
mockSigner := widmgr.NewMockWIDSigner(nil)
db := cstate.NewMemDB(logger)
// the WIDMgr env builder never has the task available
envBuilder := taskenv.NewBuilder(mock.Node(), alloc, nil, "global")
mockWIDMgr := widmgr.NewWIDMgr(mockSigner, alloc, db, logger, envBuilder)
mockWIDMgr.SignForTesting()
consulConfigs := map[string]*structsc.ConsulConfig{
"default": structsc.DefaultConsulConfig(),
}
hookResources := cstructs.NewAllocHookResources()
envBuilderFn := func() *taskenv.Builder {
return taskenv.NewBuilder(mock.Node(), alloc, nil, "global")
}
consulHookCfg := consulHookConfig{
alloc: alloc,
allocdir: nil,
widmgr: mockWIDMgr,
consulConfigs: consulConfigs,
consulClientConstructor: consul.NewMockConsulClient,
hookResources: hookResources,
envBuilder: envBuilderFn,
logger: logger,
}
return newConsulHook(consulHookCfg)
}
func Test_consulHook_prepareConsulTokensForTask(t *testing.T) {
ci.Parallel(t)
hook := consulHookTestHarness(t)
task := hook.alloc.LookupTask("web")
wid := task.GetIdentity("consul_default")
ti := *task.IdentityHandle(wid)
jwt, err := hook.widmgr.Get(ti)
must.NoError(t, err)
hashJWT1 := md5.Sum([]byte(jwt.JWT))
task2 := hook.alloc.LookupTask("extra")
ti2 := *task2.IdentityHandle(wid)
jwt2, err := hook.widmgr.Get(ti2)
must.NoError(t, err)
hashJWT2 := md5.Sum([]byte(jwt2.JWT))
tests := []struct {
name string
tasks []*structs.Task
wantErr bool
errMsg string
expectedTokens map[string]map[string]*consulapi.ACLToken
}{
{
name: "empty task",
tasks: nil,
wantErr: true,
errMsg: "no task specified",
expectedTokens: map[string]map[string]*consulapi.ACLToken{},
},
{
name: "task with signed identity",
tasks: []*structs.Task{task},
wantErr: false,
errMsg: "",
expectedTokens: map[string]map[string]*consulapi.ACLToken{
"default": {
"consul_default/web": &consulapi.ACLToken{
AccessorID: hex.EncodeToString(hashJWT1[:]),
SecretID: hex.EncodeToString(hashJWT1[:]),
},
},
},
},
{
name: "multiple tasks with signed identities",
tasks: []*structs.Task{task, task2},
wantErr: false,
errMsg: "",
expectedTokens: map[string]map[string]*consulapi.ACLToken{
"default": {
"consul_default/web": &consulapi.ACLToken{
AccessorID: hex.EncodeToString(hashJWT1[:]),
SecretID: hex.EncodeToString(hashJWT1[:]),
},
"consul_default/extra": &consulapi.ACLToken{
AccessorID: hex.EncodeToString(hashJWT2[:]),
SecretID: hex.EncodeToString(hashJWT2[:]),
},
},
},
},
{
name: "task with unknown identity",
tasks: []*structs.Task{{
Identities: []*structs.WorkloadIdentity{
{Name: structs.ConsulTaskIdentityNamePrefix + "_default"}},
Name: "foo",
}},
wantErr: true,
errMsg: "unable to find token for workload",
expectedTokens: map[string]map[string]*consulapi.ACLToken{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := map[string]map[string]*consulapi.ACLToken{}
for _, task := range tt.tasks {
err := hook.prepareConsulTokensForTask(task, nil, tokens)
if tt.wantErr {
must.Error(t, err)
must.ErrorContains(t, err, tt.errMsg)
} else {
must.NoError(t, err)
}
}
must.Eq(t, tt.expectedTokens, tokens)
})
}
}
func Test_consulHook_prepareConsulTokensForServices(t *testing.T) {
ci.Parallel(t)
hook := consulHookTestHarness(t)
task := hook.alloc.LookupTask("web")
services := task.Services
hook.envBuilder.UpdateTask(hook.alloc, task)
env := hook.envBuilder.Build()
hashedJWT := make(map[string]string)
for _, s := range services {
widHandle := *s.IdentityHandle(env.ReplaceEnv)
jwt, err := hook.widmgr.Get(widHandle)
must.NoError(t, err)
hash := md5.Sum([]byte(jwt.JWT))
hashedJWT[widHandle.InterpolatedWorkloadIdentifier] = hex.EncodeToString(hash[:])
}
tests := []struct {
name string
services []*structs.Service
wantErr bool
errMsg string
expectedTokens map[string]map[string]*consulapi.ACLToken
}{
{
name: "empty services",
services: nil,
wantErr: false,
errMsg: "",
expectedTokens: map[string]map[string]*consulapi.ACLToken{},
},
{
name: "services with signed identity",
services: services,
wantErr: false,
errMsg: "",
expectedTokens: map[string]map[string]*consulapi.ACLToken{
"default": {
"consul-service_webservice": {
AccessorID: hashedJWT["webservice"],
SecretID: hashedJWT["webservice"],
},
},
},
},
{
name: "services with unknown identity",
services: []*structs.Service{
{
Provider: structs.ServiceProviderConsul,
Identity: &structs.WorkloadIdentity{
Name: "consul-service_webservice", Audience: []string{"consul.io"}},
Cluster: "default",
Name: "foo",
TaskName: "web",
},
},
wantErr: true,
errMsg: "unable to find token for workload",
expectedTokens: map[string]map[string]*consulapi.ACLToken{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := map[string]map[string]*consulapi.ACLToken{}
err := hook.prepareConsulTokensForServices(tt.services, nil, tokens, env)
if tt.wantErr {
must.Error(t, err)
must.ErrorContains(t, err, tt.errMsg)
} else {
must.NoError(t, err)
must.Eq(t, tt.expectedTokens, tokens)
}
})
}
}
func Test_consulHook_Postrun(t *testing.T) {
ci.Parallel(t)
// no-op must be safe
hook := consulHookTestHarness(t)
must.NoError(t, hook.Postrun())
task := hook.alloc.LookupTask("web")
tokens := map[string]map[string]*consulapi.ACLToken{}
must.NoError(t, hook.prepareConsulTokensForTask(task, nil, tokens))
hook.hookResources.SetConsulTokens(tokens)
must.MapLen(t, 1, tokens)
// gracefully handle wrong tokens
otherTokens := map[string]map[string]*consulapi.ACLToken{
"default": {"foo": &consulapi.ACLToken{AccessorID: "foo", SecretID: "foo"}}}
must.NoError(t, hook.revokeTokens(otherTokens))
// hook resources should be cleared
must.NoError(t, hook.Postrun())
tokens = hook.hookResources.GetConsulTokens()
must.MapEmpty(t, tokens["default"])
}