terraform/tfdiags/contextual.go

373 lines
11 KiB
Go

package tfdiags
import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
)
// The "contextual" family of diagnostics are designed to allow separating
// the detection of a problem from placing that problem in context. For
// example, some code that is validating an object extracted from configuration
// may not have access to the configuration that generated it, but can still
// report problems within that object which the caller can then place in
// context by calling IsConfigBody on the returned diagnostics.
//
// When contextual diagnostics are used, the documentation for a method must
// be very explicit about what context is implied for any diagnostics returned,
// to help ensure the expected result.
// contextualFromConfig is an interface type implemented by diagnostic types
// that can elaborate themselves when given information about the configuration
// body they are embedded in.
//
// Usually this entails extracting source location information in order to
// populate the "Subject" range.
type contextualFromConfigBody interface {
ElaborateFromConfigBody(hcl.Body) Diagnostic
}
// InConfigBody returns a copy of the receiver with any config-contextual
// diagnostics elaborated in the context of the given body.
func (d Diagnostics) InConfigBody(body hcl.Body) Diagnostics {
if len(d) == 0 {
return nil
}
ret := make(Diagnostics, len(d))
for i, srcDiag := range d {
if cd, isCD := srcDiag.(contextualFromConfigBody); isCD {
ret[i] = cd.ElaborateFromConfigBody(body)
} else {
ret[i] = srcDiag
}
}
return ret
}
// AttributeValue returns a diagnostic about an attribute value in an implied current
// configuration context. This should be returned only from functions whose
// interface specifies a clear configuration context that this will be
// resolved in.
//
// The given path is relative to the implied configuration context. To describe
// a top-level attribute, it should be a single-element cty.Path with a
// cty.GetAttrStep. It's assumed that the path is returning into a structure
// that would be produced by our conventions in the configschema package; it
// may return unexpected results for structures that can't be represented by
// configschema.
//
// Since mapping attribute paths back onto configuration is an imprecise
// operation (e.g. dynamic block generation may cause the same block to be
// evaluated multiple times) the diagnostic detail should include the attribute
// name and other context required to help the user understand what is being
// referenced in case the identified source range is not unique.
//
// The returned attribute will not have source location information until
// context is applied to the containing diagnostics using diags.InConfigBody.
// After context is applied, the source location is the value assigned to the
// named attribute, or the containing body's "missing item range" if no
// value is present.
func AttributeValue(severity Severity, summary, detail string, attrPath cty.Path) Diagnostic {
return &attributeDiagnostic{
diagnosticBase: diagnosticBase{
severity: severity,
summary: summary,
detail: detail,
},
attrPath: attrPath,
}
}
// GetAttribute extracts an attribute cty.Path from a diagnostic if it contains
// one. Normally this is not accessed directly, and instead the config body is
// added to the Diagnostic to create a more complete message for the user. In
// some cases however, we may want to know just the name of the attribute that
// generated the Diagnostic message.
// This returns a nil cty.Path if it does not exist in the Diagnostic.
func GetAttribute(d Diagnostic) cty.Path {
if d, ok := d.(*attributeDiagnostic); ok {
return d.attrPath
}
return nil
}
type attributeDiagnostic struct {
diagnosticBase
attrPath cty.Path
subject *SourceRange // populated only after ElaborateFromConfigBody
}
// ElaborateFromConfigBody finds the most accurate possible source location
// for a diagnostic's attribute path within the given body.
//
// Backing out from a path back to a source location is not always entirely
// possible because we lose some information in the decoding process, so
// if an exact position cannot be found then the returned diagnostic will
// refer to a position somewhere within the containing body, which is assumed
// to be better than no location at all.
//
// If possible it is generally better to report an error at a layer where
// source location information is still available, for more accuracy. This
// is not always possible due to system architecture, so this serves as a
// "best effort" fallback behavior for such situations.
func (d *attributeDiagnostic) ElaborateFromConfigBody(body hcl.Body) Diagnostic {
if len(d.attrPath) < 1 {
// Should never happen, but we'll allow it rather than crashing.
return d
}
if d.subject != nil {
// Don't modify an already-elaborated diagnostic.
return d
}
ret := *d
// This function will often end up re-decoding values that were already
// decoded by an earlier step. This is non-ideal but is architecturally
// more convenient than arranging for source location information to be
// propagated to every place in Terraform, and this happens only in the
// presence of errors where performance isn't a concern.
traverse := d.attrPath[:]
final := d.attrPath[len(d.attrPath)-1]
// Index should never be the first step
// as indexing of top blocks (such as resources & data sources)
// is handled elsewhere
if _, isIdxStep := traverse[0].(cty.IndexStep); isIdxStep {
subject := SourceRangeFromHCL(body.MissingItemRange())
ret.subject = &subject
return &ret
}
// Process index separately
idxStep, hasIdx := final.(cty.IndexStep)
if hasIdx {
final = d.attrPath[len(d.attrPath)-2]
traverse = d.attrPath[:len(d.attrPath)-1]
}
// If we have more than one step after removing index
// then we'll first try to traverse to a child body
// corresponding to the requested path.
if len(traverse) > 1 {
body = traversePathSteps(traverse, body)
}
// Default is to indicate a missing item in the deepest body we reached
// while traversing.
subject := SourceRangeFromHCL(body.MissingItemRange())
ret.subject = &subject
// Once we get here, "final" should be a GetAttr step that maps to an
// attribute in our current body.
finalStep, isAttr := final.(cty.GetAttrStep)
if !isAttr {
return &ret
}
content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: finalStep.Name,
Required: true,
},
},
})
if contentDiags.HasErrors() {
return &ret
}
if attr, ok := content.Attributes[finalStep.Name]; ok {
hclRange := attr.Expr.Range()
if hasIdx {
// Try to be more precise by finding index range
hclRange = hclRangeFromIndexStepAndAttribute(idxStep, attr)
}
subject = SourceRangeFromHCL(hclRange)
ret.subject = &subject
}
return &ret
}
func traversePathSteps(traverse []cty.PathStep, body hcl.Body) hcl.Body {
for i := 0; i < len(traverse); i++ {
step := traverse[i]
switch tStep := step.(type) {
case cty.GetAttrStep:
var next cty.PathStep
if i < (len(traverse) - 1) {
next = traverse[i+1]
}
// Will be indexing into our result here?
var indexType cty.Type
var indexVal cty.Value
if nextIndex, ok := next.(cty.IndexStep); ok {
indexVal = nextIndex.Key
indexType = indexVal.Type()
i++ // skip over the index on subsequent iterations
}
var blockLabelNames []string
if indexType == cty.String {
// Map traversal means we expect one label for the key.
blockLabelNames = []string{"key"}
}
// For intermediate steps we expect to be referring to a child
// block, so we'll attempt decoding under that assumption.
content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: tStep.Name,
LabelNames: blockLabelNames,
},
},
})
if contentDiags.HasErrors() {
return body
}
filtered := make([]*hcl.Block, 0, len(content.Blocks))
for _, block := range content.Blocks {
if block.Type == tStep.Name {
filtered = append(filtered, block)
}
}
if len(filtered) == 0 {
// Step doesn't refer to a block
continue
}
switch indexType {
case cty.NilType: // no index at all
if len(filtered) != 1 {
return body
}
body = filtered[0].Body
case cty.Number:
var idx int
err := gocty.FromCtyValue(indexVal, &idx)
if err != nil || idx >= len(filtered) {
return body
}
body = filtered[idx].Body
case cty.String:
key := indexVal.AsString()
var block *hcl.Block
for _, candidate := range filtered {
if candidate.Labels[0] == key {
block = candidate
break
}
}
if block == nil {
// No block with this key, so we'll just indicate a
// missing item in the containing block.
return body
}
body = block.Body
default:
// Should never happen, because only string and numeric indices
// are supported by cty collections.
return body
}
default:
// For any other kind of step, we'll just return our current body
// as the subject and accept that this is a little inaccurate.
return body
}
}
return body
}
func hclRangeFromIndexStepAndAttribute(idxStep cty.IndexStep, attr *hcl.Attribute) hcl.Range {
switch idxStep.Key.Type() {
case cty.Number:
var idx int
err := gocty.FromCtyValue(idxStep.Key, &idx)
items, diags := hcl.ExprList(attr.Expr)
if diags.HasErrors() {
return attr.Expr.Range()
}
if err != nil || idx >= len(items) {
return attr.NameRange
}
return items[idx].Range()
case cty.String:
pairs, diags := hcl.ExprMap(attr.Expr)
if diags.HasErrors() {
return attr.Expr.Range()
}
stepKey := idxStep.Key.AsString()
for _, kvPair := range pairs {
key, err := kvPair.Key.Value(nil)
if err != nil {
return attr.Expr.Range()
}
if key.AsString() == stepKey {
startRng := kvPair.Value.StartRange()
return startRng
}
}
return attr.NameRange
}
return attr.Expr.Range()
}
func (d *attributeDiagnostic) Source() Source {
return Source{
Subject: d.subject,
}
}
// WholeContainingBody returns a diagnostic about the body that is an implied
// current configuration context. This should be returned only from
// functions whose interface specifies a clear configuration context that this
// will be resolved in.
//
// The returned attribute will not have source location information until
// context is applied to the containing diagnostics using diags.InConfigBody.
// After context is applied, the source location is currently the missing item
// range of the body. In future, this may change to some other suitable
// part of the containing body.
func WholeContainingBody(severity Severity, summary, detail string) Diagnostic {
return &wholeBodyDiagnostic{
diagnosticBase: diagnosticBase{
severity: severity,
summary: summary,
detail: detail,
},
}
}
type wholeBodyDiagnostic struct {
diagnosticBase
subject *SourceRange // populated only after ElaborateFromConfigBody
}
func (d *wholeBodyDiagnostic) ElaborateFromConfigBody(body hcl.Body) Diagnostic {
if d.subject != nil {
// Don't modify an already-elaborated diagnostic.
return d
}
ret := *d
rng := SourceRangeFromHCL(body.MissingItemRange())
ret.subject = &rng
return &ret
}
func (d *wholeBodyDiagnostic) Source() Source {
return Source{
Subject: d.subject,
}
}