3
3
package terraform_test
4
4
5
5
import (
6
+ "bytes"
6
7
"context"
8
+ "crypto/sha256"
9
+ "encoding/hex"
7
10
"encoding/json"
8
11
"errors"
9
12
"fmt"
10
13
"net"
11
14
"net/http"
12
15
"os"
16
+ "os/exec"
13
17
"path/filepath"
14
18
"sort"
15
19
"strings"
@@ -29,10 +33,11 @@ import (
29
33
)
30
34
31
35
type provisionerServeOptions struct {
32
- binaryPath string
33
- exitTimeout time.Duration
34
- workDir string
35
- logger *slog.Logger
36
+ binaryPath string
37
+ cliConfigPath string
38
+ exitTimeout time.Duration
39
+ workDir string
40
+ logger *slog.Logger
36
41
}
37
42
38
43
func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Context, proto.DRPCProvisionerClient) {
@@ -66,9 +71,10 @@ func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Cont
66
71
Logger: *opts.logger,
67
72
WorkDirectory: opts.workDir,
68
73
},
69
- BinaryPath: opts.binaryPath,
70
- CachePath: cachePath,
71
- ExitTimeout: opts.exitTimeout,
74
+ BinaryPath: opts.binaryPath,
75
+ CachePath: cachePath,
76
+ ExitTimeout: opts.exitTimeout,
77
+ CliConfigPath: opts.cliConfigPath,
72
78
})
73
79
}()
74
80
api := proto.NewDRPCProvisionerClient(client)
@@ -85,6 +91,149 @@ func configure(ctx context.Context, t *testing.T, client proto.DRPCProvisionerCl
85
91
return sess
86
92
}
87
93
94
+ func hashTemplateFilesAndTestName(t *testing.T, testName string, templateFiles map[string]string) string {
95
+ t.Helper()
96
+
97
+ sortedFileNames := make([]string, 0, len(templateFiles))
98
+ for fileName := range templateFiles {
99
+ sortedFileNames = append(sortedFileNames, fileName)
100
+ }
101
+ sort.Strings(sortedFileNames)
102
+
103
+ hasher := sha256.New()
104
+ for _, fileName := range sortedFileNames {
105
+ file := templateFiles[fileName]
106
+ _, err := hasher.Write([]byte(fileName))
107
+ require.NoError(t, err)
108
+ _, err = hasher.Write([]byte(file))
109
+ require.NoError(t, err)
110
+ }
111
+ _, err := hasher.Write([]byte(testName))
112
+ require.NoError(t, err)
113
+ return hex.EncodeToString(hasher.Sum(nil))
114
+ }
115
+
116
+ const (
117
+ terraformConfigFileName = "terraform.rc"
118
+ cacheProvidersDirName = "providers"
119
+ cacheTemplateFilesDirName = "files"
120
+ )
121
+
122
+ // Writes a Terraform CLI config file (`terraform.rc`) in `dir` to enforce using the local provider mirror.
123
+ // This blocks network access for providers, forcing Terraform to use only what's cached in `dir`.
124
+ // Returns the path to the generated config file.
125
+ func writeCliConfig(t *testing.T, dir string) string {
126
+ t.Helper()
127
+
128
+ cliConfigPath := filepath.Join(dir, terraformConfigFileName)
129
+ require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o700))
130
+
131
+ content := fmt.Sprintf(`
132
+ provider_installation {
133
+ filesystem_mirror {
134
+ path = "%s"
135
+ include = ["*/*"]
136
+ }
137
+ direct {
138
+ exclude = ["*/*"]
139
+ }
140
+ }
141
+ `, filepath.Join(dir, cacheProvidersDirName))
142
+ require.NoError(t, os.WriteFile(cliConfigPath, []byte(content), 0o600))
143
+ return cliConfigPath
144
+ }
145
+
146
+ func runCmd(t *testing.T, dir string, args ...string) {
147
+ t.Helper()
148
+
149
+ stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
150
+ cmd := exec.Command(args[0], args[1:]...) //#nosec
151
+ cmd.Dir = dir
152
+ cmd.Stdout = stdout
153
+ cmd.Stderr = stderr
154
+ if err := cmd.Run(); err != nil {
155
+ t.Fatalf("failed to run %s: %s\nstdout: %s\nstderr: %s", strings.Join(args, " "), err, stdout.String(), stderr.String())
156
+ }
157
+ }
158
+
159
+ // Ensures Terraform providers are downloaded and cached locally in a unique directory for this test.
160
+ // Uses `terraform init` then `mirror` to populate the cache if needed.
161
+ // Returns the cache directory path.
162
+ func downloadProviders(t *testing.T, rootDir string, templateFiles map[string]string) string {
163
+ t.Helper()
164
+
165
+ // Each test gets a unique cache dir based on its name and template files.
166
+ // This ensures that tests can download providers in parallel and that they
167
+ // will redownload providers if the template files change.
168
+ hash := hashTemplateFilesAndTestName(t, t.Name(), templateFiles)
169
+ dir := filepath.Join(rootDir, hash[:12])
170
+ if _, err := os.Stat(dir); err == nil {
171
+ t.Logf("%s: using cached terraform providers", t.Name())
172
+ return dir
173
+ }
174
+ filesDir := filepath.Join(dir, cacheTemplateFilesDirName)
175
+ defer func() {
176
+ // The files dir will contain a copy of terraform providers generated
177
+ // by the terraform init command. We don't want to persist them since
178
+ // we already have a registry mirror in the providers dir.
179
+ if err := os.RemoveAll(filesDir); err != nil {
180
+ t.Logf("failed to remove files dir %s: %s", filesDir, err)
181
+ }
182
+ if !t.Failed() {
183
+ return
184
+ }
185
+ if err := os.RemoveAll(dir); err != nil {
186
+ t.Logf("failed to remove dir %s: %s", dir, err)
187
+ }
188
+ }()
189
+
190
+ require.NoError(t, os.MkdirAll(filesDir, 0o700))
191
+
192
+ for fileName, file := range templateFiles {
193
+ filePath := filepath.Join(filesDir, fileName)
194
+ if _, err := os.Stat(filePath); os.IsNotExist(err) {
195
+ require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o700))
196
+ require.NoError(t, os.WriteFile(filePath, []byte(file), 0o600))
197
+ }
198
+ }
199
+
200
+ providersDir := filepath.Join(dir, cacheProvidersDirName)
201
+ require.NoError(t, os.MkdirAll(providersDir, 0o700))
202
+
203
+ // We need to run init because if a test uses modules in its template,
204
+ // the mirror command will fail without it.
205
+ runCmd(t, filesDir, "terraform", "init")
206
+ // Now, mirror the providers into `providersDir`. We use this explicit mirror
207
+ // instead of relying only on the standard Terraform plugin cache.
208
+ //
209
+ // Why? Because this mirror, when used with the CLI config from `writeCliConfig`,
210
+ // prevents Terraform from hitting the network registry during `plan`. This cuts
211
+ // down on network calls, making CI tests less flaky.
212
+ //
213
+ // In contrast, the standard cache *still* contacts the registry for metadata
214
+ // during `init`, even if the plugins are already cached locally - see link below.
215
+ //
216
+ // Ref: https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache
217
+ // > When a plugin cache directory is enabled, the terraform init command will
218
+ // > still use the configured or implied installation methods to obtain metadata
219
+ // > about which plugins are available
220
+ runCmd(t, filesDir, "terraform", "providers", "mirror", providersDir)
221
+
222
+ return dir
223
+ }
224
+
225
+ // Caches providers locally and generates a Terraform CLI config to use *only* that cache.
226
+ // This setup prevents network access for providers during `terraform init`, improving reliability
227
+ // in subsequent test runs.
228
+ // Returns the path to the generated CLI config file.
229
+ func cacheProviders(t *testing.T, templateFiles map[string]string, rootDir string) string {
230
+ t.Helper()
231
+
232
+ providersParentDir := downloadProviders(t, rootDir, templateFiles)
233
+ cliConfigPath := writeCliConfig(t, providersParentDir)
234
+ return cliConfigPath
235
+ }
236
+
88
237
func readProvisionLog(t *testing.T, response proto.DRPCProvisioner_SessionClient) string {
89
238
var logBuf strings.Builder
90
239
for {
@@ -352,6 +501,8 @@ func TestProvision(t *testing.T) {
352
501
Apply bool
353
502
// Some tests may need to be skipped until the relevant provider version is released.
354
503
SkipReason string
504
+ // If SkipCacheProviders is true, then skip caching the terraform providers for this test.
505
+ SkipCacheProviders bool
355
506
}{
356
507
{
357
508
Name: "missing-variable",
@@ -422,16 +573,18 @@ func TestProvision(t *testing.T) {
422
573
Files: map[string]string{
423
574
"main.tf": `a`,
424
575
},
425
- ErrorContains: "initialize terraform",
426
- ExpectLogContains: "Argument or block definition required",
576
+ ErrorContains: "initialize terraform",
577
+ ExpectLogContains: "Argument or block definition required",
578
+ SkipCacheProviders: true,
427
579
},
428
580
{
429
581
Name: "bad-syntax-2",
430
582
Files: map[string]string{
431
583
"main.tf": `;asdf;`,
432
584
},
433
- ErrorContains: "initialize terraform",
434
- ExpectLogContains: `The ";" character is not valid.`,
585
+ ErrorContains: "initialize terraform",
586
+ ExpectLogContains: `The ";" character is not valid.`,
587
+ SkipCacheProviders: true,
435
588
},
436
589
{
437
590
Name: "destroy-no-state",
@@ -809,7 +962,17 @@ func TestProvision(t *testing.T) {
809
962
t.Skip(testCase.SkipReason)
810
963
}
811
964
812
- ctx, api := setupProvisioner(t, nil)
965
+ cliConfigPath := ""
966
+ if !testCase.SkipCacheProviders {
967
+ cliConfigPath = cacheProviders(
968
+ t,
969
+ testCase.Files,
970
+ filepath.Join(testutil.PersistentCacheDir(t), "terraform_provision_test"),
971
+ )
972
+ }
973
+ ctx, api := setupProvisioner(t, &provisionerServeOptions{
974
+ cliConfigPath: cliConfigPath,
975
+ })
813
976
sess := configure(ctx, t, api, &proto.Config{
814
977
TemplateSourceArchive: testutil.CreateTar(t, testCase.Files),
815
978
})
0 commit comments