Repository navigation
Expand file tree
/
Copy pathaudit.go
More file actions
226 lines (210 loc) · 8.88 KB
/
Copy pathaudit.go
File metadata and controls
226 lines (210 loc) · 8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
package main
import (
"fmt"
"os"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
)
// auditReport is the -audit-file document: each configured version value the selected files
// declare, beside the value the config expects.
type auditReport struct {
SchemaVersion int `json:"schema_version"`
Terraform []terraformAuditEntry `json:"terraform"`
Providers []providerAuditEntry `json:"providers"`
Modules []moduleAuditEntry `json:"modules"`
}
type terraformAuditEntry struct {
File string `json:"file"`
Actual *string `json:"actual"`
Expected string `json:"expected"`
Matches bool `json:"matches"`
}
type providerAuditEntry struct {
File string `json:"file"`
Name string `json:"name"`
Actual *string `json:"actual"`
Expected string `json:"expected"`
Matches bool `json:"matches"`
}
type moduleAuditEntry struct {
File string `json:"file"`
Name string `json:"name"`
Source string `json:"source"`
Actual *string `json:"actual"`
Expected string `json:"expected"`
Matches bool `json:"matches"`
Skip *moduleAuditSkip `json:"skip"`
}
// moduleAuditSkip names the first config filter that stops the updater changing a block.
type moduleAuditSkip struct {
Filter string `json:"filter"`
Values []string `json:"values"`
}
// buildAudit reads each selected file once and records every version value the config targets
// in it. It never writes files and stops at the first file it cannot read or parse.
func buildAudit(files []string, config *Config) (*auditReport, error) {
audit := &auditReport{SchemaVersion: 1, Terraform: []terraformAuditEntry{}, Providers: []providerAuditEntry{}, Modules: []moduleAuditEntry{}}
for _, filename := range files {
file, err := parseAuditedFile(filename)
if err != nil {
return nil, err
}
for _, block := range file.Body().Blocks() {
switch block.Type() {
case "terraform":
audit.recordTerraformBlock(filename, block, config)
case "module":
audit.recordModuleBlock(filename, block, config.Modules)
}
}
}
return audit, nil
}
func parseAuditedFile(filename string) (*hclwrite.File, error) {
src, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("Error auditing %s: failed to read file: %w", filename, err) //nolint:staticcheck // User-facing CLI diagnostic.
}
file, diags := hclwrite.ParseConfig(src, filename, hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
return nil, fmt.Errorf("Error auditing %s: failed to parse HCL: %s", filename, diags.Error()) //nolint:staticcheck // User-facing CLI diagnostic.
}
return file, nil
}
// recordTerraformBlock records the block's required_version when the config sets one, and each
// required_providers declaration of a configured provider.
func (audit *auditReport) recordTerraformBlock(filename string, block *hclwrite.Block, config *Config) {
if config.TerraformVersion != "" {
actual, matches := auditedAttribute(block.Body().GetAttribute("required_version"), config.TerraformVersion)
audit.Terraform = append(audit.Terraform, terraformAuditEntry{File: filename, Actual: actual, Expected: config.TerraformVersion, Matches: matches})
}
for _, nestedBlock := range block.Body().Blocks() {
if nestedBlock.Type() != "required_providers" {
continue
}
for _, provider := range config.Providers {
audit.recordRequiredProvider(filename, nestedBlock, provider)
}
}
}
// recordRequiredProvider records a provider declared in block syntax, object syntax or both,
// because the updater reads either.
func (audit *auditReport) recordRequiredProvider(filename string, requiredProviders *hclwrite.Block, provider ProviderUpdate) {
for _, providerBlock := range requiredProviders.Body().Blocks() {
if providerBlock.Type() == provider.Name {
actual, matches := auditedAttribute(providerBlock.Body().GetAttribute("version"), provider.Version)
audit.Providers = append(audit.Providers, providerAuditEntry{File: filename, Name: provider.Name, Actual: actual, Expected: provider.Version, Matches: matches})
}
}
if objExpr, expression, ok, _ := providerAttributeObject(requiredProviders, provider.Name); ok {
actual, matches := auditedObjectVersion(objExpr, expression, provider.Version)
audit.Providers = append(audit.Providers, providerAuditEntry{File: filename, Name: provider.Name, Actual: actual, Expected: provider.Version, Matches: matches})
}
}
// recordModuleBlock records the block once for each config entry with an equal source. A run
// applies those entries in order, so each entry is judged against the value the entries before it
// leave: the block's own version until one of them would rewrite it. -audit-file rejects -force-add,
// so no entry gives a block without a version one.
func (audit *auditReport) recordModuleBlock(filename string, block *hclwrite.Block, updates []ModuleUpdate) {
source, ok := moduleSourceValue(block)
if !ok {
return
}
name := moduleBlockName(block)
versionAttribute := block.Body().GetAttribute("version")
var rewrittenVersion *string
// Index rather than copy: gocritic's hugeParam rejects passing a ModuleUpdate by value.
for i := range updates {
update := &updates[i]
if update.Source != source {
continue
}
actual, matches := auditedAttribute(versionAttribute, update.Version)
if rewrittenVersion != nil {
actual, matches = rewrittenVersion, *rewrittenVersion == update.Version
}
skip := moduleAuditSkipFor(name, source, actual, update)
audit.Modules = append(audit.Modules, moduleAuditEntry{
File: filename, Name: name, Source: source, Actual: actual, Expected: update.Version, Matches: matches, Skip: skip,
})
if actual != nil && !matches && skip == nil {
version := update.Version
rewrittenVersion = &version
}
}
}
// moduleAuditSkipFor applies the updater's precedence: a local source, then ignore_modules, then
// the version filters, which a block without a version never meets. ignore_modules matches on the
// branch-resolved patterns but reports the entries as the config writes them.
func moduleAuditSkipFor(name, source string, actual *string, update *ModuleUpdate) *moduleAuditSkip {
switch {
case isLocalModule(source):
return &moduleAuditSkip{Filter: "local_source", Values: []string{}}
case shouldIgnoreModule(name, update.resolvedIgnoreModules):
return &moduleAuditSkip{Filter: "ignore_modules", Values: update.IgnoreModules}
case actual == nil:
return nil
}
switch moduleVersionFilter(*actual, update.IgnoreVersions, update.From) {
case "ignore_versions":
return &moduleAuditSkip{Filter: "ignore_versions", Values: update.IgnoreVersions}
case "from":
return &moduleAuditSkip{Filter: "from", Values: update.From}
}
return nil
}
// auditedAttribute returns an attribute's value as written, without its quotes, and whether it
// already evaluates to expected, which is the updater's no-op test. A missing attribute has no
// value.
func auditedAttribute(attr *hclwrite.Attribute, expected string) (actual *string, matches bool) {
if attr == nil {
return nil, false
}
value := attributeStringValue(attr)
return &value, attributeHasStringValue(attr, expected)
}
// runAuditMode writes the audit of files against the config. It validates the destination
// before reading Terraform files, never writes them, and replaces an existing audit only once
// every file has been audited.
func runAuditMode(files []string, flags *cliFlags) error {
config, err := loadResolvedConfig(flags.configFile, flags.branch)
if err != nil {
return err
}
inputFiles := commandInputFiles(files, flags.configFile)
prepared, err := prepareJSONOutput(auditOutput, flags.auditFile, inputFiles)
if err != nil {
return err
}
audit, err := buildAudit(files, config)
if err != nil {
if discardErr := prepared.discard(); discardErr != nil {
err = fmt.Errorf("%w; failed to discard prepared audit: %v", err, discardErr)
}
return err
}
if err := prepared.publish(audit); err != nil {
return fmt.Errorf("Error writing audit: %w", err) //nolint:staticcheck // User-facing CLI diagnostic.
}
fmt.Printf("✓ Wrote audit to %s\n", quote(flags.auditFile, flags.output))
return nil
}
// auditedObjectVersion reads the version item of an object-syntax provider declaration.
func auditedObjectVersion(objExpr *hclsyntax.ObjectConsExpr, expression []byte, expected string) (actual *string, matches bool) {
for _, item := range objExpr.Items {
keyName, ok := providerObjectItemKey(item)
if !ok || keyName != "version" {
continue
}
valueRange := item.ValueExpr.Range()
if valueRange.Start.Byte < 0 || valueRange.End.Byte > len(expression) || valueRange.Start.Byte > valueRange.End.Byte {
continue
}
valueExpression := expression[valueRange.Start.Byte:valueRange.End.Byte]
value := trimQuotes(strings.TrimSpace(string(valueExpression)))
return &value, expressionHasStringValue(valueExpression, expected)
}
return nil, false
}