feat: Add Terraform Provider for agent resources (#368)

* ci: Update DataDog GitHub branch to fallback to GITHUB_REF

This was detecting branches, but not our "main" branch before.
Hopefully this fixes it!

* Add basic Terraform Provider

* Rename post files to upload

* Add tests for resources

* Skip instance identity test

* Add tests for ensuring agent get's passed through properly

* Fix linting errors

* Add echo path

* Fix agent authentication

* Update codersdk/files.go

Co-authored-by: Bryan <bryan@coder.com>

Co-authored-by: Bryan <bryan@coder.com>
This commit is contained in:
Kyle Carberry
2022-02-28 17:16:44 +00:00
committed by GitHub
co-authored by Bryan
parent 512e239835
commit 35ae532f7c
18 changed files with 1273 additions and 212 deletions
+165
View File
@@ -0,0 +1,165 @@
package provider
import (
"context"
"net/url"
"reflect"
"strings"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
"github.com/coder/coder/provisionersdk"
)
type config struct {
URL *url.URL
}
// New returns a new Terraform provider.
func New() *schema.Provider {
return &schema.Provider{
Schema: map[string]*schema.Schema{
"url": {
Type: schema.TypeString,
Optional: true,
// The "CODER_URL" environment variable is used by default
// as the Access URL when generating scripts.
DefaultFunc: schema.EnvDefaultFunc("CODER_URL", ""),
ValidateFunc: func(i interface{}, s string) ([]string, []error) {
_, err := url.Parse(s)
if err != nil {
return nil, []error{err}
}
return nil, nil
},
},
},
ConfigureContextFunc: func(c context.Context, resourceData *schema.ResourceData) (interface{}, diag.Diagnostics) {
rawURL, ok := resourceData.Get("url").(string)
if !ok {
return nil, diag.Errorf("unexpected type %q for url", reflect.TypeOf(resourceData.Get("url")).String())
}
if rawURL == "" {
return nil, diag.Errorf("CODER_URL must not be empty; got %q", rawURL)
}
parsed, err := url.Parse(resourceData.Get("url").(string))
if err != nil {
return nil, diag.FromErr(err)
}
return config{
URL: parsed,
}, nil
},
DataSourcesMap: map[string]*schema.Resource{
"coder_agent_script": {
Description: "TODO",
ReadContext: func(c context.Context, resourceData *schema.ResourceData, i interface{}) diag.Diagnostics {
config, valid := i.(config)
if !valid {
return diag.Errorf("config was unexpected type %q", reflect.TypeOf(i).String())
}
operatingSystem, valid := resourceData.Get("os").(string)
if !valid {
return diag.Errorf("os was unexpected type %q", reflect.TypeOf(resourceData.Get("os")))
}
arch, valid := resourceData.Get("arch").(string)
if !valid {
return diag.Errorf("arch was unexpected type %q", reflect.TypeOf(resourceData.Get("arch")))
}
script, err := provisionersdk.AgentScript(config.URL, operatingSystem, arch)
if err != nil {
return diag.FromErr(err)
}
err = resourceData.Set("value", script)
if err != nil {
return diag.FromErr(err)
}
resourceData.SetId(strings.Join([]string{operatingSystem, arch}, "_"))
return nil
},
Schema: map[string]*schema.Schema{
"os": {
Type: schema.TypeString,
Required: true,
ValidateFunc: validation.StringInSlice([]string{"linux", "darwin", "windows"}, false),
},
"arch": {
Type: schema.TypeString,
Required: true,
ValidateFunc: validation.StringInSlice([]string{"amd64"}, false),
},
"value": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
ResourcesMap: map[string]*schema.Resource{
"coder_agent": {
Description: "TODO",
CreateContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
// This should be a real authentication token!
rd.SetId(uuid.NewString())
err := rd.Set("token", uuid.NewString())
if err != nil {
return diag.FromErr(err)
}
return nil
},
ReadContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
return nil
},
DeleteContext: func(c context.Context, rd *schema.ResourceData, i interface{}) diag.Diagnostics {
return nil
},
Schema: map[string]*schema.Schema{
"auth": {
ForceNew: true,
Description: "TODO",
Type: schema.TypeList,
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"type": {
ForceNew: true,
Description: "TODO",
Optional: true,
Type: schema.TypeString,
ValidateFunc: validation.StringInSlice([]string{"google-instance-identity"}, false),
},
"instance_id": {
ForceNew: true,
Description: "TODO",
Optional: true,
Type: schema.TypeString,
},
},
},
},
"env": {
ForceNew: true,
Description: "TODO",
Type: schema.TypeMap,
Optional: true,
},
"startup_script": {
ForceNew: true,
Description: "TODO",
Type: schema.TypeString,
Optional: true,
},
"token": {
ForceNew: true,
Type: schema.TypeString,
Computed: true,
},
},
},
},
}
}
@@ -0,0 +1,123 @@
package provider_test
import (
"fmt"
"testing"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
"github.com/stretchr/testify/require"
"github.com/coder/coder/provisioner/terraform/provider"
)
func TestProvider(t *testing.T) {
t.Parallel()
tfProvider := provider.New()
err := tfProvider.InternalValidate()
require.NoError(t, err)
}
func TestAgentScript(t *testing.T) {
t.Parallel()
resource.Test(t, resource.TestCase{
Providers: map[string]*schema.Provider{
"coder": provider.New(),
},
IsUnitTest: true,
Steps: []resource.TestStep{{
Config: `
provider "coder" {
url = "https://example.com"
}
data "coder_agent_script" "new" {
arch = "amd64"
os = "linux"
}`,
Check: func(state *terraform.State) error {
require.Len(t, state.Modules, 1)
require.Len(t, state.Modules[0].Resources, 1)
resource := state.Modules[0].Resources["data.coder_agent_script.new"]
require.NotNil(t, resource)
value := resource.Primary.Attributes["value"]
require.NotNil(t, value)
t.Log(value)
return nil
},
}},
})
}
func TestAgent(t *testing.T) {
t.Parallel()
t.Run("Empty", func(t *testing.T) {
t.Parallel()
resource.Test(t, resource.TestCase{
Providers: map[string]*schema.Provider{
"coder": provider.New(),
},
IsUnitTest: true,
Steps: []resource.TestStep{{
Config: `
provider "coder" {
url = "https://example.com"
}
resource "coder_agent" "new" {}`,
Check: func(state *terraform.State) error {
require.Len(t, state.Modules, 1)
require.Len(t, state.Modules[0].Resources, 1)
resource := state.Modules[0].Resources["coder_agent.new"]
require.NotNil(t, resource)
require.NotNil(t, resource.Primary.Attributes["token"])
return nil
},
}},
})
})
t.Run("Filled", func(t *testing.T) {
t.Parallel()
resource.Test(t, resource.TestCase{
Providers: map[string]*schema.Provider{
"coder": provider.New(),
},
IsUnitTest: true,
Steps: []resource.TestStep{{
Config: `
provider "coder" {
url = "https://example.com"
}
resource "coder_agent" "new" {
auth {
type = "google-instance-identity"
instance_id = "instance"
}
env = {
hi = "test"
}
startup_script = "echo test"
}`,
Check: func(state *terraform.State) error {
require.Len(t, state.Modules, 1)
require.Len(t, state.Modules[0].Resources, 1)
resource := state.Modules[0].Resources["coder_agent.new"]
require.NotNil(t, resource)
for _, key := range []string{
"token",
"auth.0.type",
"auth.0.instance_id",
"env.hi",
"startup_script",
} {
value := resource.Primary.Attributes[key]
t.Log(fmt.Sprintf("%q = %q", key, value))
require.NotNil(t, value)
require.Greater(t, len(value), 0)
}
return nil
},
}},
})
})
}
+171 -31
View File
@@ -12,8 +12,11 @@ import (
"strings"
"github.com/hashicorp/terraform-exec/tfexec"
"github.com/mitchellh/mapstructure"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/provisionersdk/proto"
)
@@ -72,7 +75,8 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP
func (t *terraform) runTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, request *proto.Provision_Request, stream proto.DRPCProvisioner_ProvisionStream) error {
env := map[string]string{}
options := []tfexec.PlanOption{tfexec.JSON(true)}
planfilePath := filepath.Join(request.Directory, "terraform.tfplan")
options := []tfexec.PlanOption{tfexec.JSON(true), tfexec.Out(planfilePath)}
for _, param := range request.ParameterValues {
switch param.DestinationScheme {
case proto.ParameterDestination_ENVIRONMENT_VARIABLE:
@@ -88,7 +92,6 @@ func (t *terraform) runTerraformPlan(ctx context.Context, terraform *tfexec.Terr
return xerrors.Errorf("apply environment variables: %w", err)
}
resources := make([]*proto.Resource, 0)
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
@@ -117,13 +120,6 @@ func (t *terraform) runTerraformPlan(ctx context.Context, terraform *tfexec.Terr
},
})
if log.Change != nil && log.Change.Action == "create" {
resources = append(resources, &proto.Resource{
Name: log.Change.Resource.ResourceName,
Type: log.Change.Resource.ResourceType,
})
}
if log.Diagnostic == nil {
continue
}
@@ -148,12 +144,102 @@ func (t *terraform) runTerraformPlan(ctx context.Context, terraform *tfexec.Terr
t.logger.Debug(ctx, "running plan")
_, err = terraform.Plan(ctx, options...)
if err != nil {
return xerrors.Errorf("apply terraform: %w", err)
return xerrors.Errorf("plan terraform: %w", err)
}
t.logger.Debug(ctx, "ran plan")
plan, err := terraform.ShowPlanFile(ctx, planfilePath)
if err != nil {
return xerrors.Errorf("show terraform plan file: %w", err)
}
_ = reader.Close()
t.logger.Debug(ctx, "ran plan")
<-closeChan
resources := make([]*proto.Resource, 0)
agents := map[string]*proto.Agent{}
agentDepends := map[string][]string{}
// Store all agents inside the maps!
for _, resource := range plan.Config.RootModule.Resources {
if resource.Type != "coder_agent" {
continue
}
agent := &proto.Agent{
Auth: &proto.Agent_Token{},
}
if envRaw, has := resource.Expressions["env"]; has {
env, ok := envRaw.ConstantValue.(map[string]string)
if !ok {
return xerrors.Errorf("unexpected type %q for env map", reflect.TypeOf(envRaw.ConstantValue).String())
}
agent.Env = env
}
if startupScriptRaw, has := resource.Expressions["startup_script"]; has {
startupScript, ok := startupScriptRaw.ConstantValue.(string)
if !ok {
return xerrors.Errorf("unexpected type %q for startup script", reflect.TypeOf(startupScriptRaw.ConstantValue).String())
}
agent.StartupScript = startupScript
}
if auth, has := resource.Expressions["auth"]; has {
if len(auth.ExpressionData.NestedBlocks) > 0 {
block := auth.ExpressionData.NestedBlocks[0]
authType, has := block["type"]
if has {
authTypeValue, valid := authType.ConstantValue.(string)
if !valid {
return xerrors.Errorf("unexpected type %q for auth type", reflect.TypeOf(authType.ConstantValue))
}
switch authTypeValue {
case "google-instance-identity":
agent.Auth = &proto.Agent_GoogleInstanceIdentity{
GoogleInstanceIdentity: &proto.GoogleInstanceIdentityAuth{
InstanceId: block["instance_id"].ConstantValue.(string),
},
}
default:
return xerrors.Errorf("unknown auth type: %q", authTypeValue)
}
}
}
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
agents[resourceKey] = agent
agentDepends[resourceKey] = resource.DependsOn
}
for _, resource := range plan.Config.RootModule.Resources {
if resource.Type == "coder_agent" {
continue
}
var agent *proto.Agent
// Associate resources that depend on an agent.
for _, dep := range resource.DependsOn {
var has bool
agent, has = agents[dep]
if has {
break
}
}
// Associate resources where the agent depends on it.
for agentKey, dependsOn := range agentDepends {
for _, depend := range dependsOn {
if depend != strings.Join([]string{resource.Type, resource.Name}, ".") {
continue
}
agent = agents[agentKey]
break
}
}
resources = append(resources, &proto.Resource{
Name: resource.Name,
Type: resource.Type,
Agent: agent,
})
}
return stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
@@ -228,7 +314,7 @@ func (t *terraform) runTerraformApply(ctx context.Context, terraform *tfexec.Ter
}()
terraform.SetStdout(writer)
t.logger.Debug(ctx, "running apply")
t.logger.Debug(ctx, "running apply", slog.F("options", options))
err = terraform.Apply(ctx, options...)
if err != nil {
return xerrors.Errorf("apply terraform: %w", err)
@@ -245,18 +331,83 @@ func (t *terraform) runTerraformApply(ctx context.Context, terraform *tfexec.Ter
}
resources := make([]*proto.Resource, 0)
if state.Values != nil {
type agentAttributes struct {
ID string `mapstructure:"id"`
Token string `mapstructure:"token"`
Auth []struct {
Type string `mapstructure:"type"`
InstanceID string `mapstructure:"instance_id"`
} `mapstructure:"auth"`
Env map[string]string `mapstructure:"env"`
StartupScript string `mapstructure:"startup_script"`
}
agents := map[string]*proto.Agent{}
agentDepends := map[string][]string{}
// Store all agents inside the maps!
for _, resource := range state.Values.RootModule.Resources {
var instanceID string
if gcpInstanceID, ok := resource.AttributeValues["instance_id"]; ok {
instanceID, ok = gcpInstanceID.(string)
if !ok {
return xerrors.Errorf("invalid type for instance_id property: %s", reflect.TypeOf(gcpInstanceID).String())
if resource.Type != "coder_agent" {
continue
}
var attrs agentAttributes
err = mapstructure.Decode(resource.AttributeValues, &attrs)
if err != nil {
return xerrors.Errorf("decode agent attributes: %w", err)
}
agent := &proto.Agent{
Id: attrs.ID,
Env: attrs.Env,
StartupScript: attrs.StartupScript,
Auth: &proto.Agent_Token{
Token: attrs.Token,
},
}
if len(attrs.Auth) > 0 {
auth := attrs.Auth[0]
switch auth.Type {
case "google-instance-identity":
agent.Auth = &proto.Agent_GoogleInstanceIdentity{
GoogleInstanceIdentity: &proto.GoogleInstanceIdentityAuth{
InstanceId: auth.InstanceID,
},
}
default:
return xerrors.Errorf("unknown auth type: %q", auth.Type)
}
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
agents[resourceKey] = agent
agentDepends[resourceKey] = resource.DependsOn
}
for _, resource := range state.Values.RootModule.Resources {
if resource.Type == "coder_agent" {
continue
}
var agent *proto.Agent
// Associate resources that depend on an agent.
for _, dep := range resource.DependsOn {
var has bool
agent, has = agents[dep]
if has {
break
}
}
// Associate resources where the agent depends on it.
for agentKey, dependsOn := range agentDepends {
for _, depend := range dependsOn {
if depend != strings.Join([]string{resource.Type, resource.Name}, ".") {
continue
}
agent = agents[agentKey]
break
}
}
resources = append(resources, &proto.Resource{
Name: resource.Name,
Type: resource.Type,
InstanceId: instanceID,
Name: resource.Name,
Type: resource.Type,
Agent: agent,
})
}
}
@@ -276,17 +427,6 @@ type terraformProvisionLog struct {
Message string `json:"@message"`
Diagnostic *terraformProvisionLogDiagnostic `json:"diagnostic"`
Change *terraformProvisionLogChange `json:"change"`
}
type terraformProvisionLogChange struct {
Action string `json:"action"`
Resource *terraformProvisionLogResource `json:"resource"`
}
type terraformProvisionLogResource struct {
ResourceType string `json:"resource_type"`
ResourceName string `json:"resource_name"`
}
type terraformProvisionLogDiagnostic struct {
+173
View File
@@ -5,12 +5,18 @@ package terraform_test
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"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"
@@ -19,6 +25,37 @@ import (
func TestProvision(t *testing.T) {
t.Parallel()
// Build and output the Terraform Provider that is consumed for these tests.
homeDir, err := os.UserHomeDir()
require.NoError(t, err)
providerDest := filepath.Join(homeDir, ".terraform.d", "plugins", "coder.com", "internal", "coder", "0.0.1", fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH))
err = os.MkdirAll(providerDest, 0700)
require.NoError(t, err)
//nolint:dogsled
_, filename, _, _ := runtime.Caller(0)
providerSrc := filepath.Join(filepath.Dir(filename), "..", "..", "cmd", "terraform-provider-coder")
output, err := exec.Command("go", "build", "-o", providerDest, providerSrc).CombinedOutput()
if err != nil {
t.Log(string(output))
}
require.NoError(t, err)
provider := `
terraform {
required_providers {
coder = {
source = "coder.com/internal/coder"
version = "0.0.1"
}
}
}
provider "coder" {
url = "https://example.com"
}
`
t.Log(provider)
client, server := provisionersdk.TransportPipe()
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(func() {
@@ -31,6 +68,7 @@ func TestProvision(t *testing.T) {
ServeOptions: &provisionersdk.ServeOptions{
Listener: server,
},
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
})
require.NoError(t, err)
}()
@@ -125,6 +163,127 @@ func TestProvision(t *testing.T) {
},
},
},
}, {
Name: "resource-associated-with-agent",
Files: map[string]string{
"main.tf": provider + `
resource "coder_agent" "A" {}
resource "null_resource" "A" {
depends_on = [
coder_agent.A
]
}`,
},
Response: &proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "A",
Type: "null_resource",
Agent: &proto.Agent{
Auth: &proto.Agent_Token{
Token: "",
},
},
}},
},
},
},
}, {
Name: "agent-associated-with-resource",
Files: map[string]string{
"main.tf": provider + `
resource "coder_agent" "A" {
depends_on = [
null_resource.A
]
auth {
type = "google-instance-identity"
instance_id = "an-instance"
}
}
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",
Agent: &proto.Agent{
Auth: &proto.Agent_GoogleInstanceIdentity{
GoogleInstanceIdentity: &proto.GoogleInstanceIdentityAuth{
InstanceId: "an-instance",
},
},
},
}},
},
},
},
}, {
Name: "dryrun-resource-associated-with-agent",
Files: map[string]string{
"main.tf": provider + `
resource "coder_agent" "A" {
}
resource "null_resource" "A" {
depends_on = [
coder_agent.A
]
}`,
},
Request: &proto.Provision_Request{
DryRun: true,
},
Response: &proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "A",
Type: "null_resource",
Agent: &proto.Agent{
Auth: &proto.Agent_Token{},
},
}},
},
},
},
}, {
Name: "dryrun-agent-associated-with-resource",
Files: map[string]string{
"main.tf": provider + `
resource "coder_agent" "A" {
depends_on = [
null_resource.A
]
auth {
type = "google-instance-identity"
instance_id = "an-instance"
}
}
resource "null_resource" "A" {}`,
},
Request: &proto.Provision_Request{
DryRun: true,
},
Response: &proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "A",
Type: "null_resource",
Agent: &proto.Agent{
Auth: &proto.Agent_GoogleInstanceIdentity{
GoogleInstanceIdentity: &proto.GoogleInstanceIdentityAuth{
InstanceId: "an-instance",
},
},
},
}},
},
},
},
}} {
testCase := testCase
t.Run(testCase.Name, func(t *testing.T) {
@@ -167,6 +326,20 @@ func TestProvision(t *testing.T) {
require.Greater(t, len(msg.GetComplete().State), 0)
}
// Remove randomly generated data.
for _, resource := range msg.GetComplete().Resources {
if resource.Agent == nil {
continue
}
resource.Agent.Id = ""
if resource.Agent.GetToken() == "" {
continue
}
resource.Agent.Auth = &proto.Agent_Token{
Token: "",
}
}
resourcesGot, err := json.Marshal(msg.GetComplete().Resources)
require.NoError(t, err)