github.com/opentofu/opentofu@v1.7.1/internal/tfdiags/contextual.go (about)

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