mirror of https://github.com/coder/coder.git
649 lines
16 KiB
Go
649 lines
16 KiB
Go
//go:build linux || darwin
|
|
|
|
package terraform_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog"
|
|
"cdr.dev/slog/sloggers/slogtest"
|
|
|
|
"github.com/coder/coder/provisioner/terraform"
|
|
"github.com/coder/coder/provisionersdk"
|
|
"github.com/coder/coder/provisionersdk/proto"
|
|
)
|
|
|
|
type provisionerServeOptions struct {
|
|
binaryPath string
|
|
exitTimeout time.Duration
|
|
}
|
|
|
|
func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Context, proto.DRPCProvisionerClient) {
|
|
if opts == nil {
|
|
opts = &provisionerServeOptions{}
|
|
}
|
|
cachePath := t.TempDir()
|
|
client, server := provisionersdk.MemTransportPipe()
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
serverErr := make(chan error, 1)
|
|
t.Cleanup(func() {
|
|
_ = client.Close()
|
|
_ = server.Close()
|
|
cancelFunc()
|
|
err := <-serverErr
|
|
if !errors.Is(err, context.Canceled) {
|
|
assert.NoError(t, err)
|
|
}
|
|
})
|
|
go func() {
|
|
serverErr <- terraform.Serve(ctx, &terraform.ServeOptions{
|
|
ServeOptions: &provisionersdk.ServeOptions{
|
|
Listener: server,
|
|
},
|
|
BinaryPath: opts.binaryPath,
|
|
CachePath: cachePath,
|
|
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
|
ExitTimeout: opts.exitTimeout,
|
|
})
|
|
}()
|
|
api := proto.NewDRPCProvisionerClient(client)
|
|
return ctx, api
|
|
}
|
|
|
|
func readProvisionLog(t *testing.T, response proto.DRPCProvisioner_ProvisionClient) (
|
|
string,
|
|
*proto.Provision_Complete,
|
|
) {
|
|
var (
|
|
logBuf strings.Builder
|
|
c *proto.Provision_Complete
|
|
)
|
|
for {
|
|
msg, err := response.Recv()
|
|
require.NoError(t, err)
|
|
|
|
if log := msg.GetLog(); log != nil {
|
|
t.Log(log.Level.String(), log.Output)
|
|
_, _ = logBuf.WriteString(log.Output)
|
|
}
|
|
if c = msg.GetComplete(); c != nil {
|
|
require.Empty(t, c.Error)
|
|
break
|
|
}
|
|
}
|
|
return logBuf.String(), c
|
|
}
|
|
|
|
func TestProvision_Cancel(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("This test uses interrupts and is not supported on Windows")
|
|
}
|
|
|
|
cwd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
fakeBin := filepath.Join(cwd, "testdata", "fake_cancel.sh")
|
|
|
|
tests := []struct {
|
|
name string
|
|
mode string
|
|
startSequence []string
|
|
wantLog []string
|
|
}{
|
|
{
|
|
name: "Cancel init",
|
|
mode: "init",
|
|
startSequence: []string{"init_start"},
|
|
wantLog: []string{"interrupt", "exit"},
|
|
},
|
|
{
|
|
name: "Cancel apply",
|
|
mode: "apply",
|
|
startSequence: []string{"init", "apply_start"},
|
|
wantLog: []string{"interrupt", "exit"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
binPath := filepath.Join(dir, "terraform")
|
|
|
|
// Example: exec /path/to/terrafork_fake_cancel.sh 1.2.1 apply "$@"
|
|
content := fmt.Sprintf("#!/bin/sh\nexec %q %s %s \"$@\"\n", fakeBin, terraform.TerraformVersion.String(), tt.mode)
|
|
err := os.WriteFile(binPath, []byte(content), 0o755) //#nosec
|
|
require.NoError(t, err)
|
|
|
|
ctx, api := setupProvisioner(t, &provisionerServeOptions{
|
|
binaryPath: binPath,
|
|
exitTimeout: time.Nanosecond,
|
|
})
|
|
|
|
response, err := api.Provision(ctx)
|
|
require.NoError(t, err)
|
|
err = response.Send(&proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Apply{
|
|
Apply: &proto.Provision_Apply{
|
|
Config: &proto.Provision_Config{
|
|
Directory: dir,
|
|
Metadata: &proto.Provision_Metadata{},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for _, line := range tt.startSequence {
|
|
LoopStart:
|
|
msg, err := response.Recv()
|
|
require.NoError(t, err)
|
|
|
|
t.Log(msg.Type)
|
|
|
|
log := msg.GetLog()
|
|
if log == nil {
|
|
goto LoopStart
|
|
}
|
|
require.Equal(t, line, log.Output)
|
|
}
|
|
|
|
err = response.Send(&proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Cancel{
|
|
Cancel: &proto.Provision_Cancel{},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var gotLog []string
|
|
for {
|
|
msg, err := response.Recv()
|
|
require.NoError(t, err)
|
|
|
|
if log := msg.GetLog(); log != nil {
|
|
gotLog = append(gotLog, log.Output)
|
|
}
|
|
if c := msg.GetComplete(); c != nil {
|
|
require.Contains(t, c.Error, "exit status 1")
|
|
break
|
|
}
|
|
}
|
|
require.Equal(t, tt.wantLog, gotLog)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProvision(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
Name string
|
|
Files map[string]string
|
|
Request *proto.Provision_Plan
|
|
// Response may be nil to not check the response.
|
|
Response *proto.Provision_Response
|
|
// If ErrorContains is not empty, then response.Recv() should return an
|
|
// error containing this string before a Complete response is returned.
|
|
ErrorContains string
|
|
// If ExpectLogContains is not empty, then the logs should contain it.
|
|
ExpectLogContains string
|
|
Apply bool
|
|
}{
|
|
{
|
|
Name: "missing-variable",
|
|
Files: map[string]string{
|
|
"main.tf": `variable "A" {
|
|
}`,
|
|
},
|
|
ErrorContains: "terraform plan:",
|
|
ExpectLogContains: "No value for required variable",
|
|
},
|
|
{
|
|
Name: "missing-variable-dry-run",
|
|
Files: map[string]string{
|
|
"main.tf": `variable "A" {
|
|
}`,
|
|
},
|
|
ErrorContains: "terraform plan:",
|
|
ExpectLogContains: "No value for required variable",
|
|
},
|
|
{
|
|
Name: "single-resource-dry-run",
|
|
Files: map[string]string{
|
|
"main.tf": `resource "null_resource" "A" {}`,
|
|
},
|
|
Response: &proto.Provision_Response{
|
|
Type: &proto.Provision_Response_Complete{
|
|
Complete: &proto.Provision_Complete{
|
|
Resources: []*proto.Resource{{
|
|
Name: "A",
|
|
Type: "null_resource",
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "single-resource",
|
|
Files: map[string]string{
|
|
"main.tf": `resource "null_resource" "A" {}`,
|
|
},
|
|
Response: &proto.Provision_Response{
|
|
Type: &proto.Provision_Response_Complete{
|
|
Complete: &proto.Provision_Complete{
|
|
Resources: []*proto.Resource{{
|
|
Name: "A",
|
|
Type: "null_resource",
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
Apply: true,
|
|
},
|
|
{
|
|
Name: "bad-syntax-1",
|
|
Files: map[string]string{
|
|
"main.tf": `a`,
|
|
},
|
|
ErrorContains: "initialize terraform",
|
|
ExpectLogContains: "Argument or block definition required",
|
|
},
|
|
{
|
|
Name: "bad-syntax-2",
|
|
Files: map[string]string{
|
|
"main.tf": `;asdf;`,
|
|
},
|
|
ErrorContains: "initialize terraform",
|
|
ExpectLogContains: `The ";" character is not valid.`,
|
|
},
|
|
{
|
|
Name: "destroy-no-state",
|
|
Files: map[string]string{
|
|
"main.tf": `resource "null_resource" "A" {}`,
|
|
},
|
|
Request: &proto.Provision_Plan{
|
|
Config: &proto.Provision_Config{
|
|
Metadata: &proto.Provision_Metadata{
|
|
WorkspaceTransition: proto.WorkspaceTransition_DESTROY,
|
|
},
|
|
},
|
|
},
|
|
ExpectLogContains: "nothing to do",
|
|
},
|
|
{
|
|
Name: "rich-parameter-with-value",
|
|
Files: map[string]string{
|
|
"main.tf": `terraform {
|
|
required_providers {
|
|
coder = {
|
|
source = "coder/coder"
|
|
version = "0.6.20"
|
|
}
|
|
}
|
|
}
|
|
|
|
data "coder_parameter" "sample" {
|
|
name = "Sample"
|
|
type = "string"
|
|
default = "foobaz"
|
|
}
|
|
|
|
data "coder_parameter" "example" {
|
|
name = "Example"
|
|
type = "string"
|
|
default = "foobar"
|
|
}
|
|
|
|
resource "null_resource" "example" {
|
|
triggers = {
|
|
misc = "${data.coder_parameter.example.value}"
|
|
}
|
|
}`,
|
|
},
|
|
Request: &proto.Provision_Plan{
|
|
RichParameterValues: []*proto.RichParameterValue{
|
|
{
|
|
Name: "Sample",
|
|
Value: "foofoo",
|
|
},
|
|
{
|
|
Name: "Example",
|
|
Value: "foobaz",
|
|
},
|
|
},
|
|
},
|
|
Response: &proto.Provision_Response{
|
|
Type: &proto.Provision_Response_Complete{
|
|
Complete: &proto.Provision_Complete{
|
|
Parameters: []*proto.RichParameter{
|
|
{
|
|
Name: "Sample",
|
|
Type: "string",
|
|
DefaultValue: "foobaz",
|
|
},
|
|
{
|
|
Name: "Example",
|
|
Type: "string",
|
|
DefaultValue: "foobar",
|
|
},
|
|
},
|
|
Resources: []*proto.Resource{{
|
|
Name: "example",
|
|
Type: "null_resource",
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "git-auth",
|
|
Files: map[string]string{
|
|
"main.tf": `terraform {
|
|
required_providers {
|
|
coder = {
|
|
source = "coder/coder"
|
|
version = "0.6.20"
|
|
}
|
|
}
|
|
}
|
|
|
|
data "coder_git_auth" "github" {
|
|
id = "github"
|
|
}
|
|
|
|
resource "null_resource" "example" {}
|
|
|
|
resource "coder_metadata" "example" {
|
|
resource_id = null_resource.example.id
|
|
item {
|
|
key = "token"
|
|
value = data.coder_git_auth.github.access_token
|
|
}
|
|
}
|
|
`,
|
|
},
|
|
Request: &proto.Provision_Plan{
|
|
GitAuthProviders: []*proto.GitAuthProvider{{
|
|
Id: "github",
|
|
AccessToken: "some-value",
|
|
}},
|
|
},
|
|
Response: &proto.Provision_Response{
|
|
Type: &proto.Provision_Response_Complete{
|
|
Complete: &proto.Provision_Complete{
|
|
Resources: []*proto.Resource{{
|
|
Name: "example",
|
|
Type: "null_resource",
|
|
Metadata: []*proto.Resource_Metadata{{
|
|
Key: "token",
|
|
Value: "some-value",
|
|
}},
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
testCase := testCase
|
|
t.Run(testCase.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, api := setupProvisioner(t, nil)
|
|
|
|
directory := t.TempDir()
|
|
for path, content := range testCase.Files {
|
|
err := os.WriteFile(filepath.Join(directory, path), []byte(content), 0o600)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
planRequest := &proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Plan{
|
|
Plan: &proto.Provision_Plan{
|
|
Config: &proto.Provision_Config{
|
|
Directory: directory,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if testCase.Request != nil {
|
|
if planRequest.GetPlan().GetConfig() == nil {
|
|
planRequest.GetPlan().Config = &proto.Provision_Config{}
|
|
}
|
|
planRequest.GetPlan().RichParameterValues = testCase.Request.RichParameterValues
|
|
planRequest.GetPlan().GitAuthProviders = testCase.Request.GitAuthProviders
|
|
if testCase.Request.Config != nil {
|
|
planRequest.GetPlan().Config.State = testCase.Request.Config.State
|
|
planRequest.GetPlan().Config.Metadata = testCase.Request.Config.Metadata
|
|
}
|
|
}
|
|
if planRequest.GetPlan().Config.Metadata == nil {
|
|
planRequest.GetPlan().Config.Metadata = &proto.Provision_Metadata{}
|
|
}
|
|
|
|
gotExpectedLog := testCase.ExpectLogContains == ""
|
|
|
|
provision := func(req *proto.Provision_Request) *proto.Provision_Complete {
|
|
response, err := api.Provision(ctx)
|
|
require.NoError(t, err)
|
|
err = response.Send(req)
|
|
require.NoError(t, err)
|
|
|
|
var complete *proto.Provision_Complete
|
|
|
|
for {
|
|
msg, err := response.Recv()
|
|
if msg != nil && msg.GetLog() != nil {
|
|
if testCase.ExpectLogContains != "" && strings.Contains(msg.GetLog().Output, testCase.ExpectLogContains) {
|
|
gotExpectedLog = true
|
|
}
|
|
|
|
t.Logf("log: [%s] %s", msg.GetLog().Level, msg.GetLog().Output)
|
|
continue
|
|
}
|
|
if testCase.ErrorContains != "" {
|
|
require.ErrorContains(t, err, testCase.ErrorContains)
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
if complete = msg.GetComplete(); complete == nil {
|
|
continue
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Remove randomly generated data.
|
|
for _, resource := range msg.GetComplete().Resources {
|
|
sort.Slice(resource.Agents, func(i, j int) bool {
|
|
return resource.Agents[i].Name < resource.Agents[j].Name
|
|
})
|
|
|
|
for _, agent := range resource.Agents {
|
|
agent.Id = ""
|
|
if agent.GetToken() == "" {
|
|
continue
|
|
}
|
|
agent.Auth = &proto.Agent_Token{}
|
|
}
|
|
}
|
|
|
|
if testCase.Response != nil {
|
|
require.Equal(t, testCase.Response.GetComplete().Error, msg.GetComplete().Error)
|
|
|
|
resourcesGot, err := json.Marshal(msg.GetComplete().Resources)
|
|
require.NoError(t, err)
|
|
resourcesWant, err := json.Marshal(testCase.Response.GetComplete().Resources)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, string(resourcesWant), string(resourcesGot))
|
|
|
|
parametersGot, err := json.Marshal(msg.GetComplete().Parameters)
|
|
require.NoError(t, err)
|
|
parametersWant, err := json.Marshal(testCase.Response.GetComplete().Parameters)
|
|
require.NoError(t, err)
|
|
require.Equal(t, string(parametersWant), string(parametersGot))
|
|
}
|
|
break
|
|
}
|
|
|
|
return complete
|
|
}
|
|
|
|
planComplete := provision(planRequest)
|
|
|
|
if testCase.Apply {
|
|
require.NotNil(t, planComplete.Plan)
|
|
provision(&proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Apply{
|
|
Apply: &proto.Provision_Apply{
|
|
Config: planRequest.GetPlan().GetConfig(),
|
|
Plan: planComplete.Plan,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
if !gotExpectedLog {
|
|
t.Fatalf("expected log string %q but never saw it", testCase.ExpectLogContains)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// nolint:paralleltest
|
|
func TestProvision_ExtraEnv(t *testing.T) {
|
|
// #nosec
|
|
const secretValue = "oinae3uinxase"
|
|
t.Setenv("TF_LOG", "INFO")
|
|
t.Setenv("TF_SUPERSECRET", secretValue)
|
|
|
|
ctx, api := setupProvisioner(t, nil)
|
|
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "main.tf")
|
|
err := os.WriteFile(path, []byte(`resource "null_resource" "A" {}`), 0o600)
|
|
require.NoError(t, err)
|
|
|
|
request := &proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Plan{
|
|
Plan: &proto.Provision_Plan{
|
|
Config: &proto.Provision_Config{
|
|
Directory: directory,
|
|
Metadata: &proto.Provision_Metadata{
|
|
WorkspaceTransition: proto.WorkspaceTransition_START,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
response, err := api.Provision(ctx)
|
|
require.NoError(t, err)
|
|
err = response.Send(request)
|
|
require.NoError(t, err)
|
|
found := false
|
|
for {
|
|
msg, err := response.Recv()
|
|
require.NoError(t, err)
|
|
|
|
if log := msg.GetLog(); log != nil {
|
|
t.Log(log.Level.String(), log.Output)
|
|
if strings.Contains(log.Output, "TF_LOG") {
|
|
found = true
|
|
}
|
|
require.NotContains(t, log.Output, secretValue)
|
|
}
|
|
if c := msg.GetComplete(); c != nil {
|
|
require.Empty(t, c.Error)
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found)
|
|
}
|
|
|
|
// nolint:paralleltest
|
|
func TestProvision_SafeEnv(t *testing.T) {
|
|
// #nosec
|
|
const (
|
|
passedValue = "superautopets"
|
|
secretValue = "oinae3uinxase"
|
|
)
|
|
|
|
t.Setenv("VALID_USER_ENV", passedValue)
|
|
|
|
// We ensure random CODER_ variables aren't passed through to avoid leaking
|
|
// control plane secrets (e.g. PG URL).
|
|
t.Setenv("CODER_SECRET", secretValue)
|
|
|
|
const echoResource = `
|
|
resource "null_resource" "a" {
|
|
provisioner "local-exec" {
|
|
command = "env"
|
|
}
|
|
}
|
|
|
|
`
|
|
|
|
ctx, api := setupProvisioner(t, nil)
|
|
|
|
directory := t.TempDir()
|
|
path := filepath.Join(directory, "main.tf")
|
|
err := os.WriteFile(path, []byte(echoResource), 0o600)
|
|
require.NoError(t, err)
|
|
|
|
response, err := api.Provision(ctx)
|
|
require.NoError(t, err)
|
|
err = response.Send(&proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Plan{
|
|
Plan: &proto.Provision_Plan{
|
|
Config: &proto.Provision_Config{
|
|
Directory: directory,
|
|
Metadata: &proto.Provision_Metadata{
|
|
WorkspaceTransition: proto.WorkspaceTransition_START,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, complete := readProvisionLog(t, response)
|
|
|
|
response, err = api.Provision(ctx)
|
|
require.NoError(t, err)
|
|
err = response.Send(&proto.Provision_Request{
|
|
Type: &proto.Provision_Request_Apply{
|
|
Apply: &proto.Provision_Apply{
|
|
Config: &proto.Provision_Config{
|
|
Directory: directory,
|
|
Metadata: &proto.Provision_Metadata{
|
|
WorkspaceTransition: proto.WorkspaceTransition_START,
|
|
},
|
|
},
|
|
Plan: complete.GetPlan(),
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
log, _ := readProvisionLog(t, response)
|
|
require.Contains(t, log, passedValue)
|
|
require.NotContains(t, log, secretValue)
|
|
require.Contains(t, log, "CODER_")
|
|
}
|