@@ -5,10 +5,10 @@ import (
5
5
"context"
6
6
"encoding/json"
7
7
"fmt"
8
- "net"
9
8
"os"
10
9
"path/filepath"
11
10
"runtime"
11
+ "strconv"
12
12
"strings"
13
13
"testing"
14
14
"time"
@@ -21,58 +21,102 @@ import (
21
21
"github.com/stretchr/testify/require"
22
22
)
23
23
24
+ // TestIntegration performs an integration test against an ephemeral Coder deployment.
25
+ // For each directory containing a `main.tf` under `/integration`, performs the following:
26
+ // - Pushes the template to a temporary Coder instance running in Docker
27
+ // - Creates a workspace from the template. Templates here are expected to create a
28
+ // local_file resource containing JSON that can be marshalled as a map[string]string
29
+ // - Fetches the content of the JSON file created and compares it against the expected output.
30
+ //
31
+ // NOTE: all interfaces to this Coder deployment are performed without github.com/coder/coder/v2/codersdk
32
+ // in order to avoid a circular dependency.
24
33
func TestIntegration (t * testing.T ) {
25
34
if os .Getenv ("TF_ACC" ) == "1" {
26
35
t .Skip ("Skipping integration tests during tf acceptance tests" )
27
36
}
28
- ctx , cancel := context .WithTimeout (context .Background (), time .Minute )
37
+
38
+ timeoutStr := os .Getenv ("TIMEOUT_MINS" )
39
+ if timeoutStr == "" {
40
+ timeoutStr = "10"
41
+ }
42
+ timeoutMins , err := strconv .Atoi (timeoutStr )
43
+ require .NoError (t , err , "invalid value specified for timeout" )
44
+ ctx , cancel := context .WithTimeout (context .Background (), time .Duration (timeoutMins )* time .Minute )
29
45
t .Cleanup (cancel )
30
46
47
+ // Given: we have an existing Coder deployment running locally
31
48
ctrID := setup (ctx , t )
32
49
33
- t .Run ("test-data-sources" , func (t * testing.T ) {
34
- // Import an example template
35
- _ , rc := execContainer (ctx , t , ctrID , `coder templates push test-data-source --directory /src/integration/test-data-source/ --var output_path=/tmp/test-data-sources.json --yes` )
36
- require .Equal (t , 0 , rc )
37
- // Create a workspace
38
- _ , rc = execContainer (ctx , t , ctrID , `coder create test-data-source -t test-data-source --yes` )
39
- require .Equal (t , 0 , rc )
40
- // Fetch the output created by the template
41
- out , rc := execContainer (ctx , t , ctrID , `cat /tmp/test-data-sources.json` )
42
- require .Equal (t , 0 , rc )
43
- m := make (map [string ]string )
44
- require .NoError (t , json .NewDecoder (strings .NewReader (out )).Decode (& m ))
45
- assert .Equal (t , runtime .GOARCH , m ["provisioner.arch" ])
46
- assert .NotEmpty (t , m ["provisioner.id" ])
47
- assert .Equal (t , runtime .GOOS , m ["provisioner.os" ])
48
- assert .NotEmpty (t , m ["workspace.access_port" ])
49
- assert .NotEmpty (t , m ["workspace.access_url" ])
50
- assert .NotEmpty (t , m ["workspace.id" ])
51
- assert .Equal (t , "test-data-source" , m ["workspace.name" ])
52
- assert .Equal (t , "testing" , m ["workspace.owner" ])
53
- assert .
Equal (
t ,
"[email protected] " ,
m [
"workspace.owner_email" ])
54
- assert .NotEmpty (t , m ["workspace.owner_id" ])
55
- assert .Equal (t , "default" , m ["workspace.owner_name" ])
56
- // assert.NotEmpty(t, m["workspace.owner_oidc_access_token"]) // TODO: need a test OIDC integration
57
- assert .NotEmpty (t , m ["workspace.owner_session_token" ])
58
- assert .Equal (t , "1" , m ["workspace.start_count" ])
59
- assert .NotEmpty (t , m ["workspace.template_id" ])
60
- assert .Equal (t , "test-data-source" , m ["workspace.template_name" ])
61
- assert .NotEmpty (t , m ["workspace.template_version" ])
62
- assert .Equal (t , "start" , m ["workspace.transition" ])
63
- assert .
Equal (
t ,
"[email protected] " ,
m [
"workspace_owner.email" ])
64
- assert .Equal (t , "default" , m ["workspace_owner.full_name" ])
65
- assert .NotEmpty (t , m ["workspace_owner.groups" ])
66
- assert .NotEmpty (t , m ["workspace_owner.id" ])
67
- assert .Equal (t , "testing" , m ["workspace_owner.name" ])
68
- // assert.NotEmpty(t, m["workspace_owner.oidc_access_token"]) // TODO: test OIDC integration
69
- assert .NotEmpty (t , m ["workspace_owner.session_token" ])
70
- assert .NotEmpty (t , m ["workspace_owner.ssh_private_key" ])
71
- assert .NotEmpty (t , m ["workspace_owner.ssh_public_key" ])
72
- })
50
+ for _ , tt := range []struct {
51
+ // Name of the folder under `integration/` containing a test template
52
+ templateName string
53
+ // map of string to regex to be passed to assertOutput()
54
+ expectedOutput map [string ]string
55
+ }{
56
+ {
57
+ templateName : "test-data-source" ,
58
+ expectedOutput : map [string ]string {
59
+ "provisioner.arch" : runtime .GOARCH ,
60
+ "provisioner.id" : `[a-zA-Z0-9-]+` ,
61
+ "provisioner.os" : runtime .GOOS ,
62
+ "workspace.access_port" : `\d+` ,
63
+ "workspace.access_url" : `https?://\D+:\d+` ,
64
+ "workspace.id" : `[a-zA-z0-9-]+` ,
65
+ "workspace.name" : `test-data-source` ,
66
+ "workspace.owner" : `testing` ,
67
+ "workspace.owner_email" : `testing@coder\.com` ,
68
+ "workspace.owner_groups" : `\[\]` ,
69
+ "workspace.owner_id" : `[a-zA-Z0-9]+` ,
70
+ "workspace.owner_name" : `default` ,
71
+ "workspace.owner_oidc_access_token" : `^$` , // TODO: need a test OIDC integration
72
+ "workspace.owner_session_token" : `[a-zA-Z0-9-]+` ,
73
+ "workspace.start_count" : `1` ,
74
+ "workspace.template_id" : `[a-zA-Z0-9-]+` ,
75
+ "workspace.template_name" : `test-data-source` ,
76
+ "workspace.template_version" : `.+` ,
77
+ "workspace.transition" : `start` ,
78
+ "workspace_owner.email" : `testing@coder\.com` ,
79
+ "workspace_owner.full_name" : `default` ,
80
+ "workspace_owner.groups" : `\[\]` ,
81
+ "workspace_owner.id" : `[a-zA-Z0-9-]+` ,
82
+ "workspace_owner.name" : `testing` ,
83
+ "workspace_owner.oidc_access_token" : `^$` , // TODO: test OIDC integration
84
+ "workspace_owner.session_token" : `.+` ,
85
+ "workspace_owner.ssh_private_key" : `^$` , // Depends on coder/coder#13366
86
+ "workspace_owner.ssh_public_key" : `^$` , // Depends on coder/coder#13366
87
+ },
88
+ },
89
+ } {
90
+ t .Run (tt .templateName , func (t * testing.T ) {
91
+ // Import named template
92
+ _ , rc := execContainer (ctx , t , ctrID , fmt .Sprintf (`coder templates push %s --directory /src/integration/%s --var output_path=/tmp/%s.json --yes` , tt .templateName , tt .templateName , tt .templateName ))
93
+ require .Equal (t , 0 , rc )
94
+ // Create a workspace
95
+ _ , rc = execContainer (ctx , t , ctrID , fmt .Sprintf (`coder create %s -t %s --yes` , tt .templateName , tt .templateName ))
96
+ require .Equal (t , 0 , rc )
97
+ // Fetch the output created by the template
98
+ out , rc := execContainer (ctx , t , ctrID , fmt .Sprintf (`cat /tmp/%s.json` , tt .templateName ))
99
+ require .Equal (t , 0 , rc )
100
+ actual := make (map [string ]string )
101
+ require .NoError (t , json .NewDecoder (strings .NewReader (out )).Decode (& actual ))
102
+ assertOutput (t , tt .expectedOutput , actual )
103
+ })
104
+ }
73
105
}
74
106
75
107
func setup (ctx context.Context , t * testing.T ) string {
108
+ var (
109
+ // For this test to work, we pass in a custom terraformrc to use
110
+ // the locally built version of the provider.
111
+ testTerraformrc = `provider_installation {
112
+ dev_overrides {
113
+ "coder/coder" = "/src"
114
+ }
115
+ direct{}
116
+ }`
117
+ localURL = "http://localhost:3000"
118
+ )
119
+
76
120
coderImg := os .Getenv ("CODER_IMAGE" )
77
121
if coderImg == "" {
78
122
coderImg = "ghcr.io/coder/coder"
@@ -92,45 +136,39 @@ func setup(ctx context.Context, t *testing.T) string {
92
136
t .Fatalf ("not found: %q - please build the provider first" , binPath )
93
137
}
94
138
tmpDir := t .TempDir ()
139
+ // Create a terraformrc to point to our freshly built provider!
95
140
tfrcPath := filepath .Join (tmpDir , "integration.tfrc" )
96
- tfrc := `provider_installation {
97
- dev_overrides {
98
- "coder/coder" = "/src"
99
- }
100
- direct{}
101
- }`
102
- err = os .WriteFile (tfrcPath , []byte (tfrc ), 0o644 )
141
+ err = os .WriteFile (tfrcPath , []byte (testTerraformrc ), 0o644 )
103
142
require .NoError (t , err , "write terraformrc to tempdir" )
104
143
105
144
cli , err := client .NewClientWithOpts (client .FromEnv , client .WithAPIVersionNegotiation ())
106
145
require .NoError (t , err , "init docker client" )
107
146
108
- p := randomPort (t )
109
- t .Logf ("random port is %d\n " , p )
110
-
111
147
srcPath , err := filepath .Abs (".." )
112
148
require .NoError (t , err , "get abs path of parent" )
113
149
t .Logf ("src path is %s\n " , srcPath )
114
150
151
+ // Stand up a temporary Coder instance
115
152
ctr , err := cli .ContainerCreate (ctx , & container.Config {
116
153
Image : coderImg + ":" + coderVersion ,
117
154
Env : []string {
118
- fmt . Sprintf ( "CODER_ACCESS_URL=http://host.docker.internal:%d" , p ),
119
- "CODER_IN_MEMORY=true" ,
120
- "CODER_TELEMETRY_ENABLE=false" ,
121
- "TF_CLI_CONFIG_FILE=/tmp/integration.tfrc" ,
155
+ "CODER_ACCESS_URL=" + localURL , // Set explicitly to avoid creating try.coder.app URLs.
156
+ "CODER_IN_MEMORY=true" , // We don't necessarily care about real persistence here.
157
+ "CODER_TELEMETRY_ENABLE=false" , // Avoid creating noise.
158
+ "TF_CLI_CONFIG_FILE=/tmp/integration.tfrc" , // Our custom tfrc from above.
122
159
},
123
160
Labels : map [string ]string {},
124
161
}, & container.HostConfig {
125
162
Binds : []string {
126
- tfrcPath + ":/tmp/integration.tfrc" ,
127
- srcPath + ":/src" ,
163
+ tfrcPath + ":/tmp/integration.tfrc" , // Custom tfrc from above.
164
+ srcPath + ":/src" , // Bind-mount in the repo with the built binary and templates.
128
165
},
129
166
}, nil , nil , "" )
130
167
require .NoError (t , err , "create test deployment" )
131
168
132
169
t .Logf ("created container %s\n " , ctr .ID )
133
- t .Cleanup (func () {
170
+ t .Cleanup (func () { // Make sure we clean up after ourselves.
171
+ // TODO: also have this execute if you Ctrl+C!
134
172
t .Logf ("stopping container %s\n " , ctr .ID )
135
173
_ = cli .ContainerRemove (ctx , ctr .ID , container.RemoveOptions {
136
174
Force : true ,
@@ -141,24 +179,39 @@ func setup(ctx context.Context, t *testing.T) string {
141
179
require .NoError (t , err , "start container" )
142
180
t .Logf ("started container %s\n " , ctr .ID )
143
181
144
- // Perform first time setup
182
+ // nolint:gosec // For testing only.
145
183
var (
146
184
147
185
testPassword = "InsecurePassw0rd!"
148
186
testUsername = "testing"
149
187
)
150
188
151
189
// Wait for container to come up
152
- execContainer (ctx , t , ctr .ID , `until curl -s --fail http://localhost:3000/healthz; do sleep 1; done` )
190
+ waitLoop:
191
+ for {
192
+ select {
193
+ case <- ctx .Done ():
194
+ t .Fatalf ("coder failed to become ready in time" )
195
+ default :
196
+ _ , rc := execContainer (ctx , t , ctr .ID , fmt .Sprintf (`curl -s --fail %s/api/v2/buildinfo` , localURL ))
197
+ if rc == 0 {
198
+ break waitLoop
199
+ }
200
+ t .Logf ("not ready yet..." )
201
+ <- time .After (time .Second )
202
+ }
203
+ }
153
204
// Perform first time setup
154
- execContainer (ctx , t , ctr .ID , fmt .Sprintf (`coder login http://localhost:3000 --first-user-email=%q --first-user-password=%q --first-user-trial=false --first-user-username=%q` , testEmail , testPassword , testUsername ))
205
+ _ , rc := execContainer (ctx , t , ctr .ID , fmt .Sprintf (`coder login %s --first-user-email=%q --first-user-password=%q --first-user-trial=false --first-user-username=%q` , localURL , testEmail , testPassword , testUsername ))
206
+ require .Equal (t , 0 , rc , "failed to perform first-time setup" )
155
207
return ctr .ID
156
208
}
157
209
158
210
// execContainer executes the given command in the given container and returns
159
- // the output.
211
+ // the output and the exit code of the command .
160
212
func execContainer (ctx context.Context , t * testing.T , containerID , command string ) (string , int ) {
161
213
t .Helper ()
214
+ t .Logf ("exec container cmd: %q" , command )
162
215
cli , err := client .NewClientWithOpts (client .FromEnv , client .WithAPIVersionNegotiation ())
163
216
require .NoError (t , err , "connect to docker" )
164
217
defer cli .Close ()
@@ -182,13 +235,18 @@ func execContainer(ctx context.Context, t *testing.T, containerID, command strin
182
235
return out , execResp .ExitCode
183
236
}
184
237
185
- func randomPort (t * testing.T ) int {
186
- l , err := net .Listen ("tcp" , "127.0.0.1:0" )
187
- require .NoError (t , err , "listen on random port" )
188
- defer func () {
189
- _ = l .Close ()
190
- }()
191
- tcpAddr , valid := l .Addr ().(* net.TCPAddr )
192
- require .True (t , valid , "net.Listen did not return a *net.TCPAddr" )
193
- return tcpAddr .Port
238
+ // assertOutput asserts that, for each key-value pair in expected:
239
+ // 1. actual[k] as a regex matches expected[k], and
240
+ // 2. the set of keys of expected are not a subset of actual.
241
+ func assertOutput (t * testing.T , expected , actual map [string ]string ) {
242
+ t .Helper ()
243
+
244
+ for expectedKey , expectedValExpr := range expected {
245
+ actualVal := actual [expectedKey ]
246
+ assert .Regexp (t , expectedValExpr , actualVal )
247
+ }
248
+ for actualKey := range actual {
249
+ _ , ok := expected [actualKey ]
250
+ assert .True (t , ok , "unexpected field in actual %q=%q" , actualKey , actual [actualKey ])
251
+ }
194
252
}
0 commit comments