-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin_setup_test.go
More file actions
318 lines (277 loc) · 8.94 KB
/
Copy pathplugin_setup_test.go
File metadata and controls
318 lines (277 loc) · 8.94 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
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
package config_test
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/infracost/config"
)
const (
// defaultPluginBaseURL is the CLI's default plugin release host (see the CLI's
// pkg/plugins config.BaseURL / INFRACOST_CLI_PLUGIN_BASE_URL). The e2e tests download the
// same "latest" plugin binaries the CLI installs, using the same URL scheme, so they always
// exercise the plugins that ship to users.
defaultPluginBaseURL = "https://releases.infracost.io"
pluginCacheDir = ".test-plugins"
pluginBinarySize = 1 << 30 // 1 GB cap for archive entries
)
var requiredPlugins = []string{
"infracost-parser-terraform",
"infracost-parser-terragrunt",
"infracost-parser-cloudformation",
"infracost-parser-ciscostacks",
}
// pluginDir is the directory the test plugins are extracted into. It is
// populated by TestMain before any tests run.
var pluginDir string
func TestMain(m *testing.M) {
dir, err := installTestPlugins()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to install test plugins: %v\n", err)
os.Exit(1)
}
pluginDir = dir
os.Exit(m.Run())
}
// testConfigGenerationWithPlugins is the same as testConfigGeneration but
// injects WithPluginDir(pluginDir) so config.Generate runs against the
// downloaded plugin binaries.
func testConfigGenerationWithPlugins(t *testing.T, dir string, wantProjects []*config.Project, opts ...config.GenerationOption) {
opts = append(opts, config.WithPluginDir(pluginDir))
testConfigGeneration(t, dir, wantProjects, opts...)
}
// testConfigGenerationWithTemplateAndPlugins is the same as
// testConfigGenerationWithTemplate but injects WithPluginDir(pluginDir).
func testConfigGenerationWithTemplateAndPlugins(t *testing.T, dir, template string, wantProjects []*config.Project, opts ...config.GenerationOption) {
opts = append(opts, config.WithPluginDir(pluginDir))
testConfigGenerationWithTemplate(t, dir, template, wantProjects, opts...)
}
// installTestPlugins downloads the latest release of each required plugin for the current OS/arch
// (skipping any already present), extracts the binary, and returns the directory the binaries live
// in. It downloads exactly the way the CLI does - from {baseURL}/{plugin}/{goos}/{goarch}/latest -
// so the e2e tests always run against the plugins that ship to users.
func installTestPlugins() (string, error) {
dir, err := pluginInstallDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("create plugin dir: %w", err)
}
baseURL := os.Getenv("INFRACOST_CLI_PLUGIN_BASE_URL")
if baseURL == "" {
baseURL = defaultPluginBaseURL
}
var wg sync.WaitGroup
errs := make([]error, len(requiredPlugins))
for i, name := range requiredPlugins {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
errs[i] = ensurePlugin(dir, baseURL, name)
}(i, name)
}
wg.Wait()
if err := errors.Join(errs...); err != nil {
return "", err
}
return dir, nil
}
func pluginInstallDir() (string, error) {
// Anchor on this source file's directory so the cache lives in the repo
// root regardless of which package the `go test` was invoked from.
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", errors.New("could not determine caller path")
}
return filepath.Join(filepath.Dir(file), pluginCacheDir), nil
}
// pluginArchiveName returns the release archive filename for the current OS, matching the CLI's
// plugin release layout (data.tar.gz everywhere except Windows).
func pluginArchiveName() string {
if runtime.GOOS == "windows" {
return "data.zip"
}
return "data.tar.gz"
}
// ensurePlugin downloads the latest release of the named plugin the same way the CLI installs
// plugins: the artifact and its checksum live at {baseURL}/{plugin}/{goos}/{goarch}/latest/. It
// caches by the published archive checksum, so it only re-downloads once "latest" moves on.
func ensurePlugin(destDir, baseURL, name string) error {
archiveName := pluginArchiveName()
artifactURL := fmt.Sprintf("%s/%s/%s/%s/latest/%s", strings.TrimRight(baseURL, "/"), name, runtime.GOOS, runtime.GOARCH, archiveName)
expectedSHA, err := fetchChecksum(artifactURL + ".sha256")
if err != nil {
return fmt.Errorf("fetch checksum for %s: %w", name, err)
}
binaryName := name
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath := filepath.Join(destDir, binaryName)
// Cache by storing the installed archive SHA alongside the binary so we can skip
// re-downloading while "latest" hasn't moved on.
shaPath := binaryPath + ".sha"
if existing, err := os.ReadFile(shaPath); err == nil && string(existing) == expectedSHA {
if _, err := os.Stat(binaryPath); err == nil {
return nil
}
}
archivePath, err := downloadAndVerify(artifactURL, expectedSHA, name)
if err != nil {
return fmt.Errorf("download %s: %w", name, err)
}
defer func() { _ = os.Remove(archivePath) }()
switch {
case strings.HasSuffix(archiveName, ".tar.gz"):
if err := extractTarGzBinary(archivePath, binaryPath, name); err != nil {
return fmt.Errorf("extract %s: %w", name, err)
}
case strings.HasSuffix(archiveName, ".zip"):
if err := extractZipBinary(archivePath, binaryPath, binaryName); err != nil {
return fmt.Errorf("extract %s: %w", name, err)
}
default:
return fmt.Errorf("unsupported archive format %s", archiveName)
}
if err := os.Chmod(binaryPath, 0o755); err != nil { //nolint:gosec // G302: plugin must be executable
return fmt.Errorf("chmod %s: %w", name, err)
}
if err := os.WriteFile(shaPath, []byte(expectedSHA), 0o644); err != nil { //nolint:gosec // G306: trivial cache marker
return fmt.Errorf("write sha marker for %s: %w", name, err)
}
return nil
}
// fetchChecksum fetches a plugin's ".sha256" file and returns the hex digest (the first
// whitespace-separated field, matching the CLI's checksum parsing).
func fetchChecksum(rawURL string) (string, error) {
resp, err := http.Get(rawURL) //nolint:gosec // G107: URL derived from the plugin base URL
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum fetch from %s returned %s", rawURL, resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
if err != nil {
return "", err
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return "", fmt.Errorf("empty checksum response from %s", rawURL)
}
return fields[0], nil
}
func downloadAndVerify(rawURL, expectedSHA, name string) (string, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/octet-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download %s returned %s", rawURL, resp.Status)
}
tmp, err := os.CreateTemp("", name+"-*")
if err != nil {
return "", err
}
tmpPath := tmp.Name()
hasher := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, hasher), resp.Body); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpPath)
return "", err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
if expectedSHA != "" {
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expectedSHA {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("sha mismatch for %s: expected %s, got %s", name, expectedSHA, actual)
}
}
return tmpPath, nil
}
func extractTarGzBinary(archivePath, destPath, expectedName string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return fmt.Errorf("entry %q not found", expectedName)
}
if err != nil {
return err
}
if filepath.Base(hdr.Name) != expectedName {
continue
}
out, err := os.OpenFile(destPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, io.LimitReader(tr, pluginBinarySize)); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
}
func extractZipBinary(archivePath, destPath, expectedName string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer func() { _ = r.Close() }()
for _, zf := range r.File {
if filepath.Base(zf.Name) != expectedName {
continue
}
zr, err := zf.Open()
if err != nil {
return err
}
out, err := os.OpenFile(destPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
_ = zr.Close()
return err
}
_, copyErr := io.Copy(out, io.LimitReader(zr, pluginBinarySize))
_ = zr.Close()
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
return fmt.Errorf("entry %q not found in zip", expectedName)
}