// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 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 } // The shortcut form of the distinct_hosts constraint is a cty.Bool // so it can not use the `attr` func defined earlier if d := v.GetAttr(api.ConstraintDistinctHosts); !d.IsNull() { c.Operand = api.ConstraintDistinctHosts c.RTarget = fmt.Sprint(d.True()) } 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 }