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\n stdout: %s\n stderr: %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" ,
@@ -847,7 +1000,17 @@ func TestProvision(t *testing.T) {
847
1000
t .Skip (testCase .SkipReason )
848
1001
}
849
1002
850
- ctx , api := setupProvisioner (t , nil )
1003
+ cliConfigPath := ""
1004
+ if ! testCase .SkipCacheProviders {
1005
+ cliConfigPath = cacheProviders (
1006
+ t ,
1007
+ testCase .Files ,
1008
+ filepath .Join (testutil .PersistentCacheDir (t ), "terraform_provision_test" ),
1009
+ )
1010
+ }
1011
+ ctx , api := setupProvisioner (t , & provisionerServeOptions {
1012
+ cliConfigPath : cliConfigPath ,
1013
+ })
851
1014
sess := configure (ctx , t , api , & proto.Config {
852
1015
TemplateSourceArchive : testutil .CreateTar (t , testCase .Files ),
853
1016
})
0 commit comments