7077d1f9aa
This PR adds the functionality of allowing custom scripts to be executed on template change. Resolves #2707
500 lines
15 KiB
Go
500 lines
15 KiB
Go
package jobspec2
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/hashicorp/hcl/v2/gohcl"
|
|
"github.com/hashicorp/hcl/v2/hcldec"
|
|
"github.com/hashicorp/nomad/api"
|
|
"github.com/hashicorp/nomad/helper/pointer"
|
|
"github.com/zclconf/go-cty/cty"
|
|
"github.com/zclconf/go-cty/cty/gocty"
|
|
)
|
|
|
|
var hclDecoder *gohcl.Decoder
|
|
|
|
func init() {
|
|
hclDecoder = newHCLDecoder()
|
|
hclDecoder.RegisterBlockDecoder(reflect.TypeOf(api.TaskGroup{}), decodeTaskGroup)
|
|
hclDecoder.RegisterBlockDecoder(reflect.TypeOf(api.Task{}), decodeTask)
|
|
}
|
|
|
|
func newHCLDecoder() *gohcl.Decoder {
|
|
decoder := &gohcl.Decoder{}
|
|
|
|
// time conversion
|
|
d := time.Duration(0)
|
|
decoder.RegisterExpressionDecoder(reflect.TypeOf(d), decodeDuration)
|
|
decoder.RegisterExpressionDecoder(reflect.TypeOf(&d), decodeDuration)
|
|
|
|
// custom nomad types
|
|
decoder.RegisterBlockDecoder(reflect.TypeOf(api.Affinity{}), decodeAffinity)
|
|
decoder.RegisterBlockDecoder(reflect.TypeOf(api.Constraint{}), decodeConstraint)
|
|
|
|
return decoder
|
|
}
|
|
|
|
func decodeDuration(expr hcl.Expression, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
|
|
srcVal, diags := expr.Value(ctx)
|
|
|
|
if srcVal.Type() == cty.String {
|
|
dur, err := time.ParseDuration(srcVal.AsString())
|
|
if err != nil {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Unsuitable value type",
|
|
Detail: fmt.Sprintf("Unsuitable duration value: %s", err.Error()),
|
|
Subject: expr.StartRange().Ptr(),
|
|
Context: expr.Range().Ptr(),
|
|
})
|
|
return diags
|
|
}
|
|
|
|
srcVal = cty.NumberIntVal(int64(dur))
|
|
}
|
|
|
|
if srcVal.Type() != cty.Number {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Unsuitable value type",
|
|
Detail: fmt.Sprintf("Unsuitable value: expected a string but found %s", srcVal.Type()),
|
|
Subject: expr.StartRange().Ptr(),
|
|
Context: expr.Range().Ptr(),
|
|
})
|
|
return diags
|
|
|
|
}
|
|
|
|
err := gocty.FromCtyValue(srcVal, val)
|
|
if err != nil {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Unsuitable value type",
|
|
Detail: fmt.Sprintf("Unsuitable value: %s", err.Error()),
|
|
Subject: expr.StartRange().Ptr(),
|
|
Context: expr.Range().Ptr(),
|
|
})
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
var affinitySpec = hcldec.ObjectSpec{
|
|
"attribute": &hcldec.AttrSpec{Name: "attribute", Type: cty.String, Required: false},
|
|
"value": &hcldec.AttrSpec{Name: "value", Type: cty.String, Required: false},
|
|
"operator": &hcldec.AttrSpec{Name: "operator", Type: cty.String, Required: false},
|
|
"weight": &hcldec.AttrSpec{Name: "weight", Type: cty.Number, Required: false},
|
|
|
|
api.ConstraintVersion: &hcldec.AttrSpec{Name: api.ConstraintVersion, Type: cty.String, Required: false},
|
|
api.ConstraintSemver: &hcldec.AttrSpec{Name: api.ConstraintSemver, Type: cty.String, Required: false},
|
|
api.ConstraintRegex: &hcldec.AttrSpec{Name: api.ConstraintRegex, Type: cty.String, Required: false},
|
|
api.ConstraintSetContains: &hcldec.AttrSpec{Name: api.ConstraintSetContains, Type: cty.String, Required: false},
|
|
api.ConstraintSetContainsAll: &hcldec.AttrSpec{Name: api.ConstraintSetContainsAll, Type: cty.String, Required: false},
|
|
api.ConstraintSetContainsAny: &hcldec.AttrSpec{Name: api.ConstraintSetContainsAny, Type: cty.String, Required: false},
|
|
}
|
|
|
|
func decodeAffinity(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
|
|
a := val.(*api.Affinity)
|
|
v, diags := hcldec.Decode(body, affinitySpec, ctx)
|
|
if len(diags) != 0 {
|
|
return diags
|
|
}
|
|
|
|
attr := func(attr string) string {
|
|
a := v.GetAttr(attr)
|
|
if a.IsNull() {
|
|
return ""
|
|
}
|
|
return a.AsString()
|
|
}
|
|
a.LTarget = attr("attribute")
|
|
a.RTarget = attr("value")
|
|
a.Operand = attr("operator")
|
|
weight := v.GetAttr("weight")
|
|
if !weight.IsNull() {
|
|
w, _ := weight.AsBigFloat().Int64()
|
|
a.Weight = pointer.Of(int8(w))
|
|
}
|
|
|
|
// If "version" is provided, set the operand
|
|
// to "version" and the value to the "RTarget"
|
|
if affinity := attr(api.ConstraintVersion); affinity != "" {
|
|
a.Operand = api.ConstraintVersion
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
// If "semver" is provided, set the operand
|
|
// to "semver" and the value to the "RTarget"
|
|
if affinity := attr(api.ConstraintSemver); affinity != "" {
|
|
a.Operand = api.ConstraintSemver
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
// If "regexp" is provided, set the operand
|
|
// to "regexp" and the value to the "RTarget"
|
|
if affinity := attr(api.ConstraintRegex); affinity != "" {
|
|
a.Operand = api.ConstraintRegex
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
// If "set_contains_any" is provided, set the operand
|
|
// to "set_contains_any" and the value to the "RTarget"
|
|
if affinity := attr(api.ConstraintSetContainsAny); affinity != "" {
|
|
a.Operand = api.ConstraintSetContainsAny
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
// If "set_contains_all" is provided, set the operand
|
|
// to "set_contains_all" and the value to the "RTarget"
|
|
if affinity := attr(api.ConstraintSetContainsAll); affinity != "" {
|
|
a.Operand = api.ConstraintSetContainsAll
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
// set_contains is a synonym of set_contains_all
|
|
if affinity := attr(api.ConstraintSetContains); affinity != "" {
|
|
a.Operand = api.ConstraintSetContains
|
|
a.RTarget = affinity
|
|
}
|
|
|
|
if a.Operand == "" {
|
|
a.Operand = "="
|
|
}
|
|
return diags
|
|
}
|
|
|
|
var constraintSpec = hcldec.ObjectSpec{
|
|
"attribute": &hcldec.AttrSpec{Name: "attribute", Type: cty.String, Required: false},
|
|
"value": &hcldec.AttrSpec{Name: "value", Type: cty.String, Required: false},
|
|
"operator": &hcldec.AttrSpec{Name: "operator", Type: cty.String, Required: false},
|
|
|
|
api.ConstraintDistinctProperty: &hcldec.AttrSpec{Name: api.ConstraintDistinctProperty, Type: cty.String, Required: false},
|
|
api.ConstraintDistinctHosts: &hcldec.AttrSpec{Name: api.ConstraintDistinctHosts, Type: cty.Bool, Required: false},
|
|
api.ConstraintRegex: &hcldec.AttrSpec{Name: api.ConstraintRegex, Type: cty.String, Required: false},
|
|
api.ConstraintVersion: &hcldec.AttrSpec{Name: api.ConstraintVersion, Type: cty.String, Required: false},
|
|
api.ConstraintSemver: &hcldec.AttrSpec{Name: api.ConstraintSemver, Type: cty.String, Required: false},
|
|
api.ConstraintSetContains: &hcldec.AttrSpec{Name: api.ConstraintSetContains, Type: cty.String, Required: false},
|
|
api.ConstraintSetContainsAll: &hcldec.AttrSpec{Name: api.ConstraintSetContainsAll, Type: cty.String, Required: false},
|
|
api.ConstraintSetContainsAny: &hcldec.AttrSpec{Name: api.ConstraintSetContainsAny, Type: cty.String, Required: false},
|
|
api.ConstraintAttributeIsSet: &hcldec.AttrSpec{Name: api.ConstraintAttributeIsSet, Type: cty.String, Required: false},
|
|
api.ConstraintAttributeIsNotSet: &hcldec.AttrSpec{Name: api.ConstraintAttributeIsNotSet, Type: cty.String, Required: false},
|
|
}
|
|
|
|
func decodeConstraint(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
|
|
c := val.(*api.Constraint)
|
|
|
|
v, diags := hcldec.Decode(body, constraintSpec, ctx)
|
|
if len(diags) != 0 {
|
|
return diags
|
|
}
|
|
|
|
attr := func(attr string) string {
|
|
a := v.GetAttr(attr)
|
|
if a.IsNull() {
|
|
return ""
|
|
}
|
|
return a.AsString()
|
|
}
|
|
|
|
c.LTarget = attr("attribute")
|
|
c.RTarget = attr("value")
|
|
c.Operand = attr("operator")
|
|
|
|
// If "version" is provided, set the operand
|
|
// to "version" and the value to the "RTarget"
|
|
if constraint := attr(api.ConstraintVersion); constraint != "" {
|
|
c.Operand = api.ConstraintVersion
|
|
c.RTarget = constraint
|
|
}
|
|
|
|
// If "semver" is provided, set the operand
|
|
// to "semver" and the value to the "RTarget"
|
|
if constraint := attr(api.ConstraintSemver); constraint != "" {
|
|
c.Operand = api.ConstraintSemver
|
|
c.RTarget = constraint
|
|
}
|
|
|
|
// If "regexp" is provided, set the operand
|
|
// to "regexp" and the value to the "RTarget"
|
|
if constraint := attr(api.ConstraintRegex); constraint != "" {
|
|
c.Operand = api.ConstraintRegex
|
|
c.RTarget = constraint
|
|
}
|
|
|
|
// If "set_contains" is provided, set the operand
|
|
// to "set_contains" and the value to the "RTarget"
|
|
if constraint := attr(api.ConstraintSetContains); constraint != "" {
|
|
c.Operand = api.ConstraintSetContains
|
|
c.RTarget = constraint
|
|
}
|
|
|
|
if d := v.GetAttr(api.ConstraintDistinctHosts); !d.IsNull() && d.True() {
|
|
c.Operand = api.ConstraintDistinctHosts
|
|
}
|
|
|
|
if property := attr(api.ConstraintDistinctProperty); property != "" {
|
|
c.Operand = api.ConstraintDistinctProperty
|
|
c.LTarget = property
|
|
}
|
|
|
|
if c.Operand == "" {
|
|
c.Operand = "="
|
|
}
|
|
return diags
|
|
}
|
|
|
|
func decodeTaskGroup(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
|
|
tg := val.(*api.TaskGroup)
|
|
|
|
var diags hcl.Diagnostics
|
|
|
|
metaAttr, body, moreDiags := decodeAsAttribute(body, ctx, "meta")
|
|
diags = append(diags, moreDiags...)
|
|
|
|
tgExtra := struct {
|
|
Vault *api.Vault `hcl:"vault,block"`
|
|
}{}
|
|
|
|
extra, _ := gohcl.ImpliedBodySchema(tgExtra)
|
|
content, tgBody, moreDiags := body.PartialContent(extra)
|
|
diags = append(diags, moreDiags...)
|
|
if len(diags) != 0 {
|
|
return diags
|
|
}
|
|
|
|
for _, b := range content.Blocks {
|
|
if b.Type == "vault" {
|
|
v := &api.Vault{}
|
|
diags = append(diags, hclDecoder.DecodeBody(b.Body, ctx, v)...)
|
|
tgExtra.Vault = v
|
|
}
|
|
}
|
|
|
|
d := newHCLDecoder()
|
|
d.RegisterBlockDecoder(reflect.TypeOf(api.Task{}), decodeTask)
|
|
diags = d.DecodeBody(tgBody, ctx, tg)
|
|
|
|
if metaAttr != nil {
|
|
tg.Meta = metaAttr
|
|
}
|
|
|
|
if tgExtra.Vault != nil {
|
|
for _, t := range tg.Tasks {
|
|
if t.Vault == nil {
|
|
t.Vault = tgExtra.Vault
|
|
}
|
|
}
|
|
}
|
|
|
|
if tg.Scaling != nil {
|
|
if tg.Scaling.Type == "" {
|
|
tg.Scaling.Type = "horizontal"
|
|
}
|
|
diags = append(diags, validateGroupScalingPolicy(tg.Scaling, tgBody)...)
|
|
}
|
|
return diags
|
|
|
|
}
|
|
|
|
func decodeTask(body hcl.Body, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
|
|
// special case scaling policy
|
|
t := val.(*api.Task)
|
|
|
|
var diags hcl.Diagnostics
|
|
|
|
// special case env and meta
|
|
envAttr, body, moreDiags := decodeAsAttribute(body, ctx, "env")
|
|
diags = append(diags, moreDiags...)
|
|
metaAttr, body, moreDiags := decodeAsAttribute(body, ctx, "meta")
|
|
diags = append(diags, moreDiags...)
|
|
|
|
b, remain, moreDiags := body.PartialContent(&hcl.BodySchema{
|
|
Blocks: []hcl.BlockHeaderSchema{
|
|
{Type: "scaling", LabelNames: []string{"name"}},
|
|
},
|
|
})
|
|
|
|
diags = append(diags, moreDiags...)
|
|
diags = append(diags, decodeTaskScalingPolicies(b.Blocks, ctx, t)...)
|
|
|
|
decoder := newHCLDecoder()
|
|
diags = append(diags, decoder.DecodeBody(remain, ctx, val)...)
|
|
|
|
if envAttr != nil {
|
|
t.Env = envAttr
|
|
}
|
|
if metaAttr != nil {
|
|
t.Meta = metaAttr
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
// decodeAsAttribute decodes the named field as an attribute assignment if found.
|
|
//
|
|
// Nomad jobs contain attributes (e.g. `env`, `meta`) that are meant to contain arbitrary
|
|
// keys. HCLv1 allowed both block syntax (the preferred and documented one) as well as attribute
|
|
// assignment syntax:
|
|
//
|
|
// ```hcl
|
|
// # block assignment
|
|
//
|
|
// env {
|
|
// ENV = "production"
|
|
// }
|
|
//
|
|
// # as attribute
|
|
// env = { ENV: "production" }
|
|
// ```
|
|
//
|
|
// HCLv2 block syntax, though, restricts valid input and doesn't allow dots or invalid identifiers
|
|
// as block attribute keys.
|
|
// Thus, we support both syntax to unrestrict users.
|
|
//
|
|
// This function attempts to read the named field, as an attribute, and returns
|
|
// found map, the remaining body and diagnostics. If the named field is found
|
|
// with block syntax, it returns a nil map, and caller falls back to reading
|
|
// with block syntax.
|
|
func decodeAsAttribute(body hcl.Body, ctx *hcl.EvalContext, name string) (map[string]string, hcl.Body, hcl.Diagnostics) {
|
|
b, remain, diags := body.PartialContent(&hcl.BodySchema{
|
|
Attributes: []hcl.AttributeSchema{
|
|
{Name: name, Required: false},
|
|
},
|
|
})
|
|
|
|
if diags.HasErrors() || b.Attributes[name] == nil {
|
|
// ignoring errors, to avoid duplicate errors. True errors will
|
|
// reported in the fallback path
|
|
return nil, body, nil
|
|
}
|
|
|
|
attr := b.Attributes[name]
|
|
|
|
if attr != nil {
|
|
// check if there is another block
|
|
bb, _, _ := remain.PartialContent(&hcl.BodySchema{
|
|
Blocks: []hcl.BlockHeaderSchema{{Type: name}},
|
|
})
|
|
if len(bb.Blocks) != 0 {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: fmt.Sprintf("Duplicate %v block", name),
|
|
Detail: fmt.Sprintf("%v may not be defined more than once. Another definition is defined at %s.",
|
|
name, attr.Range.String()),
|
|
Subject: &bb.Blocks[0].DefRange,
|
|
})
|
|
return nil, remain, diags
|
|
}
|
|
}
|
|
|
|
envExpr := attr.Expr
|
|
|
|
result := map[string]string{}
|
|
diags = append(diags, hclDecoder.DecodeExpression(envExpr, ctx, &result)...)
|
|
|
|
return result, remain, diags
|
|
}
|
|
|
|
func decodeTaskScalingPolicies(blocks hcl.Blocks, ctx *hcl.EvalContext, task *api.Task) hcl.Diagnostics {
|
|
if len(blocks) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var diags hcl.Diagnostics
|
|
seen := map[string]*hcl.Block{}
|
|
for _, b := range blocks {
|
|
label := strings.ToLower(b.Labels[0])
|
|
var policyType string
|
|
switch label {
|
|
case "cpu":
|
|
policyType = "vertical_cpu"
|
|
case "mem":
|
|
policyType = "vertical_mem"
|
|
default:
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid scaling policy name",
|
|
Detail: `scaling policy name must be "cpu" or "mem"`,
|
|
Subject: &b.LabelRanges[0],
|
|
})
|
|
continue
|
|
}
|
|
|
|
if prev, ok := seen[label]; ok {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: fmt.Sprintf("Duplicate scaling %q block", label),
|
|
Detail: fmt.Sprintf(
|
|
"Only one scaling %s block is allowed. Another was defined at %s.",
|
|
label, prev.DefRange.String(),
|
|
),
|
|
Subject: &b.DefRange,
|
|
})
|
|
continue
|
|
}
|
|
seen[label] = b
|
|
|
|
var p api.ScalingPolicy
|
|
diags = append(diags, hclDecoder.DecodeBody(b.Body, ctx, &p)...)
|
|
|
|
if p.Type == "" {
|
|
p.Type = policyType
|
|
} else if p.Type != policyType {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid scaling policy type",
|
|
Detail: fmt.Sprintf(
|
|
"Invalid policy type, expected %q but found %q",
|
|
p.Type, policyType),
|
|
Subject: &b.DefRange,
|
|
})
|
|
continue
|
|
}
|
|
|
|
task.ScalingPolicies = append(task.ScalingPolicies, &p)
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
func validateGroupScalingPolicy(p *api.ScalingPolicy, body hcl.Body) hcl.Diagnostics {
|
|
// fast path: do nothing
|
|
if p.Max != nil && p.Type == "horizontal" {
|
|
return nil
|
|
}
|
|
|
|
content, _, diags := body.PartialContent(&hcl.BodySchema{
|
|
Blocks: []hcl.BlockHeaderSchema{{Type: "scaling"}},
|
|
})
|
|
|
|
if len(content.Blocks) == 0 {
|
|
// unexpected, given that we have a scaling policy
|
|
return diags
|
|
}
|
|
|
|
pc, _, diags := content.Blocks[0].Body.PartialContent(&hcl.BodySchema{
|
|
Attributes: []hcl.AttributeSchema{
|
|
{Name: "max", Required: true},
|
|
{Name: "type", Required: false},
|
|
},
|
|
})
|
|
|
|
if p.Type != "horizontal" {
|
|
if attr, ok := pc.Attributes["type"]; ok {
|
|
diags = append(diags, &hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Invalid group scaling type",
|
|
Detail: fmt.Sprintf(
|
|
"task group scaling policy had invalid type: %q",
|
|
p.Type),
|
|
Subject: attr.Expr.Range().Ptr(),
|
|
})
|
|
}
|
|
}
|
|
return diags
|
|
}
|