-
Notifications
You must be signed in to change notification settings - Fork 22
chore: add integration test for provider #233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
8b929aa
2379890
9180ca9
5c8485d
ac8d4b2
d3ecb18
7d6db3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,252 @@ | ||
package integration | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"encoding/json" | ||
"fmt" | ||
"os" | ||
"path/filepath" | ||
"runtime" | ||
"strconv" | ||
"strings" | ||
"testing" | ||
"time" | ||
|
||
"github.com/docker/docker/api/types" | ||
"github.com/docker/docker/api/types/container" | ||
"github.com/docker/docker/client" | ||
"github.com/docker/docker/pkg/stdcopy" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
// TestIntegration performs an integration test against an ephemeral Coder deployment. | ||
// For each directory containing a `main.tf` under `/integration`, performs the following: | ||
// - Pushes the template to a temporary Coder instance running in Docker | ||
// - Creates a workspace from the template. Templates here are expected to create a | ||
// local_file resource containing JSON that can be marshalled as a map[string]string | ||
// - Fetches the content of the JSON file created and compares it against the expected output. | ||
// | ||
// NOTE: all interfaces to this Coder deployment are performed without github.com/coder/coder/v2/codersdk | ||
// in order to avoid a circular dependency. | ||
func TestIntegration(t *testing.T) { | ||
if os.Getenv("TF_ACC") == "1" { | ||
t.Skip("Skipping integration tests during tf acceptance tests") | ||
} | ||
|
||
timeoutStr := os.Getenv("TIMEOUT_MINS") | ||
if timeoutStr == "" { | ||
timeoutStr = "10" | ||
} | ||
timeoutMins, err := strconv.Atoi(timeoutStr) | ||
require.NoError(t, err, "invalid value specified for timeout") | ||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMins)*time.Minute) | ||
t.Cleanup(cancel) | ||
|
||
// Given: we have an existing Coder deployment running locally | ||
ctrID := setup(ctx, t) | ||
|
||
for _, tt := range []struct { | ||
// Name of the folder under `integration/` containing a test template | ||
templateName string | ||
// map of string to regex to be passed to assertOutput() | ||
expectedOutput map[string]string | ||
}{ | ||
{ | ||
templateName: "test-data-source", | ||
expectedOutput: map[string]string{ | ||
"provisioner.arch": runtime.GOARCH, | ||
"provisioner.id": `[a-zA-Z0-9-]+`, | ||
"provisioner.os": runtime.GOOS, | ||
"workspace.access_port": `\d+`, | ||
"workspace.access_url": `https?://\D+:\d+`, | ||
"workspace.id": `[a-zA-z0-9-]+`, | ||
"workspace.name": `test-data-source`, | ||
"workspace.owner": `testing`, | ||
"workspace.owner_email": `testing@coder\.com`, | ||
"workspace.owner_groups": `\[\]`, | ||
"workspace.owner_id": `[a-zA-Z0-9]+`, | ||
"workspace.owner_name": `default`, | ||
"workspace.owner_oidc_access_token": `^$`, // TODO: need a test OIDC integration | ||
"workspace.owner_session_token": `[a-zA-Z0-9-]+`, | ||
"workspace.start_count": `1`, | ||
"workspace.template_id": `[a-zA-Z0-9-]+`, | ||
"workspace.template_name": `test-data-source`, | ||
"workspace.template_version": `.+`, | ||
"workspace.transition": `start`, | ||
"workspace_owner.email": `testing@coder\.com`, | ||
"workspace_owner.full_name": `default`, | ||
"workspace_owner.groups": `\[\]`, | ||
"workspace_owner.id": `[a-zA-Z0-9-]+`, | ||
"workspace_owner.name": `testing`, | ||
"workspace_owner.oidc_access_token": `^$`, // TODO: test OIDC integration | ||
"workspace_owner.session_token": `.+`, | ||
"workspace_owner.ssh_private_key": `^$`, // Depends on coder/coder#13366 | ||
"workspace_owner.ssh_public_key": `^$`, // Depends on coder/coder#13366 | ||
}, | ||
}, | ||
} { | ||
t.Run(tt.templateName, func(t *testing.T) { | ||
// Import named template | ||
_, 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)) | ||
require.Equal(t, 0, rc) | ||
// Create a workspace | ||
_, rc = execContainer(ctx, t, ctrID, fmt.Sprintf(`coder create %s -t %s --yes`, tt.templateName, tt.templateName)) | ||
require.Equal(t, 0, rc) | ||
// Fetch the output created by the template | ||
out, rc := execContainer(ctx, t, ctrID, fmt.Sprintf(`cat /tmp/%s.json`, tt.templateName)) | ||
require.Equal(t, 0, rc) | ||
actual := make(map[string]string) | ||
require.NoError(t, json.NewDecoder(strings.NewReader(out)).Decode(&actual)) | ||
assertOutput(t, tt.expectedOutput, actual) | ||
}) | ||
} | ||
} | ||
|
||
func setup(ctx context.Context, t *testing.T) string { | ||
var ( | ||
// For this test to work, we pass in a custom terraformrc to use | ||
// the locally built version of the provider. | ||
testTerraformrc = `provider_installation { | ||
dev_overrides { | ||
"coder/coder" = "/src" | ||
} | ||
direct{} | ||
}` | ||
localURL = "http://localhost:3000" | ||
) | ||
|
||
coderImg := os.Getenv("CODER_IMAGE") | ||
if coderImg == "" { | ||
coderImg = "ghcr.io/coder/coder" | ||
} | ||
|
||
coderVersion := os.Getenv("CODER_VERSION") | ||
if coderVersion == "" { | ||
coderVersion = "latest" | ||
} | ||
|
||
t.Logf("using coder image %s:%s", coderImg, coderVersion) | ||
|
||
// Ensure the binary is built | ||
binPath, err := filepath.Abs("../terraform-provider-coder") | ||
require.NoError(t, err) | ||
if _, err := os.Stat(binPath); os.IsNotExist(err) { | ||
t.Fatalf("not found: %q - please build the provider first", binPath) | ||
} | ||
tmpDir := t.TempDir() | ||
// Create a terraformrc to point to our freshly built provider! | ||
tfrcPath := filepath.Join(tmpDir, "integration.tfrc") | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
err = os.WriteFile(tfrcPath, []byte(testTerraformrc), 0o644) | ||
require.NoError(t, err, "write terraformrc to tempdir") | ||
|
||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | ||
require.NoError(t, err, "init docker client") | ||
|
||
srcPath, err := filepath.Abs("..") | ||
require.NoError(t, err, "get abs path of parent") | ||
t.Logf("src path is %s\n", srcPath) | ||
|
||
// Stand up a temporary Coder instance | ||
ctr, err := cli.ContainerCreate(ctx, &container.Config{ | ||
Image: coderImg + ":" + coderVersion, | ||
Env: []string{ | ||
"CODER_ACCESS_URL=" + localURL, // Set explicitly to avoid creating try.coder.app URLs. | ||
"CODER_IN_MEMORY=true", // We don't necessarily care about real persistence here. | ||
"CODER_TELEMETRY_ENABLE=false", // Avoid creating noise. | ||
"TF_CLI_CONFIG_FILE=/tmp/integration.tfrc", // Our custom tfrc from above. | ||
}, | ||
Labels: map[string]string{}, | ||
}, &container.HostConfig{ | ||
Binds: []string{ | ||
tfrcPath + ":/tmp/integration.tfrc", // Custom tfrc from above. | ||
srcPath + ":/src", // Bind-mount in the repo with the built binary and templates. | ||
}, | ||
}, nil, nil, "") | ||
require.NoError(t, err, "create test deployment") | ||
|
||
t.Logf("created container %s\n", ctr.ID) | ||
t.Cleanup(func() { // Make sure we clean up after ourselves. | ||
// TODO: also have this execute if you Ctrl+C! | ||
t.Logf("stopping container %s\n", ctr.ID) | ||
_ = cli.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{ | ||
Force: true, | ||
}) | ||
}) | ||
|
||
err = cli.ContainerStart(ctx, ctr.ID, container.StartOptions{}) | ||
require.NoError(t, err, "start container") | ||
t.Logf("started container %s\n", ctr.ID) | ||
|
||
// nolint:gosec // For testing only. | ||
var ( | ||
testEmail = "testing@coder.com" | ||
testPassword = "InsecurePassw0rd!" | ||
testUsername = "testing" | ||
) | ||
|
||
// Wait for container to come up | ||
waitLoop: | ||
johnstcn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for { | ||
select { | ||
case <-ctx.Done(): | ||
t.Fatalf("coder failed to become ready in time") | ||
default: | ||
_, rc := execContainer(ctx, t, ctr.ID, fmt.Sprintf(`curl -s --fail %s/api/v2/buildinfo`, localURL)) | ||
if rc == 0 { | ||
break waitLoop | ||
} | ||
t.Logf("not ready yet...") | ||
<-time.After(time.Second) | ||
} | ||
} | ||
// Perform first time setup | ||
_, 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)) | ||
require.Equal(t, 0, rc, "failed to perform first-time setup") | ||
return ctr.ID | ||
} | ||
|
||
// execContainer executes the given command in the given container and returns | ||
// the output and the exit code of the command. | ||
func execContainer(ctx context.Context, t *testing.T, containerID, command string) (string, int) { | ||
t.Helper() | ||
t.Logf("exec container cmd: %q", command) | ||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | ||
require.NoError(t, err, "connect to docker") | ||
defer cli.Close() | ||
execConfig := types.ExecConfig{ | ||
AttachStdout: true, | ||
AttachStderr: true, | ||
Cmd: []string{"/bin/sh", "-c", command}, | ||
} | ||
ex, err := cli.ContainerExecCreate(ctx, containerID, execConfig) | ||
require.NoError(t, err, "create container exec") | ||
resp, err := cli.ContainerExecAttach(ctx, ex.ID, types.ExecStartCheck{}) | ||
require.NoError(t, err, "attach to container exec") | ||
defer resp.Close() | ||
var buf bytes.Buffer | ||
_, err = stdcopy.StdCopy(&buf, &buf, resp.Reader) | ||
require.NoError(t, err, "read stdout") | ||
out := buf.String() | ||
t.Log("exec container output:\n" + out) | ||
execResp, err := cli.ContainerExecInspect(ctx, ex.ID) | ||
require.NoError(t, err, "get exec exit code") | ||
return out, execResp.ExitCode | ||
} | ||
|
||
// assertOutput asserts that, for each key-value pair in expected: | ||
// 1. actual[k] as a regex matches expected[k], and | ||
// 2. the set of keys of expected are not a subset of actual. | ||
func assertOutput(t *testing.T, expected, actual map[string]string) { | ||
t.Helper() | ||
|
||
for expectedKey, expectedValExpr := range expected { | ||
actualVal := actual[expectedKey] | ||
assert.Regexp(t, expectedValExpr, actualVal) | ||
} | ||
for actualKey := range actual { | ||
_, ok := expected[actualKey] | ||
assert.True(t, ok, "unexpected field in actual %q=%q", actualKey, actual[actualKey]) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
terraform { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: curious if we can use a file from the examples directory, or put it there. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could use some, but most have dependencies like gcloud credentials that we can't necessarily provide. |
||
required_providers { | ||
coder = { | ||
source = "coder/coder" | ||
} | ||
local = { | ||
source = "hashicorp/local" | ||
} | ||
} | ||
} | ||
|
||
// TODO: test coder_external_auth and coder_git_auth | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
// data coder_external_auth "me" {} | ||
// data coder_git_auth "me" {} | ||
data "coder_provisioner" "me" {} | ||
data "coder_workspace" "me" {} | ||
data "coder_workspace_owner" "me" {} | ||
|
||
locals { | ||
# NOTE: these must all be strings in the output | ||
output = { | ||
"provisioner.arch" : data.coder_provisioner.me.arch, | ||
"provisioner.id" : data.coder_provisioner.me.id, | ||
"provisioner.os" : data.coder_provisioner.me.os, | ||
"workspace.access_port" : tostring(data.coder_workspace.me.access_port), | ||
"workspace.access_url" : data.coder_workspace.me.access_url, | ||
"workspace.id" : data.coder_workspace.me.id, | ||
"workspace.name" : data.coder_workspace.me.name, | ||
"workspace.owner" : data.coder_workspace.me.owner, | ||
"workspace.owner_email" : data.coder_workspace.me.owner_email, | ||
"workspace.owner_groups" : jsonencode(data.coder_workspace.me.owner_groups), | ||
"workspace.owner_id" : data.coder_workspace.me.owner_id, | ||
"workspace.owner_name" : data.coder_workspace.me.owner_name, | ||
"workspace.owner_oidc_access_token" : data.coder_workspace.me.owner_oidc_access_token, | ||
"workspace.owner_session_token" : data.coder_workspace.me.owner_session_token, | ||
"workspace.start_count" : tostring(data.coder_workspace.me.start_count), | ||
"workspace.template_id" : data.coder_workspace.me.template_id, | ||
"workspace.template_name" : data.coder_workspace.me.template_name, | ||
"workspace.template_version" : data.coder_workspace.me.template_version, | ||
"workspace.transition" : data.coder_workspace.me.transition, | ||
"workspace_owner.email" : data.coder_workspace_owner.me.email, | ||
"workspace_owner.full_name" : data.coder_workspace_owner.me.full_name, | ||
"workspace_owner.groups" : jsonencode(data.coder_workspace_owner.me.groups), | ||
"workspace_owner.id" : data.coder_workspace_owner.me.id, | ||
"workspace_owner.name" : data.coder_workspace_owner.me.name, | ||
"workspace_owner.oidc_access_token" : data.coder_workspace_owner.me.oidc_access_token, | ||
"workspace_owner.session_token" : data.coder_workspace_owner.me.session_token, | ||
"workspace_owner.ssh_private_key" : data.coder_workspace_owner.me.ssh_private_key, | ||
"workspace_owner.ssh_public_key" : data.coder_workspace_owner.me.ssh_public_key, | ||
} | ||
} | ||
|
||
variable "output_path" { | ||
type = string | ||
} | ||
|
||
resource "local_file" "output" { | ||
filename = var.output_path | ||
content = jsonencode(local.output) | ||
} | ||
|
||
output "output" { | ||
value = local.output | ||
sensitive = true | ||
} |
Uh oh!
There was an error while loading. Please reload this page.