Skip to content

Commit ac8d4b2

Browse files
committed
address PR comments
1 parent 5c8485d commit ac8d4b2

File tree

2 files changed

+132
-72
lines changed

2 files changed

+132
-72
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ gen:
88

99
build: terraform-provider-coder
1010

11+
# Builds the provider. Note that as coder/coder is based on
12+
# alpine, we need to disable cgo.
1113
terraform-provider-coder: provider/*.go main.go
1214
CGO_ENABLED=0 go build .
1315

integration/integration_test.go

Lines changed: 130 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8-
"net"
98
"os"
109
"path/filepath"
1110
"runtime"
11+
"strconv"
1212
"strings"
1313
"testing"
1414
"time"
@@ -21,58 +21,102 @@ import (
2121
"github.com/stretchr/testify/require"
2222
)
2323

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.
2433
func TestIntegration(t *testing.T) {
2534
if os.Getenv("TF_ACC") == "1" {
2635
t.Skip("Skipping integration tests during tf acceptance tests")
2736
}
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)
2945
t.Cleanup(cancel)
3046

47+
// Given: we have an existing Coder deployment running locally
3148
ctrID := setup(ctx, t)
3249

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+
}
73105
}
74106

75107
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+
76120
coderImg := os.Getenv("CODER_IMAGE")
77121
if coderImg == "" {
78122
coderImg = "ghcr.io/coder/coder"
@@ -92,45 +136,39 @@ func setup(ctx context.Context, t *testing.T) string {
92136
t.Fatalf("not found: %q - please build the provider first", binPath)
93137
}
94138
tmpDir := t.TempDir()
139+
// Create a terraformrc to point to our freshly built provider!
95140
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)
103142
require.NoError(t, err, "write terraformrc to tempdir")
104143

105144
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
106145
require.NoError(t, err, "init docker client")
107146

108-
p := randomPort(t)
109-
t.Logf("random port is %d\n", p)
110-
111147
srcPath, err := filepath.Abs("..")
112148
require.NoError(t, err, "get abs path of parent")
113149
t.Logf("src path is %s\n", srcPath)
114150

151+
// Stand up a temporary Coder instance
115152
ctr, err := cli.ContainerCreate(ctx, &container.Config{
116153
Image: coderImg + ":" + coderVersion,
117154
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.
122159
},
123160
Labels: map[string]string{},
124161
}, &container.HostConfig{
125162
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.
128165
},
129166
}, nil, nil, "")
130167
require.NoError(t, err, "create test deployment")
131168

132169
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!
134172
t.Logf("stopping container %s\n", ctr.ID)
135173
_ = cli.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{
136174
Force: true,
@@ -141,24 +179,39 @@ func setup(ctx context.Context, t *testing.T) string {
141179
require.NoError(t, err, "start container")
142180
t.Logf("started container %s\n", ctr.ID)
143181

144-
// Perform first time setup
182+
// nolint:gosec // For testing only.
145183
var (
146184
testEmail = "[email protected]"
147185
testPassword = "InsecurePassw0rd!"
148186
testUsername = "testing"
149187
)
150188

151189
// 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+
}
153204
// 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")
155207
return ctr.ID
156208
}
157209

158210
// execContainer executes the given command in the given container and returns
159-
// the output.
211+
// the output and the exit code of the command.
160212
func execContainer(ctx context.Context, t *testing.T, containerID, command string) (string, int) {
161213
t.Helper()
214+
t.Logf("exec container cmd: %q", command)
162215
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
163216
require.NoError(t, err, "connect to docker")
164217
defer cli.Close()
@@ -182,13 +235,18 @@ func execContainer(ctx context.Context, t *testing.T, containerID, command strin
182235
return out, execResp.ExitCode
183236
}
184237

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+
}
194252
}

0 commit comments

Comments
 (0)