| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: BUSL-1.1 |
| |
| package json |
| |
| import ( |
| "bufio" |
| "fmt" |
| "sort" |
| "strings" |
| |
| "github.com/hashicorp/hcl/v2" |
| "github.com/hashicorp/hcl/v2/hcled" |
| "github.com/hashicorp/hcl/v2/hclparse" |
| "github.com/hashicorp/hcl/v2/hclsyntax" |
| "github.com/zclconf/go-cty/cty" |
| |
| "github.com/hashicorp/terraform/internal/lang/marks" |
| "github.com/hashicorp/terraform/internal/tfdiags" |
| ) |
| |
| // These severities map to the tfdiags.Severity values, plus an explicit |
| // unknown in case that enum grows without us noticing here. |
| const ( |
| DiagnosticSeverityUnknown = "unknown" |
| DiagnosticSeverityError = "error" |
| DiagnosticSeverityWarning = "warning" |
| ) |
| |
| // Diagnostic represents any tfdiags.Diagnostic value. The simplest form has |
| // just a severity, single line summary, and optional detail. If there is more |
| // information about the source of the diagnostic, this is represented in the |
| // range field. |
| type Diagnostic struct { |
| Severity string `json:"severity"` |
| Summary string `json:"summary"` |
| Detail string `json:"detail"` |
| Address string `json:"address,omitempty"` |
| Range *DiagnosticRange `json:"range,omitempty"` |
| Snippet *DiagnosticSnippet `json:"snippet,omitempty"` |
| } |
| |
| // Pos represents a position in the source code. |
| type Pos struct { |
| // Line is a one-based count for the line in the indicated file. |
| Line int `json:"line"` |
| |
| // Column is a one-based count of Unicode characters from the start of the line. |
| Column int `json:"column"` |
| |
| // Byte is a zero-based offset into the indicated file. |
| Byte int `json:"byte"` |
| } |
| |
| // DiagnosticRange represents the filename and position of the diagnostic |
| // subject. This defines the range of the source to be highlighted in the |
| // output. Note that the snippet may include additional surrounding source code |
| // if the diagnostic has a context range. |
| // |
| // The Start position is inclusive, and the End position is exclusive. Exact |
| // positions are intended for highlighting for human interpretation only and |
| // are subject to change. |
| type DiagnosticRange struct { |
| Filename string `json:"filename"` |
| Start Pos `json:"start"` |
| End Pos `json:"end"` |
| } |
| |
| // DiagnosticSnippet represents source code information about the diagnostic. |
| // It is possible for a diagnostic to have a source (and therefore a range) but |
| // no source code can be found. In this case, the range field will be present and |
| // the snippet field will not. |
| type DiagnosticSnippet struct { |
| // Context is derived from HCL's hcled.ContextString output. This gives a |
| // high-level summary of the root context of the diagnostic: for example, |
| // the resource block in which an expression causes an error. |
| Context *string `json:"context"` |
| |
| // Code is a possibly-multi-line string of Terraform configuration, which |
| // includes both the diagnostic source and any relevant context as defined |
| // by the diagnostic. |
| Code string `json:"code"` |
| |
| // StartLine is the line number in the source file for the first line of |
| // the snippet code block. This is not necessarily the same as the value of |
| // Range.Start.Line, as it is possible to have zero or more lines of |
| // context source code before the diagnostic range starts. |
| StartLine int `json:"start_line"` |
| |
| // HighlightStartOffset is the character offset into Code at which the |
| // diagnostic source range starts, which ought to be highlighted as such by |
| // the consumer of this data. |
| HighlightStartOffset int `json:"highlight_start_offset"` |
| |
| // HighlightEndOffset is the character offset into Code at which the |
| // diagnostic source range ends. |
| HighlightEndOffset int `json:"highlight_end_offset"` |
| |
| // Values is a sorted slice of expression values which may be useful in |
| // understanding the source of an error in a complex expression. |
| Values []DiagnosticExpressionValue `json:"values"` |
| |
| // FunctionCall is information about a function call whose failure is |
| // being reported by this diagnostic, if any. |
| FunctionCall *DiagnosticFunctionCall `json:"function_call,omitempty"` |
| |
| // TestAssertionExpr is information derived from a diagnostic that is caused |
| // by a failed run assertion. This field is only populated when the assertion |
| // is a binary expression, i.e `a operand b``. |
| TestAssertionExpr *DiagnosticTestBinaryExpr `json:"test_assertion_expr,omitempty"` |
| } |
| |
| // DiagnosticExpressionValue represents an HCL traversal string (e.g. |
| // "var.foo") and a statement about its value while the expression was |
| // evaluated (e.g. "is a string", "will be known only after apply"). These are |
| // intended to help the consumer diagnose why an expression caused a diagnostic |
| // to be emitted. |
| type DiagnosticExpressionValue struct { |
| Traversal string `json:"traversal"` |
| Statement string `json:"statement"` |
| } |
| |
| // DiagnosticFunctionCall represents a function call whose information is |
| // being included as part of a diagnostic snippet. |
| type DiagnosticFunctionCall struct { |
| // CalledAs is the full name that was used to call this function, |
| // potentially including namespace prefixes if the function does not belong |
| // to the default function namespace. |
| CalledAs string `json:"called_as"` |
| |
| // Signature is a description of the signature of the function that was |
| // called, if any. Might be omitted if we're reporting that a call failed |
| // because the given function name isn't known, for example. |
| Signature *Function `json:"signature,omitempty"` |
| } |
| |
| // DiagnosticTestBinaryExpr represents a failed test assertion diagnostic |
| // caused by a binary expression. It includes the left-hand side (LHS) and |
| // right-hand side (RHS) values of the binary expression, as well as a warning |
| // message if there is a potential issue with the values being compared. |
| type DiagnosticTestBinaryExpr struct { |
| LHS string `json:"lhs"` |
| RHS string `json:"rhs"` |
| Warning string `json:"warning"` |
| ShowVerbose bool `json:"show_verbose"` |
| } |
| |
| // NewDiagnostic takes a tfdiags.Diagnostic and a map of configuration sources, |
| // and returns a Diagnostic struct. |
| func NewDiagnostic(diag tfdiags.Diagnostic, sources map[string][]byte) *Diagnostic { |
| var sev string |
| switch diag.Severity() { |
| case tfdiags.Error: |
| sev = DiagnosticSeverityError |
| case tfdiags.Warning: |
| sev = DiagnosticSeverityWarning |
| default: |
| sev = DiagnosticSeverityUnknown |
| } |
| |
| desc := diag.Description() |
| |
| diagnostic := &Diagnostic{ |
| Severity: sev, |
| Summary: desc.Summary, |
| Detail: desc.Detail, |
| Address: desc.Address, |
| } |
| |
| sourceRefs := diag.Source() |
| if sourceRefs.Subject != nil { |
| // We'll borrow HCL's range implementation here, because it has some |
| // handy features to help us produce a nice source code snippet. |
| highlightRange := sourceRefs.Subject.ToHCL() |
| |
| // Some diagnostic sources fail to set the end of the subject range. |
| if highlightRange.End == (hcl.Pos{}) { |
| highlightRange.End = highlightRange.Start |
| } |
| |
| snippetRange := highlightRange |
| if sourceRefs.Context != nil { |
| snippetRange = sourceRefs.Context.ToHCL() |
| } |
| |
| // Make sure the snippet includes the highlight. This should be true |
| // for any reasonable diagnostic, but we'll make sure. |
| snippetRange = hcl.RangeOver(snippetRange, highlightRange) |
| |
| // Empty ranges result in odd diagnostic output, so extend the end to |
| // ensure there's at least one byte in the snippet or highlight. |
| if snippetRange.Empty() { |
| snippetRange.End.Byte++ |
| snippetRange.End.Column++ |
| } |
| if highlightRange.Empty() { |
| highlightRange.End.Byte++ |
| highlightRange.End.Column++ |
| } |
| |
| diagnostic.Range = &DiagnosticRange{ |
| Filename: highlightRange.Filename, |
| Start: Pos{ |
| Line: highlightRange.Start.Line, |
| Column: highlightRange.Start.Column, |
| Byte: highlightRange.Start.Byte, |
| }, |
| End: Pos{ |
| Line: highlightRange.End.Line, |
| Column: highlightRange.End.Column, |
| Byte: highlightRange.End.Byte, |
| }, |
| } |
| |
| var src []byte |
| if sources != nil { |
| src = sources[highlightRange.Filename] |
| } |
| |
| // If we have a source file for the diagnostic, we can emit a code |
| // snippet. |
| if src != nil { |
| diagnostic.Snippet = &DiagnosticSnippet{ |
| StartLine: snippetRange.Start.Line, |
| |
| // Ensure that the default Values struct is an empty array, as this |
| // makes consuming the JSON structure easier in most languages. |
| Values: []DiagnosticExpressionValue{}, |
| } |
| |
| file, offset := parseRange(src, highlightRange) |
| |
| // Some diagnostics may have a useful top-level context to add to |
| // the code snippet output. |
| contextStr := hcled.ContextString(file, offset-1) |
| if contextStr != "" { |
| diagnostic.Snippet.Context = &contextStr |
| } |
| |
| // Build the string of the code snippet, tracking at which byte of |
| // the file the snippet starts. |
| var codeStartByte int |
| sc := hcl.NewRangeScanner(src, highlightRange.Filename, bufio.ScanLines) |
| var code strings.Builder |
| for sc.Scan() { |
| lineRange := sc.Range() |
| if lineRange.Overlaps(snippetRange) { |
| if codeStartByte == 0 && code.Len() == 0 { |
| codeStartByte = lineRange.Start.Byte |
| } |
| code.Write(lineRange.SliceBytes(src)) |
| code.WriteRune('\n') |
| } |
| } |
| codeStr := strings.TrimSuffix(code.String(), "\n") |
| diagnostic.Snippet.Code = codeStr |
| |
| // Calculate the start and end byte of the highlight range relative |
| // to the code snippet string. |
| start := highlightRange.Start.Byte - codeStartByte |
| end := start + (highlightRange.End.Byte - highlightRange.Start.Byte) |
| |
| // We can end up with some quirky results here in edge cases like |
| // when a source range starts or ends at a newline character, |
| // so we'll cap the results at the bounds of the highlight range |
| // so that consumers of this data don't need to contend with |
| // out-of-bounds errors themselves. |
| if start < 0 { |
| start = 0 |
| } else if start > len(codeStr) { |
| start = len(codeStr) |
| } |
| if end < 0 { |
| end = 0 |
| } else if end > len(codeStr) { |
| end = len(codeStr) |
| } |
| |
| diagnostic.Snippet.HighlightStartOffset = start |
| diagnostic.Snippet.HighlightEndOffset = end |
| |
| if fromExpr := diag.FromExpr(); fromExpr != nil { |
| // We may also be able to generate information about the dynamic |
| // values of relevant variables at the point of evaluation, then. |
| // This is particularly useful for expressions that get evaluated |
| // multiple times with different values, such as blocks using |
| // "count" and "for_each", or within "for" expressions. |
| expr := fromExpr.Expression |
| ctx := fromExpr.EvalContext |
| vars := expr.Variables() |
| values := make([]DiagnosticExpressionValue, 0, len(vars)) |
| seen := make(map[string]struct{}, len(vars)) |
| includeUnknown := tfdiags.DiagnosticCausedByUnknown(diag) |
| includeEphemeral := tfdiags.DiagnosticCausedByEphemeral(diag) |
| includeSensitive := tfdiags.DiagnosticCausedBySensitive(diag) |
| testDiag := tfdiags.ExtraInfo[tfdiags.DiagnosticExtraCausedByTestFailure](diag) |
| Traversals: |
| for _, traversal := range vars { |
| for len(traversal) > 1 { |
| val, diags := traversal.TraverseAbs(ctx) |
| if diags.HasErrors() { |
| // Skip anything that generates errors, since we probably |
| // already have the same error in our diagnostics set |
| // already. |
| traversal = traversal[:len(traversal)-1] |
| continue |
| } |
| |
| traversalStr := tfdiags.TraversalStr(traversal) |
| if _, exists := seen[traversalStr]; exists { |
| continue Traversals // don't show duplicates when the same variable is referenced multiple times |
| } |
| value := DiagnosticExpressionValue{ |
| Traversal: traversalStr, |
| } |
| |
| // If the diagnostic is caused by a failed run assertion, |
| // we'll redact sensitive and ephemeral values within traversals, but format |
| // the values in a more human-readable way than the general case. |
| // If the value is unknown, we'll leave it to the general case to handle. |
| if testDiag != nil && val.IsKnown() { |
| valBuf, err := tfdiags.FormatValueStr(val) |
| if err != nil { |
| panic(err) |
| } |
| value.Statement = fmt.Sprintf("is %s", valBuf) |
| values = append(values, value) |
| seen[traversalStr] = struct{}{} |
| continue Traversals |
| } |
| |
| // We'll skip any value that has a mark that we don't |
| // know how to handle, because in that case we can't |
| // know what that mark is intended to represent and so |
| // must be conservative. |
| _, valMarks := val.Unmark() |
| for mark := range valMarks { |
| switch mark { |
| case marks.Sensitive, marks.Ephemeral: |
| // These are handled below |
| continue |
| default: |
| // All other marks are unhandled, so we'll |
| // skip this traversal entirely. |
| continue Traversals |
| } |
| } |
| switch { |
| case val.HasMark(marks.Sensitive) && val.HasMark(marks.Ephemeral): |
| // We only mention the combination of sensitive and ephemeral |
| // values if the diagnostic we're rendering is explicitly |
| // marked as being caused by sensitive and ephemeral values, |
| // because otherwise readers tend to be misled into thinking the error |
| // is caused by the sensitive value even when it isn't. |
| if !includeSensitive || !includeEphemeral { |
| continue Traversals |
| } |
| |
| value.Statement = "has an ephemeral, sensitive value" |
| case val.HasMark(marks.Sensitive): |
| // We only mention a sensitive value if the diagnostic |
| // we're rendering is explicitly marked as being |
| // caused by sensitive values, because otherwise |
| // readers tend to be misled into thinking the error |
| // is caused by the sensitive value even when it isn't. |
| if !includeSensitive { |
| continue Traversals |
| } |
| // Even when we do mention one, we keep it vague |
| // in order to minimize the chance of giving away |
| // whatever was sensitive about it. |
| value.Statement = "has a sensitive value" |
| case val.HasMark(marks.Ephemeral): |
| if !includeEphemeral { |
| continue Traversals |
| } |
| value.Statement = "has an ephemeral value" |
| case !val.IsKnown(): |
| // We'll avoid saying anything about unknown or |
| // "known after apply" unless the diagnostic is |
| // explicitly marked as being caused by unknown |
| // values, because otherwise readers tend to be |
| // misled into thinking the error is caused by the |
| // unknown value even when it isn't. |
| if ty := val.Type(); ty != cty.DynamicPseudoType { |
| if includeUnknown { |
| switch { |
| case ty.IsCollectionType(): |
| valRng := val.Range() |
| minLen := valRng.LengthLowerBound() |
| maxLen := valRng.LengthUpperBound() |
| const maxLimit = 1024 // (upper limit is just an arbitrary value to avoid showing distracting large numbers in the UI) |
| switch { |
| case minLen == maxLen: |
| value.Statement = fmt.Sprintf("is a %s of length %d, known only after apply", ty.FriendlyName(), minLen) |
| case minLen != 0 && maxLen <= maxLimit: |
| value.Statement = fmt.Sprintf("is a %s with between %d and %d elements, known only after apply", ty.FriendlyName(), minLen, maxLen) |
| case minLen != 0: |
| value.Statement = fmt.Sprintf("is a %s with at least %d elements, known only after apply", ty.FriendlyName(), minLen) |
| case maxLen <= maxLimit: |
| value.Statement = fmt.Sprintf("is a %s with up to %d elements, known only after apply", ty.FriendlyName(), maxLen) |
| default: |
| value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) |
| } |
| default: |
| value.Statement = fmt.Sprintf("is a %s, known only after apply", ty.FriendlyName()) |
| } |
| } else { |
| value.Statement = fmt.Sprintf("is a %s", ty.FriendlyName()) |
| } |
| } else { |
| if !includeUnknown { |
| continue Traversals |
| } |
| value.Statement = "will be known only after apply" |
| } |
| default: |
| value.Statement = fmt.Sprintf("is %s", tfdiags.CompactValueStr(val)) |
| } |
| values = append(values, value) |
| seen[traversalStr] = struct{}{} |
| } |
| } |
| sort.Slice(values, func(i, j int) bool { |
| return values[i].Traversal < values[j].Traversal |
| }) |
| |
| diagnostic.Snippet.Values = values |
| |
| if callInfo := tfdiags.ExtraInfo[hclsyntax.FunctionCallDiagExtra](diag); callInfo != nil && callInfo.CalledFunctionName() != "" { |
| calledAs := callInfo.CalledFunctionName() |
| baseName := calledAs |
| if idx := strings.LastIndex(baseName, "::"); idx >= 0 { |
| baseName = baseName[idx+2:] |
| } |
| callInfo := &DiagnosticFunctionCall{ |
| CalledAs: calledAs, |
| } |
| if f, ok := ctx.Functions[calledAs]; ok { |
| callInfo.Signature = DescribeFunction(baseName, f) |
| } |
| diagnostic.Snippet.FunctionCall = callInfo |
| } |
| |
| if testDiag != nil { |
| // If the test assertion is a binary expression, we'll include the human-readable |
| // formatted LHS and RHS values in the diagnostic snippet. |
| diagnostic.Snippet.TestAssertionExpr = formatRunBinaryDiag(ctx, fromExpr.Expression) |
| if diagnostic.Snippet.TestAssertionExpr != nil { |
| diagnostic.Snippet.TestAssertionExpr.ShowVerbose = testDiag.IsTestVerboseMode() |
| } |
| } |
| |
| } |
| |
| } |
| } |
| |
| return diagnostic |
| } |
| |
| // formatRunBinaryDiag formats the binary expression that caused the failed run diagnostic. |
| // The LHS and RHS values are formatted in a more human-readable way, redacting |
| // sensitive and ephemeral values only for the exact values that hold the mark(s). |
| func formatRunBinaryDiag(ctx *hcl.EvalContext, expr hcl.Expression) *DiagnosticTestBinaryExpr { |
| bExpr, ok := expr.(*hclsyntax.BinaryOpExpr) |
| if !ok { |
| return nil |
| } |
| // The expression has already been evaluated and failed, so we can ignore the diags here. |
| lhs, _ := bExpr.LHS.Value(ctx) |
| rhs, _ := bExpr.RHS.Value(ctx) |
| |
| lhsStr, err := tfdiags.FormatValueStr(lhs) |
| if err != nil { |
| panic(err) |
| } |
| rhsStr, err := tfdiags.FormatValueStr(rhs) |
| if err != nil { |
| panic(err) |
| } |
| |
| ret := &DiagnosticTestBinaryExpr{LHS: lhsStr, RHS: rhsStr} |
| |
| // The types do not match. We don't diff them. |
| if !lhs.Type().Equals(rhs.Type()) { |
| ret.Warning = "LHS and RHS values are of different types" |
| } |
| return ret |
| } |
| |
| func parseRange(src []byte, rng hcl.Range) (*hcl.File, int) { |
| filename := rng.Filename |
| offset := rng.Start.Byte |
| |
| // We need to re-parse here to get a *hcl.File we can interrogate. This |
| // is not awesome since we presumably already parsed the file earlier too, |
| // but this re-parsing is architecturally simpler than retaining all of |
| // the hcl.File objects and we only do this in the case of an error anyway |
| // so the overhead here is not a big problem. |
| parser := hclparse.NewParser() |
| var file *hcl.File |
| |
| // Ignore diagnostics here as there is nothing we can do with them. |
| if strings.HasSuffix(filename, ".json") { |
| file, _ = parser.ParseJSON(src, filename) |
| } else { |
| file, _ = parser.ParseHCL(src, filename) |
| } |
| |
| return file, offset |
| } |