github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/tfdiags/contextual.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package tfdiags
     5  
     6  import (
     7  	"github.com/hashicorp/hcl/v2"
     8  	"github.com/zclconf/go-cty/cty"
     9  	"github.com/zclconf/go-cty/cty/gocty"
    10  )
    11  
    12  // The "contextual" family of diagnostics are designed to allow separating
    13  // the detection of a problem from placing that problem in context. For
    14  // example, some code that is validating an object extracted from configuration
    15  // may not have access to the configuration that generated it, but can still
    16  // report problems within that object which the caller can then place in
    17  // context by calling IsConfigBody on the returned diagnostics.
    18  //
    19  // When contextual diagnostics are used, the documentation for a method must
    20  // be very explicit about what context is implied for any diagnostics returned,
    21  // to help ensure the expected result.
    22  
    23  // contextualFromConfig is an interface type implemented by diagnostic types
    24  // that can elaborate themselves when given information about the configuration
    25  // body they are embedded in, as well as the runtime address associated with
    26  // that configuration.
    27  //
    28  // Usually this entails extracting source location information in order to
    29  // populate the "Subject" range.
    30  type contextualFromConfigBody interface {
    31  	ElaborateFromConfigBody(hcl.Body, string) Diagnostic
    32  }
    33  
    34  // InConfigBody returns a copy of the receiver with any config-contextual
    35  // diagnostics elaborated in the context of the given body. An optional address
    36  // argument may be added to indicate which instance of the configuration the
    37  // error related to.
    38  func (diags Diagnostics) InConfigBody(body hcl.Body, addr string) Diagnostics {
    39  	if len(diags) == 0 {
    40  		return nil
    41  	}
    42  
    43  	ret := make(Diagnostics, len(diags))
    44  	for i, srcDiag := range diags {
    45  		if cd, isCD := srcDiag.(contextualFromConfigBody); isCD {
    46  			ret[i] = cd.ElaborateFromConfigBody(body, addr)
    47  		} else {
    48  			ret[i] = srcDiag
    49  		}
    50  	}
    51  
    52  	return ret
    53  }
    54  
    55  // AttributeValue returns a diagnostic about an attribute value in an implied current
    56  // configuration context. This should be returned only from functions whose
    57  // interface specifies a clear configuration context that this will be
    58  // resolved in.
    59  //
    60  // The given path is relative to the implied configuration context. To describe
    61  // a top-level attribute, it should be a single-element cty.Path with a
    62  // cty.GetAttrStep. It's assumed that the path is returning into a structure
    63  // that would be produced by our conventions in the configschema package; it
    64  // may return unexpected results for structures that can't be represented by
    65  // configschema.
    66  //
    67  // Since mapping attribute paths back onto configuration is an imprecise
    68  // operation (e.g. dynamic block generation may cause the same block to be
    69  // evaluated multiple times) the diagnostic detail should include the attribute
    70  // name and other context required to help the user understand what is being
    71  // referenced in case the identified source range is not unique.
    72  //
    73  // The returned attribute will not have source location information until
    74  // context is applied to the containing diagnostics using diags.InConfigBody.
    75  // After context is applied, the source location is the value assigned to the
    76  // named attribute, or the containing body's "missing item range" if no
    77  // value is present.
    78  func AttributeValue(severity Severity, summary, detail string, attrPath cty.Path) Diagnostic {
    79  	return &attributeDiagnostic{
    80  		diagnosticBase: diagnosticBase{
    81  			severity: severity,
    82  			summary:  summary,
    83  			detail:   detail,
    84  		},
    85  		attrPath: attrPath,
    86  	}
    87  }
    88  
    89  // GetAttribute extracts an attribute cty.Path from a diagnostic if it contains
    90  // one. Normally this is not accessed directly, and instead the config body is
    91  // added to the Diagnostic to create a more complete message for the user. In
    92  // some cases however, we may want to know just the name of the attribute that
    93  // generated the Diagnostic message.
    94  // This returns a nil cty.Path if it does not exist in the Diagnostic.
    95  func GetAttribute(d Diagnostic) cty.Path {
    96  	if d, ok := d.(*attributeDiagnostic); ok {
    97  		return d.attrPath
    98  	}
    99  	return nil
   100  }
   101  
   102  type attributeDiagnostic struct {
   103  	diagnosticBase
   104  	attrPath cty.Path
   105  	subject  *SourceRange // populated only after ElaborateFromConfigBody
   106  }
   107  
   108  // ElaborateFromConfigBody finds the most accurate possible source location
   109  // for a diagnostic's attribute path within the given body.
   110  //
   111  // Backing out from a path back to a source location is not always entirely
   112  // possible because we lose some information in the decoding process, so
   113  // if an exact position cannot be found then the returned diagnostic will
   114  // refer to a position somewhere within the containing body, which is assumed
   115  // to be better than no location at all.
   116  //
   117  // If possible it is generally better to report an error at a layer where
   118  // source location information is still available, for more accuracy. This
   119  // is not always possible due to system architecture, so this serves as a
   120  // "best effort" fallback behavior for such situations.
   121  func (d *attributeDiagnostic) ElaborateFromConfigBody(body hcl.Body, addr string) Diagnostic {
   122  	// don't change an existing address
   123  	if d.address == "" {
   124  		d.address = addr
   125  	}
   126  
   127  	if len(d.attrPath) < 1 {
   128  		// Should never happen, but we'll allow it rather than crashing.
   129  		return d
   130  	}
   131  
   132  	if d.subject != nil {
   133  		// Don't modify an already-elaborated diagnostic.
   134  		return d
   135  	}
   136  
   137  	ret := *d
   138  
   139  	// This function will often end up re-decoding values that were already
   140  	// decoded by an earlier step. This is non-ideal but is architecturally
   141  	// more convenient than arranging for source location information to be
   142  	// propagated to every place in Terraform, and this happens only in the
   143  	// presence of errors where performance isn't a concern.
   144  
   145  	traverse := d.attrPath[:]
   146  	final := d.attrPath[len(d.attrPath)-1]
   147  
   148  	// Index should never be the first step
   149  	// as indexing of top blocks (such as resources & data sources)
   150  	// is handled elsewhere
   151  	if _, isIdxStep := traverse[0].(cty.IndexStep); isIdxStep {
   152  		subject := SourceRangeFromHCL(body.MissingItemRange())
   153  		ret.subject = &subject
   154  		return &ret
   155  	}
   156  
   157  	// Process index separately
   158  	idxStep, hasIdx := final.(cty.IndexStep)
   159  	if hasIdx {
   160  		final = d.attrPath[len(d.attrPath)-2]
   161  		traverse = d.attrPath[:len(d.attrPath)-1]
   162  	}
   163  
   164  	// If we have more than one step after removing index
   165  	// then we'll first try to traverse to a child body
   166  	// corresponding to the requested path.
   167  	if len(traverse) > 1 {
   168  		body = traversePathSteps(traverse, body)
   169  	}
   170  
   171  	// Default is to indicate a missing item in the deepest body we reached
   172  	// while traversing.
   173  	subject := SourceRangeFromHCL(body.MissingItemRange())
   174  	ret.subject = &subject
   175  
   176  	// Once we get here, "final" should be a GetAttr step that maps to an
   177  	// attribute in our current body.
   178  	finalStep, isAttr := final.(cty.GetAttrStep)
   179  	if !isAttr {
   180  		return &ret
   181  	}
   182  
   183  	content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
   184  		Attributes: []hcl.AttributeSchema{
   185  			{
   186  				Name:     finalStep.Name,
   187  				Required: true,
   188  			},
   189  		},
   190  	})
   191  	if contentDiags.HasErrors() {
   192  		return &ret
   193  	}
   194  
   195  	if attr, ok := content.Attributes[finalStep.Name]; ok {
   196  		hclRange := attr.Expr.Range()
   197  		if hasIdx {
   198  			// Try to be more precise by finding index range
   199  			hclRange = hclRangeFromIndexStepAndAttribute(idxStep, attr)
   200  		}
   201  		subject = SourceRangeFromHCL(hclRange)
   202  		ret.subject = &subject
   203  	}
   204  
   205  	return &ret
   206  }
   207  
   208  func traversePathSteps(traverse []cty.PathStep, body hcl.Body) hcl.Body {
   209  	for i := 0; i < len(traverse); i++ {
   210  		step := traverse[i]
   211  
   212  		switch tStep := step.(type) {
   213  		case cty.GetAttrStep:
   214  
   215  			var next cty.PathStep
   216  			if i < (len(traverse) - 1) {
   217  				next = traverse[i+1]
   218  			}
   219  
   220  			// Will be indexing into our result here?
   221  			var indexType cty.Type
   222  			var indexVal cty.Value
   223  			if nextIndex, ok := next.(cty.IndexStep); ok {
   224  				indexVal = nextIndex.Key
   225  				indexType = indexVal.Type()
   226  				i++ // skip over the index on subsequent iterations
   227  			}
   228  
   229  			var blockLabelNames []string
   230  			if indexType == cty.String {
   231  				// Map traversal means we expect one label for the key.
   232  				blockLabelNames = []string{"key"}
   233  			}
   234  
   235  			// For intermediate steps we expect to be referring to a child
   236  			// block, so we'll attempt decoding under that assumption.
   237  			content, _, contentDiags := body.PartialContent(&hcl.BodySchema{
   238  				Blocks: []hcl.BlockHeaderSchema{
   239  					{
   240  						Type:       tStep.Name,
   241  						LabelNames: blockLabelNames,
   242  					},
   243  				},
   244  			})
   245  			if contentDiags.HasErrors() {
   246  				return body
   247  			}
   248  			filtered := make([]*hcl.Block, 0, len(content.Blocks))
   249  			for _, block := range content.Blocks {
   250  				if block.Type == tStep.Name {
   251  					filtered = append(filtered, block)
   252  				}
   253  			}
   254  			if len(filtered) == 0 {
   255  				// Step doesn't refer to a block
   256  				continue
   257  			}
   258  
   259  			switch indexType {
   260  			case cty.NilType: // no index at all
   261  				if len(filtered) != 1 {
   262  					return body
   263  				}
   264  				body = filtered[0].Body
   265  			case cty.Number:
   266  				var idx int
   267  				err := gocty.FromCtyValue(indexVal, &idx)
   268  				if err != nil || idx >= len(filtered) {
   269  					return body
   270  				}
   271  				body = filtered[idx].Body
   272  			case cty.String:
   273  				key := indexVal.AsString()
   274  				var block *hcl.Block
   275  				for _, candidate := range filtered {
   276  					if candidate.Labels[0] == key {
   277  						block = candidate
   278  						break
   279  					}
   280  				}
   281  				if block == nil {
   282  					// No block with this key, so we'll just indicate a
   283  					// missing item in the containing block.
   284  					return body
   285  				}
   286  				body = block.Body
   287  			default:
   288  				// Should never happen, because only string and numeric indices
   289  				// are supported by cty collections.
   290  				return body
   291  			}
   292  
   293  		default:
   294  			// For any other kind of step, we'll just return our current body
   295  			// as the subject and accept that this is a little inaccurate.
   296  			return body
   297  		}
   298  	}
   299  	return body
   300  }
   301  
   302  func hclRangeFromIndexStepAndAttribute(idxStep cty.IndexStep, attr *hcl.Attribute) hcl.Range {
   303  	switch idxStep.Key.Type() {
   304  	case cty.Number:
   305  		var idx int
   306  		err := gocty.FromCtyValue(idxStep.Key, &idx)
   307  		items, diags := hcl.ExprList(attr.Expr)
   308  		if diags.HasErrors() {
   309  			return attr.Expr.Range()
   310  		}
   311  		if err != nil || idx >= len(items) {
   312  			return attr.NameRange
   313  		}
   314  		return items[idx].Range()
   315  	case cty.String:
   316  		pairs, diags := hcl.ExprMap(attr.Expr)
   317  		if diags.HasErrors() {
   318  			return attr.Expr.Range()
   319  		}
   320  		stepKey := idxStep.Key.AsString()
   321  		for _, kvPair := range pairs {
   322  			key, diags := kvPair.Key.Value(nil)
   323  			if diags.HasErrors() {
   324  				return attr.Expr.Range()
   325  			}
   326  			if key.AsString() == stepKey {
   327  				startRng := kvPair.Value.StartRange()
   328  				return startRng
   329  			}
   330  		}
   331  		return attr.NameRange
   332  	}
   333  	return attr.Expr.Range()
   334  }
   335  
   336  func (d *attributeDiagnostic) Source() Source {
   337  	return Source{
   338  		Subject: d.subject,
   339  	}
   340  }
   341  
   342  // WholeContainingBody returns a diagnostic about the body that is an implied
   343  // current configuration context. This should be returned only from
   344  // functions whose interface specifies a clear configuration context that this
   345  // will be resolved in.
   346  //
   347  // The returned attribute will not have source location information until
   348  // context is applied to the containing diagnostics using diags.InConfigBody.
   349  // After context is applied, the source location is currently the missing item
   350  // range of the body. In future, this may change to some other suitable
   351  // part of the containing body.
   352  func WholeContainingBody(severity Severity, summary, detail string) Diagnostic {
   353  	return &wholeBodyDiagnostic{
   354  		diagnosticBase: diagnosticBase{
   355  			severity: severity,
   356  			summary:  summary,
   357  			detail:   detail,
   358  		},
   359  	}
   360  }
   361  
   362  type wholeBodyDiagnostic struct {
   363  	diagnosticBase
   364  	subject *SourceRange // populated only after ElaborateFromConfigBody
   365  }
   366  
   367  func (d *wholeBodyDiagnostic) ElaborateFromConfigBody(body hcl.Body, addr string) Diagnostic {
   368  	// don't change an existing address
   369  	if d.address == "" {
   370  		d.address = addr
   371  	}
   372  
   373  	if d.subject != nil {
   374  		// Don't modify an already-elaborated diagnostic.
   375  		return d
   376  	}
   377  
   378  	ret := *d
   379  	rng := SourceRangeFromHCL(body.MissingItemRange())
   380  	ret.subject = &rng
   381  	return &ret
   382  }
   383  
   384  func (d *wholeBodyDiagnostic) Source() Source {
   385  	return Source{
   386  		Subject: d.subject,
   387  	}
   388  }