/
snapshots.go
259 lines (225 loc) 路 8.34 KB
/
snapshots.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
// Copyright 2016-2023, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bytes"
"fmt"
"io"
iofs "io/fs"
"os"
"path/filepath"
"strings"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
)
func copyDirectory(fs iofs.FS, src string, dst string, edits []compiledReplacement) error {
return iofs.WalkDir(fs, src, func(path string, d iofs.DirEntry, err error) error {
if err != nil {
return err
}
relativePath, err := filepath.Rel(src, path)
contract.AssertNoErrorf(err, "path %s should be relative to %s", path, src)
srcPath := filepath.Join(src, relativePath)
dstPath := filepath.Join(dst, relativePath)
contract.Assertf(srcPath == path, "srcPath %s should be equal to path %s", srcPath, path)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o700)
}
srcFile, err := fs.Open(srcPath)
if err != nil {
return fmt.Errorf("open file %s: %w", srcPath, err)
}
defer srcFile.Close()
dstFile, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("create file %s: %w", dstPath, err)
}
defer dstFile.Close()
editsToApply := []compiledReplacement{}
for _, replace := range edits {
if replace.Path.MatchString(relativePath) {
editsToApply = append(editsToApply, replace)
}
}
if len(editsToApply) > 0 {
// Apply edits to the file
data, err := io.ReadAll(srcFile)
if err != nil {
return fmt.Errorf("read file %s: %w", srcPath, err)
}
src := string(data)
for len(src) > 0 {
// Find a line of text
i := strings.Index(src, "\n")
text := src
if i == -1 {
// Last line, process text (set to src above) then exit the loop
src = ""
} else {
// Extract the line of text _including_ the newline and remove it from src
text = src[:i+1]
src = src[i+1:]
}
for _, edit := range editsToApply {
text = edit.Pattern.ReplaceAllString(text, edit.Replacement)
}
_, err = dstFile.WriteString(text)
if err != nil {
return fmt.Errorf("write file %s: %w", dstPath, err)
}
}
} else {
// Can just do a straight copy
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("copy file %s->%s: %w", srcPath, dstPath, err)
}
}
return nil
})
}
// compareDirectories compares two directories, returning a list of validation failures where files had
// different contents, or we only present in on of the directories. If allowNewFiles is true then it's ok to
// have extra files in the actual directory, we use this for checking building SDKs doesn't mutate any files,
// but doing so might add new build files (which would then normally be .gitignored).
func compareDirectories(actualDir, expectedDir string, allowNewFiles bool) ([]string, error) {
// Validate files, we need to walk twice to get this correct because we need to check all expected
// files are present, but also that no unexpected files are present.
var validations []string
// Check that every file in expected is also in actual with the same content
err := filepath.WalkDir(expectedDir, func(path string, d iofs.DirEntry, err error) error {
if err != nil {
return err
}
// No need to check directories, just recurse into them
if d.IsDir() {
return nil
}
relativePath, err := filepath.Rel(expectedDir, path)
contract.AssertNoErrorf(err, "path %s should be relative to %s", path, expectedDir)
// Check that the file is present in the expected directory and has the same contents
expectedContents, err := os.ReadFile(filepath.Join(expectedDir, relativePath))
if err != nil {
return fmt.Errorf("read expected file: %w", err)
}
actualPath := filepath.Join(actualDir, relativePath)
actualContents, err := os.ReadFile(actualPath)
// An error here is a test failure rather than an error, add this to the validation list
if err != nil {
validations = append(validations, fmt.Sprintf("expected file %s could not be read", relativePath))
// Move on to the next file
return nil
}
if !bytes.Equal(actualContents, expectedContents) {
edits := myers.ComputeEdits(
span.URIFromPath("expected"), string(expectedContents), string(actualContents),
)
diff := gotextdiff.ToUnified("expected", "actual", string(expectedContents), edits)
validations = append(validations, fmt.Sprintf(
"expected file %s does not match actual file:\n\n%s", relativePath, diff),
)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk expected dir: %w", err)
}
// Now walk the actual directory and check every file found is present in the expected directory, i.e.
// there aren't any new files that aren't expected. We've already done contents checking so we just need
// existence checks.
if !allowNewFiles {
err = filepath.WalkDir(actualDir, func(path string, d iofs.DirEntry, err error) error {
if err != nil {
return err
}
// No need to check directories
if d.IsDir() {
return nil
}
relativePath, err := filepath.Rel(actualDir, path)
contract.AssertNoErrorf(err, "path %s should be relative to %s", path, actualDir)
// Just need to see if this file exists in expected, if it doesn't return add a validation failure.
_, err = os.Stat(filepath.Join(expectedDir, relativePath))
if err == nil {
// File exists in expected, we've already done a contents check so just move on to
// the next file.
return nil
}
// Check if this was a NotFound error in which case add a validation failure, else return the error
if os.IsNotExist(err) {
validations = append(validations, fmt.Sprintf("file %s is not expected", relativePath))
return nil
}
return err
})
if err != nil {
return nil, fmt.Errorf("walk actual dir: %w", err)
}
}
return validations, nil
}
// editSnapshot applies the given edits to the snapshot files in the snapshot directory, it either returns the
// given path directly, or creates a new temporary folder and returns the path to that with the edits applied
// in that folder
//
// It _might_ be worth changing this approach to instead fold this edit logic directly into the comparison
// itself rather than having to copy all the files in a snapshot. i.e. an one the file mutation of each file
// as part of the compare rather than edit and write all then doing a direct comparison.
func editSnapshot(snapshotDirectory string, edits []compiledReplacement) (string, error) {
// If we have any edits to apply then we need to copy to a temporary directory and apply the edits there.
result := snapshotDirectory
if len(edits) > 0 {
var err error
result, err = os.MkdirTemp("", "pulumi-test-language")
if err != nil {
return "", fmt.Errorf("create temp dir: %w", err)
}
err = copyDirectory(os.DirFS(snapshotDirectory), ".", result, edits)
if err != nil {
return "", fmt.Errorf("copy source dir: %w", err)
}
}
return result, nil
}
// Do a snapshot check of the generated source code against the snapshot code. If PULUMI_ACCEPT is true just
// write the new files instead.
func doSnapshot(
disableSnapshotWriting bool,
sourceDirectory, snapshotDirectory string,
) ([]string, error) {
if !disableSnapshotWriting && cmdutil.IsTruthy(os.Getenv("PULUMI_ACCEPT")) {
// Write files
err := os.RemoveAll(snapshotDirectory)
if err != nil {
return nil, fmt.Errorf("remove snapshot dir: %w", err)
}
err = os.MkdirAll(snapshotDirectory, 0o755)
if err != nil {
return nil, fmt.Errorf("create snapshot dir: %w", err)
}
err = copyDirectory(os.DirFS(sourceDirectory), ".", snapshotDirectory, nil)
if err != nil {
return nil, fmt.Errorf("copy snapshot dir: %w", err)
}
return nil, nil
}
validations, err := compareDirectories(sourceDirectory, snapshotDirectory, false /* allowNewFiles */)
if err != nil {
return nil, err
}
return validations, nil
}