-
Notifications
You must be signed in to change notification settings - Fork 523
/
parser.go
343 lines (287 loc) · 9.34 KB
/
parser.go
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
package template
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"text/template"
jsoniter "github.com/json-iterator/go"
"github.com/pkg/errors"
pathToRegexp "github.com/soongo/path-to-regexp"
"gopkg.in/yaml.v2"
)
var (
defaultInfracostTmplName = "infracost.yml.tmpl"
)
type DetectedProject struct {
Name string
Path string
TerraformVarFiles []string
Env string
}
type DetectedRooModule struct {
Path string
Projects []DetectedProject
}
// Variables hold the global variables that are passed into any template that the Parser evaluates.
type Variables struct {
Branch string
BaseBranch string
DetectedProjects []DetectedProject
DetectedRootModules []DetectedRooModule
}
// Parser is the representation of an initialized Infracost template parser.
// It exposes custom template functions to the user which can act on Parser.repoDir
// or in isolation.
type Parser struct {
repoDir string
template *template.Template
variables Variables
}
// NewParser returns a safely initialized Infracost template parser, this builds the underlying template with the
// Parser functions and sets the underlying default template name. Default variables can be passed to the parser which
// will be passed to the template on execution.
func NewParser(repoDir string, variables Variables) *Parser {
absRepoDir, _ := filepath.Abs(repoDir)
p := Parser{repoDir: absRepoDir, variables: variables}
t := template.New(defaultInfracostTmplName).Funcs(template.FuncMap{
"base": p.base,
"stem": p.stem,
"ext": p.ext,
"startsWith": p.startsWith,
"endsWith": p.endsWith,
"contains": p.contains,
"pathExists": p.pathExists,
"matchPaths": p.matchPaths,
"list": p.list,
"relPath": p.relPath,
"isDir": p.isDir,
"readFile": p.readFile,
"parseJson": p.parseJson,
"parseYaml": p.parseYaml,
})
p.template = t
return &p
}
// CompileFromFile writes an Infracost config to io.Writer wr using the provided template path.
func (p *Parser) CompileFromFile(templatePath string, wr io.Writer) error {
baseTemplate := p.template
// if the template name is not `infracost.yml.tmpl` we need to change the template base
// name to match the template name, otherwise executing the template will fail. This is
// done by calling .New which copies over all the configuration from the base template
// to a new one.
base := filepath.Base(templatePath)
if base != defaultInfracostTmplName {
baseTemplate = baseTemplate.New(base)
}
t, err := baseTemplate.ParseFiles(templatePath)
if err != nil {
return fmt.Errorf("could not parse template path: %s err: %w", templatePath, err)
}
err = t.Execute(wr, p.variables)
if err != nil {
return fmt.Errorf("could not execute template: %s err: %w", templatePath, err)
}
return nil
}
// Compile writes an Infracost config to io.Writer wr using the provided template string.
func (p *Parser) Compile(template string, wr io.Writer) error {
// rewrite escaped values to their literal values so that we get a nice output.
template = strings.ReplaceAll(template, `\n`, "\n")
template = strings.ReplaceAll(template, `\r`, "\r")
template = strings.ReplaceAll(template, `\t`, "\t")
t, err := p.template.Parse(template)
if err != nil {
return fmt.Errorf("could not parse template: %q err: %w", template, err)
}
err = t.Execute(wr, p.variables)
if err != nil {
return fmt.Errorf("could not execute template: %q err: %w", template, err)
}
return nil
}
// base returns the last element of path.
func (p *Parser) base(path string) string {
return filepath.Base(path)
}
// base returns the last element of path with the extension removed.
func (p *Parser) stem(path string) string {
return strings.TrimSuffix(p.base(path), p.ext(path))
}
// ext returns the file name extension used by path.
func (p *Parser) ext(path string) string {
return filepath.Ext(path)
}
// startsWith tests whether the string s begins with prefix.
func (p *Parser) startsWith(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
}
// endsWith tests whether the string s ends with suffix.
func (p *Parser) endsWith(s, suffix string) bool {
return strings.HasSuffix(s, suffix)
}
// contains reports whether substr is within s.
func (p *Parser) contains(s, substr string) bool {
return strings.Contains(s, substr)
}
// pathExists reports whether path is a subpath within base.
func (p *Parser) pathExists(base, path string) bool {
if !filepath.IsAbs(base) {
base = filepath.Join(p.repoDir, base)
}
// Ensure the base path is within the repo directory
baseAbs, _ := filepath.Abs(base)
repoDirAbs, _ := filepath.Abs(p.repoDir)
// Add a file separator at the end to ensure we don't match a directory that starts with the same prefix
// e.g. `/path/to/infracost` shouldn't match `/path/to/infra`.
if !strings.HasPrefix(fmt.Sprintf("%s%s", baseAbs, string(filepath.Separator)), fmt.Sprintf("%s%s", repoDirAbs, string(filepath.Separator))) {
return false
}
var fileExists bool
_ = filepath.WalkDir(base, func(subpath string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(base, subpath)
if rel == path {
fileExists = true
// exit the WalkDir tree evaluation early as we've found the file we're looking for
return errors.New("file found")
}
return nil
})
return fileExists
}
// matchPaths returns a list of matches that in the project directory tree that match the pattern.
// Results are returned using a map as keys are variable.
// Each result returned also contains two special keys:
//
// _path - the full path of that the pattern matched on
// _dir - the base directory that the pattern matched on
//
// With an example tree of:
// ├── environment
// │ ├── dev
// │ │ └── terraform.tfvars
// │ └── prod
// │ └── terraform.tfvars
// ├── infracost.yml.tmpl
// └── main.tf
//
// Using a pattern of:
//
// "environment/:env/terraform.tfvars"
//
// Returned results would be:
//
// - { _path: environment/dev/terraform.tfvars, _dir: environment/dev, env: dev }
// - { _path: environment/prod/terraform.tfvars, _dir: environment/prod, env: prod }
func (p *Parser) matchPaths(pattern string) []map[interface{}]interface{} {
match := pathToRegexp.MustMatch(pattern, nil)
var matches []map[interface{}]interface{}
_ = filepath.WalkDir(p.repoDir, func(path string, d fs.DirEntry, err error) error {
rel, _ := filepath.Rel(p.repoDir, path)
res, _ := match(rel)
if res != nil {
var out map[string]interface{}
params := make(map[interface{}]interface{})
b, _ := jsoniter.Marshal(p.variables)
_ = jsoniter.Unmarshal(b, &out)
for k, v := range out {
params[k] = v
}
for k, v := range res.Params {
params[k] = v
}
params["_path"] = rel
dir := filepath.Dir(rel)
params["_dir"] = dir
matches = append(matches, params)
}
return nil
})
return matches
}
// list is a useful function for creating an arbitrary array of values which can be
// looped over in a template. For example:
//
// $my_list = list "foo" "bar"
// {{- range $my_list }}
// {{ . }}
// {{- end }}
func (p *Parser) list(v ...interface{}) []interface{} {
return v
}
// relPath returns a relative path that is lexically equivalent to targpath when
// joined to basepath with an intervening separator. If there is an error returning the
// relative path we panic so that the error is show when executing the template.
func (p *Parser) relPath(basepath string, tarpath string) string {
rel, err := filepath.Rel(basepath, tarpath)
if err != nil {
panic(err)
}
return rel
}
// isDir returns is path points to a directory.
func (p *Parser) isDir(path string) bool {
fullPath := filepath.Join(p.repoDir, path)
info, err := os.Stat(fullPath)
if err != nil {
return false
}
return info.IsDir()
}
// readFile reads the named file and returns the contents.
func (p *Parser) readFile(path string) string {
if !isSubdirectory(p.repoDir, path) {
panic(fmt.Sprintf("%q must be within the repository root %q", path, filepath.Base(p.repoDir)))
}
fullPath := filepath.Join(p.repoDir, path)
b, err := os.ReadFile(fullPath)
if err != nil {
panic(err)
}
return string(b)
}
// parseYaml decodes provided yaml contents and assigns decoded values into a
// generic out value. This can be used as a simple object in the templates.
func (p *Parser) parseYaml(contents string) map[string]interface{} {
var out map[string]interface{}
err := yaml.Unmarshal([]byte(contents), &out)
if err != nil {
panic(err)
}
return out
}
// parseJson decodes the provided json contents and assigns decoded values into a
// generic out value. This can be used as a simple object in the templates.
func (p *Parser) parseJson(contents string) map[string]interface{} {
var out map[string]interface{}
err := jsoniter.Unmarshal([]byte(contents), &out)
if err != nil {
panic(err)
}
return out
}
func isSubdirectory(base, target string) bool {
full := filepath.Join(base, target)
fileInfo, err := os.Lstat(full)
if err != nil {
return false
}
absBasePath, err := filepath.Abs(base)
if err != nil {
return false
}
absTargetPath, err := filepath.Abs(full)
if err != nil {
return false
}
relPath, err := filepath.Rel(absBasePath, absTargetPath)
if err != nil {
return false
}
return !strings.HasPrefix(relPath, "..") && fileInfo.Mode()&os.ModeSymlink == 0
}