coder/provisioner/terraform/resources_test.go

730 lines
22 KiB
Go

package terraform_test
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/require"
protobuf "google.golang.org/protobuf/proto"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/provisioner/terraform"
"github.com/coder/coder/provisionersdk/proto"
)
func TestConvertResources(t *testing.T) {
t.Parallel()
// nolint:dogsled
_, filename, _, _ := runtime.Caller(0)
type testCase struct {
resources []*proto.Resource
parameters []*proto.RichParameter
gitAuthProviders []string
}
// nolint:paralleltest
for folderName, expected := range map[string]testCase{
// When a resource depends on another, the shortest route
// to a resource should always be chosen for the agent.
"chaining-resources": {
resources: []*proto.Resource{{
Name: "a",
Type: "null_resource",
}, {
Name: "b",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
// This can happen when resources hierarchically conflict.
// When multiple resources exist at the same level, the first
// listed in state will be chosen.
"conflicting-resources": {
resources: []*proto.Resource{{
Name: "first",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}, {
Name: "second",
Type: "null_resource",
}},
},
// Ensures the instance ID authentication type surfaces.
"instance-id": {
resources: []*proto.Resource{{
Name: "main",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_InstanceId{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
// Ensures that calls to resources through modules work
// as expected.
"calling-module": {
resources: []*proto.Resource{{
Name: "example",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
// Ensures the attachment of multiple agents to a single
// resource is successful.
"multiple-agents": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "dev1",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
ConnectionTimeoutSeconds: 120,
LoginBeforeReady: true,
StartupScriptTimeoutSeconds: 300,
ShutdownScriptTimeoutSeconds: 300,
}, {
Name: "dev2",
OperatingSystem: "darwin",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
ConnectionTimeoutSeconds: 1,
MotdFile: "/etc/motd",
LoginBeforeReady: true,
StartupScriptTimeoutSeconds: 30,
ShutdownScript: "echo bye bye",
ShutdownScriptTimeoutSeconds: 30,
}, {
Name: "dev3",
OperatingSystem: "windows",
Architecture: "arm64",
Auth: &proto.Agent_Token{},
ConnectionTimeoutSeconds: 120,
TroubleshootingUrl: "https://coder.com/troubleshoot",
LoginBeforeReady: false,
StartupScriptTimeoutSeconds: 300,
ShutdownScriptTimeoutSeconds: 300,
}},
}},
},
// Ensures multiple applications can be set for a single agent.
"multiple-apps": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "dev1",
OperatingSystem: "linux",
Architecture: "amd64",
Apps: []*proto.App{
{
Slug: "app1",
DisplayName: "app1",
// Subdomain defaults to false if unspecified.
Subdomain: false,
},
{
Slug: "app2",
DisplayName: "app2",
Subdomain: true,
Healthcheck: &proto.Healthcheck{
Url: "http://localhost:13337/healthz",
Interval: 5,
Threshold: 6,
},
},
{
Slug: "app3",
DisplayName: "app3",
Subdomain: false,
},
},
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
"mapped-apps": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "dev",
OperatingSystem: "linux",
Architecture: "amd64",
Apps: []*proto.App{
{
Slug: "app1",
DisplayName: "app1",
},
{
Slug: "app2",
DisplayName: "app2",
},
},
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
// Tests fetching metadata about workspace resources.
"resource-metadata": {
resources: []*proto.Resource{{
Name: "about",
Type: "null_resource",
Hide: true,
Icon: "/icon/server.svg",
DailyCost: 29,
Metadata: []*proto.Resource_Metadata{{
Key: "hello",
Value: "world",
}, {
Key: "null",
IsNull: true,
}, {
Key: "empty",
}, {
Key: "secret",
Value: "squirrel",
Sensitive: true,
}},
Agents: []*proto.Agent{{
Name: "main",
Auth: &proto.Agent_Token{},
OperatingSystem: "linux",
Architecture: "amd64",
Metadata: []*proto.Agent_Metadata{{
Key: "process_count",
DisplayName: "Process Count",
Script: "ps -ef | wc -l",
Interval: 5,
Timeout: 1,
}},
ShutdownScriptTimeoutSeconds: 300,
StartupScriptTimeoutSeconds: 300,
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
},
// Tests that resources with the same id correctly get metadata applied
// to them.
"kubernetes-metadata": {
resources: []*proto.Resource{
{
Name: "coder_workspace",
Type: "kubernetes_config_map",
}, {
Name: "coder_workspace",
Type: "kubernetes_role",
}, {
Name: "coder_workspace",
Type: "kubernetes_role_binding",
}, {
Name: "coder_workspace",
Type: "kubernetes_secret",
}, {
Name: "coder_workspace",
Type: "kubernetes_service_account",
}, {
Name: "main",
Type: "kubernetes_pod",
Metadata: []*proto.Resource_Metadata{{
Key: "cpu",
Value: "1",
}, {
Key: "memory",
Value: "1Gi",
}, {
Key: "gpu",
Value: "1",
}},
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
StartupScript: " #!/bin/bash\n # home folder can be empty, so copying default bash settings\n if [ ! -f ~/.profile ]; then\n cp /etc/skel/.profile $HOME\n fi\n if [ ! -f ~/.bashrc ]; then\n cp /etc/skel/.bashrc $HOME\n fi\n # install and start code-server\n curl -fsSL https://code-server.dev/install.sh | sh | tee code-server-install.log\n code-server --auth none --port 13337 | tee code-server-install.log &\n",
Apps: []*proto.App{
{
Icon: "/icon/code.svg",
Slug: "code-server",
DisplayName: "code-server",
Url: "http://localhost:13337?folder=/home/coder",
},
},
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
},
},
},
"rich-parameters": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "dev",
OperatingSystem: "windows",
ShutdownScriptTimeoutSeconds: 300,
StartupScriptTimeoutSeconds: 300,
Architecture: "arm64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
parameters: []*proto.RichParameter{{
Name: "Example",
Type: "string",
Options: []*proto.RichParameterOption{{
Name: "First Option",
Value: "first",
}, {
Name: "Second Option",
Value: "second",
}},
Required: true,
}, {
Name: "Sample",
Type: "string",
Description: "blah blah",
DefaultValue: "ok",
}, {
Name: "number_example_min_max",
Type: "number",
DefaultValue: "4",
ValidationMin: terraform.PtrInt32(3),
ValidationMax: terraform.PtrInt32(6),
}, {
Name: "number_example_min_zero",
Type: "number",
DefaultValue: "4",
ValidationMin: terraform.PtrInt32(0),
ValidationMax: terraform.PtrInt32(6),
}, {
Name: "number_example_max_zero",
Type: "number",
DefaultValue: "-2",
ValidationMin: terraform.PtrInt32(-3),
ValidationMax: terraform.PtrInt32(0),
}, {
Name: "number_example",
Type: "number",
DefaultValue: "4",
ValidationMin: nil,
ValidationMax: nil,
}},
},
"rich-parameters-validation": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "dev",
OperatingSystem: "windows",
ShutdownScriptTimeoutSeconds: 300,
StartupScriptTimeoutSeconds: 300,
Architecture: "arm64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
}},
}},
parameters: []*proto.RichParameter{{
Name: "number_example_min_max",
Type: "number",
DefaultValue: "4",
ValidationMin: terraform.PtrInt32(3),
ValidationMax: terraform.PtrInt32(6),
}, {
Name: "number_example_min",
Type: "number",
DefaultValue: "4",
ValidationMin: terraform.PtrInt32(3),
ValidationMax: nil,
}, {
Name: "number_example_min_zero",
Type: "number",
DefaultValue: "4",
ValidationMin: terraform.PtrInt32(0),
ValidationMax: nil,
}, {
Name: "number_example_max",
Type: "number",
DefaultValue: "4",
ValidationMin: nil,
ValidationMax: terraform.PtrInt32(6),
}, {
Name: "number_example_max_zero",
Type: "number",
DefaultValue: "-3",
ValidationMin: nil,
ValidationMax: terraform.PtrInt32(0),
}, {
Name: "number_example",
Type: "number",
DefaultValue: "4",
ValidationMin: nil,
ValidationMax: nil,
}},
},
"git-auth-providers": {
resources: []*proto.Resource{{
Name: "dev",
Type: "null_resource",
Agents: []*proto.Agent{{
Name: "main",
OperatingSystem: "linux",
Architecture: "amd64",
Auth: &proto.Agent_Token{},
LoginBeforeReady: true,
ConnectionTimeoutSeconds: 120,
StartupScriptTimeoutSeconds: 300,
ShutdownScriptTimeoutSeconds: 300,
}},
}},
gitAuthProviders: []string{"github", "gitlab"},
},
} {
folderName := folderName
expected := expected
t.Run(folderName, func(t *testing.T) {
t.Parallel()
dir := filepath.Join(filepath.Dir(filename), "testdata", folderName)
t.Run("Plan", func(t *testing.T) {
t.Parallel()
tfPlanRaw, err := os.ReadFile(filepath.Join(dir, folderName+".tfplan.json"))
require.NoError(t, err)
var tfPlan tfjson.Plan
err = json.Unmarshal(tfPlanRaw, &tfPlan)
require.NoError(t, err)
tfPlanGraph, err := os.ReadFile(filepath.Join(dir, folderName+".tfplan.dot"))
require.NoError(t, err)
modules := []*tfjson.StateModule{tfPlan.PlannedValues.RootModule}
if tfPlan.PriorState != nil {
modules = append(modules, tfPlan.PriorState.Values.RootModule)
} else {
// Ensure that resources can be duplicated in the source state
// and that no errors occur!
modules = append(modules, tfPlan.PlannedValues.RootModule)
}
state, err := terraform.ConvertState(modules, string(tfPlanGraph), richParameterResourceNames(expected.parameters))
require.NoError(t, err)
sortResources(state.Resources)
sort.Strings(state.GitAuthProviders)
expectedNoMetadata := make([]*proto.Resource, 0)
for _, resource := range expected.resources {
resourceCopy, _ := protobuf.Clone(resource).(*proto.Resource)
// plan cannot know whether values are null or not
for _, metadata := range resourceCopy.Metadata {
metadata.IsNull = false
}
expectedNoMetadata = append(expectedNoMetadata, resourceCopy)
}
// Convert expectedNoMetadata and resources into a
// []map[string]interface{} so they can be compared easily.
data, err := json.Marshal(expectedNoMetadata)
require.NoError(t, err)
var expectedNoMetadataMap []map[string]interface{}
err = json.Unmarshal(data, &expectedNoMetadataMap)
require.NoError(t, err)
data, err = json.Marshal(state.Resources)
require.NoError(t, err)
var resourcesMap []map[string]interface{}
err = json.Unmarshal(data, &resourcesMap)
require.NoError(t, err)
require.Equal(t, expectedNoMetadataMap, resourcesMap)
expectedParams := expected.parameters
if expectedParams == nil {
expectedParams = []*proto.RichParameter{}
}
parametersWant, err := json.Marshal(expectedParams)
require.NoError(t, err)
parametersGot, err := json.Marshal(state.Parameters)
require.NoError(t, err)
require.Equal(t, string(parametersWant), string(parametersGot))
require.Equal(t, expectedNoMetadataMap, resourcesMap)
require.ElementsMatch(t, expected.gitAuthProviders, state.GitAuthProviders)
})
t.Run("Provision", func(t *testing.T) {
t.Parallel()
tfStateRaw, err := os.ReadFile(filepath.Join(dir, folderName+".tfstate.json"))
require.NoError(t, err)
var tfState tfjson.State
err = json.Unmarshal(tfStateRaw, &tfState)
require.NoError(t, err)
tfStateGraph, err := os.ReadFile(filepath.Join(dir, folderName+".tfstate.dot"))
require.NoError(t, err)
state, err := terraform.ConvertState([]*tfjson.StateModule{tfState.Values.RootModule}, string(tfStateGraph), richParameterResourceNames(expected.parameters))
require.NoError(t, err)
sortResources(state.Resources)
sort.Strings(state.GitAuthProviders)
for _, resource := range state.Resources {
for _, agent := range resource.Agents {
agent.Id = ""
if agent.GetToken() != "" {
agent.Auth = &proto.Agent_Token{}
}
if agent.GetInstanceId() != "" {
agent.Auth = &proto.Agent_InstanceId{}
}
}
}
// Convert expectedNoMetadata and resources into a
// []map[string]interface{} so they can be compared easily.
data, err := json.Marshal(expected.resources)
require.NoError(t, err)
var expectedMap []map[string]interface{}
err = json.Unmarshal(data, &expectedMap)
require.NoError(t, err)
data, err = json.Marshal(state.Resources)
require.NoError(t, err)
var resourcesMap []map[string]interface{}
err = json.Unmarshal(data, &resourcesMap)
require.NoError(t, err)
require.Equal(t, expectedMap, resourcesMap)
require.ElementsMatch(t, expected.gitAuthProviders, state.GitAuthProviders)
})
})
}
}
func TestAppSlugValidation(t *testing.T) {
t.Parallel()
// nolint:dogsled
_, filename, _, _ := runtime.Caller(0)
// Load the multiple-apps state file and edit it.
dir := filepath.Join(filepath.Dir(filename), "testdata", "multiple-apps")
tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "multiple-apps.tfplan.json"))
require.NoError(t, err)
var tfPlan tfjson.Plan
err = json.Unmarshal(tfPlanRaw, &tfPlan)
require.NoError(t, err)
tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "multiple-apps.tfplan.dot"))
require.NoError(t, err)
// Change all slugs to be invalid.
for _, resource := range tfPlan.PlannedValues.RootModule.Resources {
if resource.Type == "coder_app" {
resource.AttributeValues["slug"] = "$$$ invalid slug $$$"
}
}
state, err := terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph), nil)
require.Nil(t, state)
require.Error(t, err)
require.ErrorContains(t, err, "invalid app slug")
// Change all slugs to be identical and valid.
for _, resource := range tfPlan.PlannedValues.RootModule.Resources {
if resource.Type == "coder_app" {
resource.AttributeValues["slug"] = "valid"
}
}
state, err = terraform.ConvertState([]*tfjson.StateModule{tfPlan.PlannedValues.RootModule}, string(tfPlanGraph), nil)
require.Nil(t, state)
require.Error(t, err)
require.ErrorContains(t, err, "duplicate app slug")
}
func TestInstanceTypeAssociation(t *testing.T) {
t.Parallel()
type tc struct {
ResourceType string
InstanceTypeKey string
}
for _, tc := range []tc{{
ResourceType: "google_compute_instance",
InstanceTypeKey: "machine_type",
}, {
ResourceType: "aws_instance",
InstanceTypeKey: "instance_type",
}, {
ResourceType: "aws_spot_instance_request",
InstanceTypeKey: "instance_type",
}, {
ResourceType: "azurerm_linux_virtual_machine",
InstanceTypeKey: "size",
}, {
ResourceType: "azurerm_windows_virtual_machine",
InstanceTypeKey: "size",
}} {
tc := tc
t.Run(tc.ResourceType, func(t *testing.T) {
t.Parallel()
instanceType, err := cryptorand.String(12)
require.NoError(t, err)
state, err := terraform.ConvertState([]*tfjson.StateModule{{
Resources: []*tfjson.StateResource{{
Address: tc.ResourceType + ".dev",
Type: tc.ResourceType,
Name: "dev",
Mode: tfjson.ManagedResourceMode,
AttributeValues: map[string]interface{}{
tc.InstanceTypeKey: instanceType,
},
}},
// This is manually created to join the edges.
}}, `digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] `+tc.ResourceType+`.dev" [label = "`+tc.ResourceType+`.dev", shape = "box"]
}
}`, nil)
require.NoError(t, err)
require.Len(t, state.Resources, 1)
require.Equal(t, state.Resources[0].GetInstanceType(), instanceType)
})
}
}
func TestInstanceIDAssociation(t *testing.T) {
t.Parallel()
type tc struct {
Auth string
ResourceType string
InstanceIDKey string
}
for _, tc := range []tc{{
Auth: "google-instance-identity",
ResourceType: "google_compute_instance",
InstanceIDKey: "instance_id",
}, {
Auth: "aws-instance-identity",
ResourceType: "aws_instance",
InstanceIDKey: "id",
}, {
Auth: "aws-instance-identity",
ResourceType: "aws_spot_instance_request",
InstanceIDKey: "spot_instance_id",
}, {
Auth: "azure-instance-identity",
ResourceType: "azurerm_linux_virtual_machine",
InstanceIDKey: "virtual_machine_id",
}, {
Auth: "azure-instance-identity",
ResourceType: "azurerm_windows_virtual_machine",
InstanceIDKey: "virtual_machine_id",
}} {
tc := tc
t.Run(tc.ResourceType, func(t *testing.T) {
t.Parallel()
instanceID, err := cryptorand.String(12)
require.NoError(t, err)
state, err := terraform.ConvertState([]*tfjson.StateModule{{
Resources: []*tfjson.StateResource{{
Address: "coder_agent.dev",
Type: "coder_agent",
Name: "dev",
Mode: tfjson.ManagedResourceMode,
AttributeValues: map[string]interface{}{
"arch": "amd64",
"auth": tc.Auth,
},
}, {
Address: tc.ResourceType + ".dev",
Type: tc.ResourceType,
Name: "dev",
Mode: tfjson.ManagedResourceMode,
DependsOn: []string{"coder_agent.dev"},
AttributeValues: map[string]interface{}{
tc.InstanceIDKey: instanceID,
},
}},
// This is manually created to join the edges.
}}, `digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] coder_agent.dev" [label = "coder_agent.dev", shape = "box"]
"[root] `+tc.ResourceType+`.dev" [label = "`+tc.ResourceType+`.dev", shape = "box"]
"[root] `+tc.ResourceType+`.dev" -> "[root] coder_agent.dev"
}
}
`, nil)
require.NoError(t, err)
require.Len(t, state.Resources, 1)
require.Len(t, state.Resources[0].Agents, 1)
require.Equal(t, state.Resources[0].Agents[0].GetInstanceId(), instanceID)
})
}
}
// sortResource ensures resources appear in a consistent ordering
// to prevent tests from flaking.
func sortResources(resources []*proto.Resource) {
sort.Slice(resources, func(i, j int) bool {
if resources[i].Name != resources[j].Name {
return resources[i].Name < resources[j].Name
}
return resources[i].Type < resources[j].Type
})
for _, resource := range resources {
for _, agent := range resource.Agents {
sort.Slice(agent.Apps, func(i, j int) bool {
return agent.Apps[i].Slug < agent.Apps[j].Slug
})
}
sort.Slice(resource.Agents, func(i, j int) bool {
return resource.Agents[i].Name < resource.Agents[j].Name
})
}
}
func richParameterResourceNames(parameters []*proto.RichParameter) []string {
var names []string
for _, p := range parameters {
names = append(names, strings.ToLower(p.Name))
}
return names
}