Files
coder/provisioner/terraform/provision.go
Kyle Carberry 43c6bff5ae fix: Use "terraform state pull" instead of "terraform show" (#1262)
Although the terraform-exec docs don't indicate this, the result of
"terraform show" isn't actually the state... it's a trimmed version
of the state that excludes resource identifiers, essentially removing
all state that did exist.

Tests will be written to ensure Terraform state reconciliation can occur.
This will happen in another PR, as dogfood is currently broken because of this.
2022-05-02 20:02:38 +00:00

635 lines
17 KiB
Go

package terraform
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/awalterschulze/gographviz"
"github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/mapstructure"
"golang.org/x/xerrors"
"github.com/coder/coder/provisionersdk"
"github.com/coder/coder/provisionersdk/proto"
)
// Provision executes `terraform apply`.
func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) error {
shutdown, shutdownFunc := context.WithCancel(stream.Context())
defer shutdownFunc()
request, err := stream.Recv()
if err != nil {
return err
}
if request.GetCancel() != nil {
return nil
}
// We expect the first message is start!
if request.GetStart() == nil {
return nil
}
go func() {
for {
request, err := stream.Recv()
if err != nil {
return
}
if request.GetCancel() == nil {
// This is only to process cancels!
continue
}
shutdownFunc()
return
}
}()
start := request.GetStart()
terraform, err := tfexec.NewTerraform(start.Directory, t.binaryPath)
if err != nil {
return xerrors.Errorf("create new terraform executor: %w", err)
}
version, _, err := terraform.Version(shutdown, false)
if err != nil {
return xerrors.Errorf("get terraform version: %w", err)
}
if !version.GreaterThanOrEqual(minimumTerraformVersion) {
return xerrors.Errorf("terraform version %q is too old. required >= %q", version.String(), minimumTerraformVersion.String())
}
statefilePath := filepath.Join(start.Directory, "terraform.tfstate")
if len(start.State) > 0 {
err := os.WriteFile(statefilePath, start.State, 0600)
if err != nil {
return xerrors.Errorf("write statefile %q: %w", statefilePath, err)
}
}
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
go func() {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
_ = stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Log{
Log: &proto.Log{
Level: proto.LogLevel_DEBUG,
Output: scanner.Text(),
},
},
})
}
}()
terraformEnv := map[string]string{}
// Required for "terraform init" to find "git" to
// clone Terraform modules.
for _, env := range os.Environ() {
parts := strings.SplitN(env, "=", 2)
if len(parts) < 2 {
continue
}
terraformEnv[parts[0]] = parts[1]
}
// Only Linux reliably works with the Terraform plugin
// cache directory. It's unknown why this is.
if t.cachePath != "" && runtime.GOOS == "linux" {
terraformEnv["TF_PLUGIN_CACHE_DIR"] = t.cachePath
}
err = terraform.SetEnv(terraformEnv)
if err != nil {
return xerrors.Errorf("set terraform env: %w", err)
}
terraform.SetStdout(writer)
t.logger.Debug(shutdown, "running initialization")
err = terraform.Init(shutdown)
if err != nil {
return xerrors.Errorf("initialize terraform: %w", err)
}
t.logger.Debug(shutdown, "ran initialization")
_ = reader.Close()
terraform.SetStdout(io.Discard)
env := os.Environ()
env = append(env,
"CODER_AGENT_URL="+start.Metadata.CoderUrl,
"CODER_WORKSPACE_TRANSITION="+strings.ToLower(start.Metadata.WorkspaceTransition.String()),
"CODER_WORKSPACE_NAME="+start.Metadata.WorkspaceName,
"CODER_WORKSPACE_OWNER="+start.Metadata.WorkspaceOwner,
"CODER_WORKSPACE_ID="+start.Metadata.WorkspaceId,
"CODER_WORKSPACE_OWNER_ID="+start.Metadata.WorkspaceOwnerId,
)
for key, value := range provisionersdk.AgentScriptEnv() {
env = append(env, key+"="+value)
}
vars := []string{}
for _, param := range start.ParameterValues {
switch param.DestinationScheme {
case proto.ParameterDestination_ENVIRONMENT_VARIABLE:
env = append(env, fmt.Sprintf("%s=%s", param.Name, param.Value))
case proto.ParameterDestination_PROVISIONER_VARIABLE:
vars = append(vars, fmt.Sprintf("%s=%s", param.Name, param.Value))
default:
return xerrors.Errorf("unsupported parameter type %q for %q", param.DestinationScheme, param.Name)
}
}
closeChan := make(chan struct{})
reader, writer = io.Pipe()
defer reader.Close()
defer writer.Close()
go func() {
defer close(closeChan)
decoder := json.NewDecoder(reader)
for {
var log terraformProvisionLog
err := decoder.Decode(&log)
if err != nil {
return
}
logLevel, err := convertTerraformLogLevel(log.Level)
if err != nil {
// Not a big deal, but we should handle this at some point!
continue
}
_ = stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Log{
Log: &proto.Log{
Level: logLevel,
Output: log.Message,
},
},
})
if log.Diagnostic == nil {
continue
}
// If the diagnostic is provided, let's provide a bit more info!
logLevel, err = convertTerraformLogLevel(log.Diagnostic.Severity)
if err != nil {
continue
}
_ = stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Log{
Log: &proto.Log{
Level: logLevel,
Output: log.Diagnostic.Detail,
},
},
})
}
}()
planfilePath := filepath.Join(start.Directory, "terraform.tfplan")
var args []string
if start.DryRun {
args = []string{
"plan",
"-no-color",
"-input=false",
"-json",
"-refresh=true",
"-out=" + planfilePath,
}
} else {
args = []string{
"apply",
"-no-color",
"-auto-approve",
"-input=false",
"-json",
"-refresh=true",
}
}
if start.Metadata.WorkspaceTransition == proto.WorkspaceTransition_DESTROY {
args = append(args, "-destroy")
}
for _, variable := range vars {
args = append(args, "-var", variable)
}
// #nosec
cmd := exec.CommandContext(stream.Context(), t.binaryPath, args...)
go func() {
select {
case <-stream.Context().Done():
return
case <-shutdown.Done():
_ = cmd.Process.Signal(os.Interrupt)
}
}()
cmd.Stdout = writer
cmd.Env = env
cmd.Dir = terraform.WorkingDir()
err = cmd.Run()
if err != nil {
if start.DryRun {
if shutdown.Err() != nil {
return stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Error: err.Error(),
},
},
})
}
return xerrors.Errorf("plan terraform: %w", err)
}
errorMessage := err.Error()
// Terraform can fail and apply and still need to store it's state.
// In this case, we return Complete with an explicit error message.
stateData, _ := os.ReadFile(statefilePath)
return stream.Send(&proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
State: stateData,
Error: errorMessage,
},
},
})
}
_ = reader.Close()
<-closeChan
var resp *proto.Provision_Response
if start.DryRun {
resp, err = parseTerraformPlan(stream.Context(), terraform, planfilePath)
} else {
resp, err = parseTerraformApply(stream.Context(), terraform, statefilePath)
}
if err != nil {
return err
}
return stream.Send(resp)
}
func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfilePath string) (*proto.Provision_Response, error) {
plan, err := terraform.ShowPlanFile(ctx, planfilePath)
if err != nil {
return nil, xerrors.Errorf("show terraform plan file: %w", err)
}
rawGraph, err := terraform.Graph(ctx)
if err != nil {
return nil, xerrors.Errorf("graph: %w", err)
}
resourceDependencies, err := findDirectDependencies(rawGraph)
if err != nil {
return nil, xerrors.Errorf("find dependencies: %w", err)
}
resources := make([]*proto.Resource, 0)
agents := map[string]*proto.Agent{}
// Store all agents inside the maps!
for _, resource := range plan.Config.RootModule.Resources {
if resource.Type != "coder_agent" {
continue
}
agent := &proto.Agent{
Name: resource.Name,
Auth: &proto.Agent_Token{},
}
if operatingSystemRaw, has := resource.Expressions["os"]; has {
operatingSystem, ok := operatingSystemRaw.ConstantValue.(string)
if ok {
agent.OperatingSystem = operatingSystem
}
}
if archRaw, has := resource.Expressions["arch"]; has {
arch, ok := archRaw.ConstantValue.(string)
if ok {
agent.Architecture = arch
}
}
if envRaw, has := resource.Expressions["env"]; has {
env, ok := envRaw.ConstantValue.(map[string]interface{})
if ok {
agent.Env = map[string]string{}
for key, valueRaw := range env {
value, valid := valueRaw.(string)
if !valid {
continue
}
agent.Env[key] = value
}
}
}
if startupScriptRaw, has := resource.Expressions["startup_script"]; has {
startupScript, ok := startupScriptRaw.ConstantValue.(string)
if ok {
agent.StartupScript = startupScript
}
}
if directoryRaw, has := resource.Expressions["dir"]; has {
dir, ok := directoryRaw.ConstantValue.(string)
if ok {
agent.Directory = dir
}
}
agents[resource.Address] = agent
}
for _, resource := range plan.PlannedValues.RootModule.Resources {
if resource.Mode == tfjson.DataResourceMode {
continue
}
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
continue
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
resources = append(resources, &proto.Resource{
Name: resource.Name,
Type: resource.Type,
Agents: findAgents(resourceDependencies, agents, resourceKey),
})
}
return &proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: resources,
},
},
}, nil
}
func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, statefilePath string) (*proto.Provision_Response, error) {
_, err := os.Stat(statefilePath)
statefileExisted := err == nil
statefile, err := os.OpenFile(statefilePath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, xerrors.Errorf("open statefile %q: %w", statefilePath, err)
}
defer statefile.Close()
// #nosec
cmd := exec.CommandContext(ctx, terraform.ExecPath(), "state", "pull")
cmd.Dir = terraform.WorkingDir()
cmd.Stdout = statefile
err = cmd.Run()
if err != nil {
return nil, xerrors.Errorf("pull terraform state: %w", err)
}
state, err := terraform.ShowStateFile(ctx, statefilePath)
if err != nil {
return nil, xerrors.Errorf("show terraform state: %w", err)
}
resources := make([]*proto.Resource, 0)
if state.Values != nil {
rawGraph, err := terraform.Graph(ctx)
if err != nil {
return nil, xerrors.Errorf("graph: %w", err)
}
resourceDependencies, err := findDirectDependencies(rawGraph)
if err != nil {
return nil, xerrors.Errorf("find dependencies: %w", err)
}
type agentAttributes struct {
Auth string `mapstructure:"auth"`
OperatingSystem string `mapstructure:"os"`
Architecture string `mapstructure:"arch"`
Directory string `mapstructure:"dir"`
ID string `mapstructure:"id"`
Token string `mapstructure:"token"`
Env map[string]string `mapstructure:"env"`
StartupScript string `mapstructure:"startup_script"`
}
agents := map[string]*proto.Agent{}
// Store all agents inside the maps!
for _, resource := range state.Values.RootModule.Resources {
if resource.Type != "coder_agent" {
continue
}
var attrs agentAttributes
err = mapstructure.Decode(resource.AttributeValues, &attrs)
if err != nil {
return nil, xerrors.Errorf("decode agent attributes: %w", err)
}
agent := &proto.Agent{
Name: resource.Name,
Id: attrs.ID,
Env: attrs.Env,
StartupScript: attrs.StartupScript,
OperatingSystem: attrs.OperatingSystem,
Architecture: attrs.Architecture,
Directory: attrs.Directory,
}
switch attrs.Auth {
case "token":
agent.Auth = &proto.Agent_Token{
Token: attrs.Token,
}
default:
agent.Auth = &proto.Agent_InstanceId{}
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
agents[resourceKey] = agent
}
// Manually associate agents with instance IDs.
for _, resource := range state.Values.RootModule.Resources {
if resource.Type != "coder_agent_instance" {
continue
}
agentIDRaw, valid := resource.AttributeValues["agent_id"]
if !valid {
continue
}
agentID, valid := agentIDRaw.(string)
if !valid {
continue
}
instanceIDRaw, valid := resource.AttributeValues["instance_id"]
if !valid {
continue
}
instanceID, valid := instanceIDRaw.(string)
if !valid {
continue
}
for _, agent := range agents {
if agent.Id != agentID {
continue
}
agent.Auth = &proto.Agent_InstanceId{
InstanceId: instanceID,
}
break
}
}
for _, resource := range state.Values.RootModule.Resources {
if resource.Mode == tfjson.DataResourceMode {
continue
}
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
continue
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
resourceAgents := findAgents(resourceDependencies, agents, resourceKey)
for _, agent := range resourceAgents {
// Didn't use instance identity.
if agent.GetToken() != "" {
continue
}
key, isValid := map[string]string{
"google_compute_instance": "instance_id",
"aws_instance": "id",
"azurerm_linux_virtual_machine": "id",
"azurerm_windows_virtual_machine": "id",
}[resource.Type]
if !isValid {
// The resource type doesn't support
// automatically setting the instance ID.
continue
}
instanceIDRaw, valid := resource.AttributeValues[key]
if !valid {
continue
}
instanceID, valid := instanceIDRaw.(string)
if !valid {
continue
}
agent.Auth = &proto.Agent_InstanceId{
InstanceId: instanceID,
}
}
resources = append(resources, &proto.Resource{
Name: resource.Name,
Type: resource.Type,
Agents: resourceAgents,
})
}
}
var stateContent []byte
// We only want to restore state if it's not hosted remotely.
if statefileExisted {
stateContent, err = os.ReadFile(statefilePath)
if err != nil {
return nil, xerrors.Errorf("read statefile %q: %w", statefilePath, err)
}
}
return &proto.Provision_Response{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
State: stateContent,
Resources: resources,
},
},
}, nil
}
type terraformProvisionLog struct {
Level string `json:"@level"`
Message string `json:"@message"`
Diagnostic *terraformProvisionLogDiagnostic `json:"diagnostic"`
}
type terraformProvisionLogDiagnostic struct {
Severity string `json:"severity"`
Summary string `json:"summary"`
Detail string `json:"detail"`
}
func convertTerraformLogLevel(logLevel string) (proto.LogLevel, error) {
switch strings.ToLower(logLevel) {
case "trace":
return proto.LogLevel_TRACE, nil
case "debug":
return proto.LogLevel_DEBUG, nil
case "info":
return proto.LogLevel_INFO, nil
case "warn":
return proto.LogLevel_WARN, nil
case "error":
return proto.LogLevel_ERROR, nil
default:
return proto.LogLevel(0), xerrors.Errorf("invalid log level %q", logLevel)
}
}
// findDirectDependencies maps Terraform resources to their children nodes.
// This parses GraphViz output from Terraform which isn't ideal, but seems reliable.
func findDirectDependencies(rawGraph string) (map[string][]string, error) {
parsedGraph, err := gographviz.ParseString(rawGraph)
if err != nil {
return nil, xerrors.Errorf("parse graph: %w", err)
}
graph, err := gographviz.NewAnalysedGraph(parsedGraph)
if err != nil {
return nil, xerrors.Errorf("analyze graph: %w", err)
}
direct := map[string][]string{}
for _, node := range graph.Nodes.Nodes {
label, exists := node.Attrs["label"]
if !exists {
continue
}
label = strings.Trim(label, `"`)
direct[label] = findDependenciesWithLabels(graph, node.Name)
}
return direct, nil
}
// findDependenciesWithLabels recursively finds nodes with labels (resource and data nodes)
// to build a dependency tree.
func findDependenciesWithLabels(graph *gographviz.Graph, nodeName string) []string {
dependencies := make([]string, 0)
for destination := range graph.Edges.SrcToDsts[nodeName] {
dependencyNode, exists := graph.Nodes.Lookup[destination]
if !exists {
continue
}
label, exists := dependencyNode.Attrs["label"]
if !exists {
dependencies = append(dependencies, findDependenciesWithLabels(graph, dependencyNode.Name)...)
continue
}
label = strings.Trim(label, `"`)
dependencies = append(dependencies, label)
}
return dependencies
}
// findAgents recursively searches through resource dependencies
// to find associated agents. Nested is required for indirect
// dependency matching.
func findAgents(resourceDependencies map[string][]string, agents map[string]*proto.Agent, resourceKey string) []*proto.Agent {
resourceNode, exists := resourceDependencies[resourceKey]
if !exists {
return []*proto.Agent{}
}
// Associate resources that depend on an agent.
resourceAgents := make([]*proto.Agent, 0)
for _, dep := range resourceNode {
var has bool
agent, has := agents[dep]
if !has {
resourceAgents = append(resourceAgents, findAgents(resourceDependencies, agents, dep)...)
continue
}
resourceAgents = append(resourceAgents, agent)
}
return resourceAgents
}