Skip to content

fix: Allow nested Terraform resources #1093

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

Merged
merged 2 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"tcpip",
"TCSETS",
"tfexec",
"tfjson",
"tfstate",
"trimprefix",
"unconvert",
Expand Down
5 changes: 5 additions & 0 deletions examples/gcp-vm-container/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
name: Develop in a container on a Google Cloud VM
description: Get started with Linux development on Google Cloud.
tags: [cloud, google, container]
---
103 changes: 103 additions & 0 deletions examples/gcp-vm-container/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
version = "~> 0.3.1"
}
google = {
source = "hashicorp/google"
version = "~> 4.15"
}
}
}

variable "service_account" {
description = <<EOF
Coder requires a Google Cloud Service Account to provision workspaces.

1. Create a service account:
https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts/create
2. Add the roles:
- Compute Admin
- Service Account User
3. Click on the created key, and navigate to the "Keys" tab.
4. Click "Add key", then "Create new key".
5. Generate a JSON private key, and paste the contents below.
EOF
sensitive = true
}

variable "zone" {
description = "What region should your workspace live in?"
default = "us-central1-a"
validation {
condition = contains(["northamerica-northeast1-a", "us-central1-a", "us-west2-c", "europe-west4-b", "southamerica-east1-a"], var.zone)
error_message = "Invalid zone!"
}
}

provider "google" {
zone = var.zone
credentials = var.service_account
project = jsondecode(var.service_account).project_id
}

data "google_compute_default_service_account" "default" {
}

data "coder_workspace" "me" {
}

resource "coder_agent" "dev" {
auth = "google-instance-identity"
arch = "amd64"
os = "linux"
}

module "gce-container" {
source = "terraform-google-modules/container-vm/google"
version = "3.0.0"

container = {
image = "mcr.microsoft.com/vscode/devcontainers/go:1"
command = ["sh"]
args = ["-c", coder_agent.dev.init_script]
securityContext = {
privileged : true
}
}
}

resource "google_compute_instance" "dev" {
zone = var.zone
count = data.coder_workspace.me.start_count
name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}"
machine_type = "e2-medium"
network_interface {
network = "default"
access_config {
// Ephemeral public IP
}
}
boot_disk {
initialize_params {
image = module.gce-container.source_image
}
}
service_account {
email = data.google_compute_default_service_account.default.email
scopes = ["cloud-platform"]
}
metadata = {
"gce-container-declaration" = module.gce-container.metadata_value
}
labels = {
container-vm = module.gce-container.vm_container_label
}
}

resource "coder_agent_instance" "dev" {
count = data.coder_workspace.me.start_count
agent_id = coder_agent.dev.id
instance_id = google_compute_instance.dev[0].instance_id
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ require (
github.com/hashicorp/hcl/v2 v2.11.1
github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f
github.com/hashicorp/terraform-exec v0.15.0
github.com/hashicorp/terraform-json v0.13.0
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87
github.com/jedib0t/go-pretty/v6 v6.3.1
github.com/justinas/nosurf v1.1.1
Expand Down Expand Up @@ -162,7 +163,6 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/terraform-json v0.13.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
Expand Down
132 changes: 73 additions & 59 deletions provisioner/terraform/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/awalterschulze/gographviz"
"github.com/hashicorp/terraform-exec/tfexec"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mitchellh/mapstructure"
"golang.org/x/xerrors"

Expand Down Expand Up @@ -88,15 +89,24 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro
})
}
}()
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" {
err = terraform.SetEnv(map[string]string{
"TF_PLUGIN_CACHE_DIR": t.cachePath,
})
if err != nil {
return xerrors.Errorf("set terraform plugin cache dir: %w", err)
}
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")
Expand Down Expand Up @@ -320,40 +330,22 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi
agent.StartupScript = startupScript
}
}
if _, has := resource.Expressions["instance_id"]; has {
// This is a dynamic value. If it's expressed, we know
// it's at least an instance ID, which is better than nothing.
agent.Auth = &proto.Agent_InstanceId{
InstanceId: "",
}
}

agents[resource.Address] = agent
}

for _, resource := range plan.PlannedValues.RootModule.Resources {
if resource.Type == "coder_agent" {
if resource.Mode == tfjson.DataResourceMode {
continue
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
resourceNode, exists := resourceDependencies[resourceKey]
if !exists {
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
continue
}
// 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 {
continue
}
resourceAgents = append(resourceAgents, agent)
}

resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
resources = append(resources, &proto.Resource{
Name: resource.Name,
Type: resource.Type,
Agents: resourceAgents,
Agents: findAgents(resourceDependencies, agents, resourceKey),
})
}

Expand Down Expand Up @@ -460,32 +452,25 @@ func parseTerraformApply(ctx context.Context, terraform *tfexec.Terraform, state
}

for _, resource := range state.Values.RootModule.Resources {
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
if resource.Mode == tfjson.DataResourceMode {
continue
}
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
resourceNode, exists := resourceDependencies[resourceKey]
if !exists {
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
continue
}
// 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 {
continue
}
resourceAgents = append(resourceAgents, agent)

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",
"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
Expand Down Expand Up @@ -571,21 +556,50 @@ func findDirectDependencies(rawGraph string) (map[string][]string, error) {
continue
}
label = strings.Trim(label, `"`)
direct[label] = findDependenciesWithLabels(graph, node.Name)
}

dependencies := make([]string, 0)
for destination := range graph.Edges.SrcToDsts[node.Name] {
dependencyNode, exists := graph.Nodes.Lookup[destination]
if !exists {
continue
}
label, exists := dependencyNode.Attrs["label"]
if !exists {
continue
}
label = strings.Trim(label, `"`)
dependencies = append(dependencies, label)
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
}
direct[label] = dependencies
label, exists := dependencyNode.Attrs["label"]
if !exists {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

dependencies = append(dependencies, findDependenciesWithLabels(graph, dependencyNode.Name)...)
continue
}
label = strings.Trim(label, `"`)
dependencies = append(dependencies, label)
}
return direct, nil
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to have an upper bound on recursion depth "just in case"?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We limit payload file size, so I don't think it'd be possible to bomb it beyond 10mb.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

resourceAgents = append(resourceAgents, findAgents(resourceDependencies, agents, dep)...)
continue
}
resourceAgents = append(resourceAgents, agent)
}
return resourceAgents
}
Loading