job_hooks: add implicit constraint when using Consul for services. (#12602)

This commit is contained in:
James Rasell 2022-04-20 14:09:13 +02:00 committed by GitHub
parent 42068f8823
commit 010acce59f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 843 additions and 85 deletions

3
.changelog/12602.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
consul: Added implicit Consul constraint for task groups utilising Consul service and check registrations
```

View File

@ -72,6 +72,8 @@ func TestPrevAlloc_StreamAllocDir_TLS(t *testing.T) {
Operand: "=",
},
}
job.TaskGroups[0].Constraints = nil
job.TaskGroups[0].Tasks[0].Services = nil
job.TaskGroups[0].Count = 1
job.TaskGroups[0].EphemeralDisk.Sticky = true
job.TaskGroups[0].EphemeralDisk.Migrate = true

View File

@ -441,6 +441,7 @@ func TestClient_UpdateAllocStatus(t *testing.T) {
job := mock.Job()
// allow running job on any node including self client, that may not be a Linux box
job.Constraints = nil
job.TaskGroups[0].Constraints = nil
job.TaskGroups[0].Count = 1
task := job.TaskGroups[0].Tasks[0]
task.Driver = "mock_driver"

View File

@ -53,7 +53,7 @@ func TestIntegration_Command_RoundTripJob(t *testing.T) {
defer srv.Shutdown()
{
cmd := exec.Command("nomad", "job", "init")
cmd := exec.Command("nomad", "job", "init", "-short")
cmd.Dir = tmpDir
assert.Nil(cmd.Run())
}

View File

@ -24,6 +24,16 @@ var (
Operand: structs.ConstraintSemver,
}
// consulServiceDiscoveryConstraint is the implicit constraint added to
// task groups which include services utilising the Consul provider. The
// Consul version is pinned to a minimum of that which introduced the
// namespace feature.
consulServiceDiscoveryConstraint = &structs.Constraint{
LTarget: "${attr.consul.version}",
RTarget: ">= 1.7.0",
Operand: structs.ConstraintSemver,
}
// nativeServiceDiscoveryConstraint is the constraint injected into task
// groups that utilise Nomad's native service discovery feature. This is
// needed, as operators can disable the client functionality, and therefore
@ -134,79 +144,96 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro
// Identify which task groups are utilising Nomad native service discovery.
nativeServiceDisco := j.RequiredNativeServiceDiscovery()
// Identify which task groups are utilising Consul service discovery.
consulServiceDisco := j.RequiredConsulServiceDiscovery()
// Hot path
if len(signals) == 0 && len(vaultBlocks) == 0 && len(nativeServiceDisco) == 0 {
if len(signals) == 0 && len(vaultBlocks) == 0 &&
len(nativeServiceDisco) == 0 && len(consulServiceDisco) == 0 {
return j, nil, nil
}
// Add Vault constraints if no Vault constraint exists
// Iterate through all the task groups within the job and add any required
// constraints. When adding new implicit constraints, they should go inside
// this single loop, with a new constraintMatcher if needed.
for _, tg := range j.TaskGroups {
_, ok := vaultBlocks[tg.Name]
if !ok {
// Not requesting Vault
continue
// If the task group utilises Vault, run the mutator.
if _, ok := vaultBlocks[tg.Name]; ok {
mutateConstraint(constraintMatcherLeft, tg, vaultConstraint)
}
found := false
for _, c := range tg.Constraints {
if c.LTarget == vaultConstraintLTarget {
found = true
break
}
// Check whether the task group is using signals. In the case that it
// is, we flatten the signals and build a constraint, then run the
// mutator.
if tgSignals, ok := signals[tg.Name]; ok {
required := helper.MapStringStringSliceValueSet(tgSignals)
sigConstraint := getSignalConstraint(required)
mutateConstraint(constraintMatcherFull, tg, sigConstraint)
}
if !found {
tg.Constraints = append(tg.Constraints, vaultConstraint)
}
}
// Add signal constraints
for _, tg := range j.TaskGroups {
tgSignals, ok := signals[tg.Name]
if !ok {
// Not requesting signal
continue
// If the task group utilises Nomad service discovery, run the mutator.
if ok := nativeServiceDisco[tg.Name]; ok {
mutateConstraint(constraintMatcherFull, tg, nativeServiceDiscoveryConstraint)
}
// Flatten the signals
required := helper.MapStringStringSliceValueSet(tgSignals)
sigConstraint := getSignalConstraint(required)
found := false
for _, c := range tg.Constraints {
if c.Equals(sigConstraint) {
found = true
break
}
}
if !found {
tg.Constraints = append(tg.Constraints, sigConstraint)
}
}
// Add the Nomad service discovery constraints.
for _, tg := range j.TaskGroups {
if ok := nativeServiceDisco[tg.Name]; !ok {
continue
}
found := false
for _, c := range tg.Constraints {
if c.Equals(nativeServiceDiscoveryConstraint) {
found = true
break
}
}
if !found {
tg.Constraints = append(tg.Constraints, nativeServiceDiscoveryConstraint)
// If the task group utilises Consul service discovery, run the mutator.
if ok := consulServiceDisco[tg.Name]; ok {
mutateConstraint(constraintMatcherLeft, tg, consulServiceDiscoveryConstraint)
}
}
return j, nil, nil
}
// constraintMatcher is a custom type which helps control how constraints are
// identified as being present within a task group.
type constraintMatcher uint
const (
// constraintMatcherFull ensures that a constraint is only considered found
// when they match totally. This check is performed using the
// structs.Constraint Equals function.
constraintMatcherFull constraintMatcher = iota
// constraintMatcherLeft ensure that a constraint is considered found if
// the constraints LTarget is matched only. This allows an existing
// constraint to override the proposed implicit one.
constraintMatcherLeft
)
// mutateConstraint is a generic mutator used to set implicit constraints
// within the task group if they are needed.
func mutateConstraint(matcher constraintMatcher, taskGroup *structs.TaskGroup, constraint *structs.Constraint) {
var found bool
// It's possible to switch on the matcher within the constraint loop to
// reduce repetition. This, however, means switching per constraint,
// therefore we do it here.
switch matcher {
case constraintMatcherFull:
for _, c := range taskGroup.Constraints {
if c.Equals(constraint) {
found = true
break
}
}
case constraintMatcherLeft:
for _, c := range taskGroup.Constraints {
if c.LTarget == constraint.LTarget {
found = true
break
}
}
}
// If we didn't find a suitable constraint match, add one.
if !found {
taskGroup.Constraints = append(taskGroup.Constraints, constraint)
}
}
// jobValidate validates a Job and task drivers and returns an error if there is
// a validation problem or if the Job is of a type a user is not allowed to
// submit.

View File

@ -39,6 +39,406 @@ func Test_jobImpliedConstraints_Mutate(t *testing.T) {
expectedOutputError: nil,
name: "no needed constraints",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{},
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Vault: &structs.Vault{},
Name: "group1-task1",
},
},
Constraints: []*structs.Constraint{vaultConstraint},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task with vault",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Vault: &structs.Vault{},
Name: "group1-task1",
},
{
Vault: &structs.Vault{},
Name: "group1-task2",
},
{
Vault: &structs.Vault{},
Name: "group1-task3",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Vault: &structs.Vault{},
Name: "group1-task1",
},
{
Vault: &structs.Vault{},
Name: "group1-task2",
},
{
Vault: &structs.Vault{},
Name: "group1-task3",
},
},
Constraints: []*structs.Constraint{vaultConstraint},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "group with multiple tasks with vault",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
},
},
},
{
Name: "group2",
Tasks: []*structs.Task{
{
Name: "group2-task1",
Vault: &structs.Vault{},
},
},
},
{
Name: "group3",
Tasks: []*structs.Task{
{
Name: "group3-task1",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
},
},
},
{
Name: "group2",
Tasks: []*structs.Task{
{
Name: "group2-task1",
Vault: &structs.Vault{},
},
},
Constraints: []*structs.Constraint{vaultConstraint},
},
{
Name: "group3",
Tasks: []*structs.Task{
{
Name: "group3-task1",
},
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "multiple groups only one with vault",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{},
},
},
Constraints: []*structs.Constraint{
{
LTarget: vaultConstraintLTarget,
RTarget: ">= 1.0.0",
Operand: structs.ConstraintSemver,
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{},
},
},
Constraints: []*structs.Constraint{
{
LTarget: vaultConstraintLTarget,
RTarget: ">= 1.0.0",
Operand: structs.ConstraintSemver,
},
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "existing vault version constraint",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{},
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${node.class}",
RTarget: "high-memory",
Operand: "=",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "group1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{},
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${node.class}",
RTarget: "high-memory",
Operand: "=",
},
vaultConstraint,
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "vault with other constraints",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{
ChangeSignal: "SIGINT",
ChangeMode: structs.VaultChangeModeSignal,
},
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Vault: &structs.Vault{
ChangeSignal: "SIGINT",
ChangeMode: structs.VaultChangeModeSignal,
},
},
},
Constraints: []*structs.Constraint{
vaultConstraint,
{
LTarget: "${attr.os.signals}",
RTarget: "SIGINT",
Operand: "set_contains",
},
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task with vault signal change",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
KillSignal: "SIGINT",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
KillSignal: "SIGINT",
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${attr.os.signals}",
RTarget: "SIGINT",
Operand: "set_contains",
},
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task with kill signal",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Templates: []*structs.Template{
{
ChangeMode: "signal",
ChangeSignal: "SIGINT",
},
},
},
{
Name: "group1-task2",
Templates: []*structs.Template{
{
ChangeMode: "signal",
ChangeSignal: "SIGHUP",
},
},
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Tasks: []*structs.Task{
{
Name: "group1-task1",
Templates: []*structs.Template{
{
ChangeMode: "signal",
ChangeSignal: "SIGINT",
},
},
},
{
Name: "group1-task2",
Templates: []*structs.Template{
{
ChangeMode: "signal",
ChangeSignal: "SIGHUP",
},
},
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${attr.os.signals}",
RTarget: "SIGHUP,SIGINT",
Operand: "set_contains",
},
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "multiple tasks with template signal change",
},
{
inputJob: &structs.Job{
Name: "example",
@ -156,6 +556,155 @@ func Test_jobImpliedConstraints_Mutate(t *testing.T) {
expectedOutputError: nil,
name: "task group nomad discovery other constraints",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
Constraints: []*structs.Constraint{consulServiceDiscoveryConstraint},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task group Consul discovery",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
Constraints: []*structs.Constraint{consulServiceDiscoveryConstraint},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
Constraints: []*structs.Constraint{consulServiceDiscoveryConstraint},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task group Consul discovery constraint found",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${node.class}",
RTarget: "high-memory",
Operand: "=",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
Provider: structs.ServiceProviderConsul,
},
},
Constraints: []*structs.Constraint{
{
LTarget: "${node.class}",
RTarget: "high-memory",
Operand: "=",
},
consulServiceDiscoveryConstraint,
},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task group Consul discovery other constraints",
},
{
inputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
},
},
},
},
},
expectedOutputJob: &structs.Job{
Name: "example",
TaskGroups: []*structs.TaskGroup{
{
Name: "example-group-1",
Services: []*structs.Service{
{
Name: "example-group-service-1",
},
},
Constraints: []*structs.Constraint{consulServiceDiscoveryConstraint},
},
},
},
expectedOutputWarnings: nil,
expectedOutputError: nil,
name: "task group with empty provider",
},
}
for _, tc := range testCases {

View File

@ -1646,15 +1646,13 @@ func TestJobEndpoint_Register_Vault_Policies(t *testing.T) {
t.Fatalf("vault token not cleared")
}
// Check that an implicit constraint was created
// Check that an implicit constraints were created for Vault and Consul.
constraints := out.TaskGroups[0].Constraints
if l := len(constraints); l != 1 {
if l := len(constraints); l != 2 {
t.Fatalf("Unexpected number of tests: %v", l)
}
if !constraints[0].Equal(vaultConstraint) {
t.Fatalf("bad constraint; got %#v; want %#v", constraints[0], vaultConstraint)
}
require.ElementsMatch(t, constraints, []*structs.Constraint{consulServiceDiscoveryConstraint, vaultConstraint})
// Create the register request with another job asking for a vault policy but
// send the root Vault token
@ -6482,15 +6480,11 @@ func TestJobEndpoint_ImplicitConstraints_Vault(t *testing.T) {
t.Fatalf("index mis-match")
}
// Check that there is an implicit vault constraint
constraints := out.TaskGroups[0].Constraints
if len(constraints) != 1 {
t.Fatalf("Expected an implicit constraint")
}
if !constraints[0].Equal(vaultConstraint) {
t.Fatalf("Expected implicit vault constraint")
}
// Check that there is an implicit Vault and Consul constraint.
require.Len(t, out.TaskGroups[0].Constraints, 2)
require.ElementsMatch(t, out.TaskGroups[0].Constraints, []*structs.Constraint{
consulServiceDiscoveryConstraint, vaultConstraint,
})
}
func TestJobEndpoint_ValidateJob_ConsulConnect(t *testing.T) {
@ -6640,20 +6634,11 @@ func TestJobEndpoint_ImplicitConstraints_Signals(t *testing.T) {
t.Fatalf("index mis-match")
}
// Check that there is an implicit signal constraint
constraints := out.TaskGroups[0].Constraints
if len(constraints) != 1 {
t.Fatalf("Expected an implicit constraint")
}
sigConstraint := getSignalConstraint([]string{signal1, signal2})
if !strings.HasPrefix(sigConstraint.RTarget, "SIGHUP") {
t.Fatalf("signals not sorted: %v", sigConstraint.RTarget)
}
if !constraints[0].Equal(sigConstraint) {
t.Fatalf("Expected implicit vault constraint")
}
// Check that there is an implicit signal and Consul constraint.
require.Len(t, out.TaskGroups[0].Constraints, 2)
require.ElementsMatch(t, out.TaskGroups[0].Constraints, []*structs.Constraint{
getSignalConstraint([]string{signal1, signal2}), consulServiceDiscoveryConstraint},
)
}
func TestJobEndpoint_ValidateJobUpdate(t *testing.T) {

View File

@ -34,6 +34,7 @@ func Node() *structs.Node {
"nomad.version": "0.5.0",
"driver.exec": "1",
"driver.mock_driver": "1",
"consul.version": "1.11.4",
},
// TODO Remove once clientv2 gets merged
@ -251,6 +252,13 @@ func Job() *structs.Job {
{
Name: "web",
Count: 10,
Constraints: []*structs.Constraint{
{
LTarget: "${attr.consul.version}",
RTarget: ">= 1.7.0",
Operand: structs.ConstraintSemver,
},
},
EphemeralDisk: &structs.EphemeralDisk{
SizeMB: 150,
},

View File

@ -60,3 +60,42 @@ func requiresNativeServiceDiscovery(services []*Service) bool {
}
return false
}
// RequiredConsulServiceDiscovery identifies which task groups, if any, within
// the job are utilising Consul service discovery.
func (j *Job) RequiredConsulServiceDiscovery() map[string]bool {
groups := make(map[string]bool)
for _, tg := range j.TaskGroups {
// It is possible for services using the Consul provider to be
// configured at the task group level, so check here first. This is
// a requirement for Consul Connect services.
if requiresConsulServiceDiscovery(tg.Services) {
groups[tg.Name] = true
continue
}
// Iterate the tasks within the task group to check the services
// configured at this more traditional level.
for _, task := range tg.Tasks {
if requiresConsulServiceDiscovery(task.Services) {
groups[tg.Name] = true
continue
}
}
}
return groups
}
// requiresConsulServiceDiscovery identifies whether any of the services passed
// to the function are utilising Consul service discovery.
func requiresConsulServiceDiscovery(services []*Service) bool {
for _, tgService := range services {
if tgService.Provider == ServiceProviderConsul || tgService.Provider == "" {
return true
}
}
return false
}

View File

@ -154,3 +154,147 @@ func TestJob_RequiresNativeServiceDiscovery(t *testing.T) {
})
}
}
func TestJob_RequiredConsulServiceDiscovery(t *testing.T) {
testCases := []struct {
inputJob *Job
expectedOutput map[string]bool
name string
}{
{
inputJob: &Job{
TaskGroups: []*TaskGroup{
{
Name: "group1",
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
{
Name: "group2",
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
},
},
expectedOutput: map[string]bool{"group1": true, "group2": true},
name: "multiple group services with Consul provider",
},
{
inputJob: &Job{
TaskGroups: []*TaskGroup{
{
Name: "group1",
Tasks: []*Task{
{
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
{
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
},
},
{
Name: "group2",
Tasks: []*Task{
{
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
{
Services: []*Service{
{Provider: "consul"},
{Provider: "consul"},
},
},
},
},
},
},
expectedOutput: map[string]bool{"group1": true, "group2": true},
name: "multiple task services with Consul provider",
},
{
inputJob: &Job{
TaskGroups: []*TaskGroup{
{
Name: "group1",
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
{
Name: "group2",
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
},
},
expectedOutput: map[string]bool{},
name: "multiple group services with Nomad provider",
},
{
inputJob: &Job{
TaskGroups: []*TaskGroup{
{
Name: "group1",
Tasks: []*Task{
{
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
{
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
},
},
{
Name: "group2",
Tasks: []*Task{
{
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
{
Services: []*Service{
{Provider: "nomad"},
{Provider: "nomad"},
},
},
},
},
},
},
expectedOutput: map[string]bool{},
name: "multiple task services with Nomad provider",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actualOutput := tc.inputJob.RequiredConsulServiceDiscovery()
require.Equal(t, tc.expectedOutput, actualOutput)
})
}
}