From c7a6b8b253807d183204e7edfc110b8b5432665c Mon Sep 17 00:00:00 2001 From: Michael Smithhisler Date: Thu, 24 Jul 2025 14:07:28 -0400 Subject: [PATCH] adds implied secrets constraint to job hook (#26328) --- nomad/job_endpoint_hooks.go | 22 +++++- nomad/job_endpoint_hooks_test.go | 116 +++++++++++++++++++++++++++++++ nomad/structs/structs.go | 27 +++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/nomad/job_endpoint_hooks.go b/nomad/job_endpoint_hooks.go index e32781481..b1788dc2e 100644 --- a/nomad/job_endpoint_hooks.go +++ b/nomad/job_endpoint_hooks.go @@ -279,6 +279,8 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro taskScheduleTaskGroups := j.RequiredScheduleTask() + secretBlocks := j.Secrets() + // Hot path where none of our things require constraints. // // [UPDATE THIS] if you are adding a new constraint thing! @@ -286,7 +288,8 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro nativeServiceDisco.Empty() && len(consulServiceDisco) == 0 && numaTaskGroups.Empty() && bridgeNetworkingTaskGroups.Empty() && transparentProxyTaskGroups.Empty() && - taskScheduleTaskGroups.Empty() { + taskScheduleTaskGroups.Empty() && + len(secretBlocks) == 0 { return j, nil, nil } @@ -302,6 +305,12 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro mutateConstraint(constraintMatcherLeft, tg, vaultConstraintFn(vaultBlock)) } + for _, secretProviders := range secretBlocks { + for _, provider := range secretProviders { + mutateConstraint(constraintMatcherLeft, tg, secretsConstraintFn(provider)) + } + } + // If the task group utilizes NUMA resources, run the mutator. if numaTaskGroups.Contains(tg.Name) { mutateConstraint(constraintMatcherFull, tg, numaVersionConstraint) @@ -382,6 +391,17 @@ func vaultConstraintFn(vault *structs.Vault) *structs.Constraint { return vaultConstraint } +// secretsConstraintFn returns a constraint that checks for the existence of a +// fingerprinted secrets plugin. This is to support upgrades to 1.11 where a nomad +// server follower may not be upgraded yet and attempt to place a job on a client +// that has not been upgraded. This should be removed in Nomad 1.14. +func secretsConstraintFn(provider string) *structs.Constraint { + return &structs.Constraint{ + LTarget: fmt.Sprintf("${attr.plugins.secrets.%s.version}", provider), + Operand: structs.ConstraintAttributeIsSet, + } +} + // consulConstraintFn returns a service discovery constraint that matches the // fingerprint of the requested Consul cluster. This is to support Nomad // Enterprise but neither the fingerprint or non-default cluster are allowed diff --git a/nomad/job_endpoint_hooks_test.go b/nomad/job_endpoint_hooks_test.go index d0e83c8a1..2e4f8417c 100644 --- a/nomad/job_endpoint_hooks_test.go +++ b/nomad/job_endpoint_hooks_test.go @@ -1280,6 +1280,122 @@ func Test_jobImpliedConstraints_Mutate(t *testing.T) { expectedOutputWarnings: nil, expectedOutputError: nil, }, + { + name: "task with secret", + inputJob: &structs.Job{ + Name: "example", + TaskGroups: []*structs.TaskGroup{ + { + Name: "group-with-secret", + Tasks: []*structs.Task{ + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "test", + }, + }, + }, + }, + }, + }, + }, + expectedOutputJob: &structs.Job{ + Name: "example", + TaskGroups: []*structs.TaskGroup{ + { + Name: "group-with-secret", + Tasks: []*structs.Task{ + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "test", + }, + }, + }, + }, + Constraints: []*structs.Constraint{ + { + LTarget: "${attr.plugins.secrets.test.version}", + Operand: structs.ConstraintAttributeIsSet, + }, + }, + }, + }, + }, + }, + { + name: "tasks with overlapping secrets", + inputJob: &structs.Job{ + Name: "example", + TaskGroups: []*structs.TaskGroup{ + { + Name: "group-with-secret", + Tasks: []*structs.Task{ + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "foo", + }, + }, + }, + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "foo", + }, + { + Provider: "bar", + }, + }, + }, + }, + }, + }, + }, + expectedOutputJob: &structs.Job{ + Name: "example", + TaskGroups: []*structs.TaskGroup{ + { + Name: "group-with-secret", + Tasks: []*structs.Task{ + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "foo", + }, + }, + }, + { + Name: "task-with-secret", + Secrets: []*structs.Secret{ + { + Provider: "foo", + }, + { + Provider: "bar", + }, + }, + }, + }, + Constraints: []*structs.Constraint{ + { + LTarget: "${attr.plugins.secrets.foo.version}", + Operand: structs.ConstraintAttributeIsSet, + }, + { + LTarget: "${attr.plugins.secrets.bar.version}", + Operand: structs.ConstraintAttributeIsSet, + }, + }, + }, + }, + }, + }, } for _, tc := range testCases { diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index e1b0df9eb..b5515d257 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -5096,6 +5096,33 @@ func (j *Job) Vault() map[string]map[string]*Vault { return blocks } +// Secrets returns the set of secrets per task group, per task +func (j *Job) Secrets() map[string][]string { + blocks := make(map[string][]string, len(j.TaskGroups)) + + for _, tg := range j.TaskGroups { + secrets := []string{} + + for _, task := range tg.Tasks { + if len(task.Secrets) == 0 { + continue + } + + for _, s := range task.Secrets { + if !slices.Contains(secrets, s.Provider) { + secrets = append(secrets, s.Provider) + } + } + } + + if len(secrets) != 0 { + blocks[tg.Name] = secrets + } + } + + return blocks +} + // ConnectTasks returns the set of Consul Connect enabled tasks defined on the // job that will require a Service Identity token in the case that Consul ACLs // are enabled. The TaskKind.Value is the name of the Consul service.