Skip to content

Commit 65d7738

Browse files
authored
fix: Allow nested Terraform resources (coder#1093)
This fixes the dependency tree by adding recursion. It now finds indirect connections and associates it with an agent. An example is attached which surfaced this issue.
1 parent e35a4fd commit 65d7738

File tree

7 files changed

+276
-72
lines changed

7 files changed

+276
-72
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"tcpip",
5252
"TCSETS",
5353
"tfexec",
54+
"tfjson",
5455
"tfstate",
5556
"trimprefix",
5657
"unconvert",

examples/gcp-vm-container/README.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
name: Develop in a container on a Google Cloud VM
3+
description: Get started with Linux development on Google Cloud.
4+
tags: [cloud, google, container]
5+
---

examples/gcp-vm-container/main.tf

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
terraform {
2+
required_providers {
3+
coder = {
4+
source = "coder/coder"
5+
version = "~> 0.3.1"
6+
}
7+
google = {
8+
source = "hashicorp/google"
9+
version = "~> 4.15"
10+
}
11+
}
12+
}
13+
14+
variable "service_account" {
15+
description = <<EOF
16+
Coder requires a Google Cloud Service Account to provision workspaces.
17+
18+
1. Create a service account:
19+
https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts/create
20+
2. Add the roles:
21+
- Compute Admin
22+
- Service Account User
23+
3. Click on the created key, and navigate to the "Keys" tab.
24+
4. Click "Add key", then "Create new key".
25+
5. Generate a JSON private key, and paste the contents below.
26+
EOF
27+
sensitive = true
28+
}
29+
30+
variable "zone" {
31+
description = "What region should your workspace live in?"
32+
default = "us-central1-a"
33+
validation {
34+
condition = contains(["northamerica-northeast1-a", "us-central1-a", "us-west2-c", "europe-west4-b", "southamerica-east1-a"], var.zone)
35+
error_message = "Invalid zone!"
36+
}
37+
}
38+
39+
provider "google" {
40+
zone = var.zone
41+
credentials = var.service_account
42+
project = jsondecode(var.service_account).project_id
43+
}
44+
45+
data "google_compute_default_service_account" "default" {
46+
}
47+
48+
data "coder_workspace" "me" {
49+
}
50+
51+
resource "coder_agent" "dev" {
52+
auth = "google-instance-identity"
53+
arch = "amd64"
54+
os = "linux"
55+
}
56+
57+
module "gce-container" {
58+
source = "terraform-google-modules/container-vm/google"
59+
version = "3.0.0"
60+
61+
container = {
62+
image = "mcr.microsoft.com/vscode/devcontainers/go:1"
63+
command = ["sh"]
64+
args = ["-c", coder_agent.dev.init_script]
65+
securityContext = {
66+
privileged : true
67+
}
68+
}
69+
}
70+
71+
resource "google_compute_instance" "dev" {
72+
zone = var.zone
73+
count = data.coder_workspace.me.start_count
74+
name = "coder-${data.coder_workspace.me.owner}-${data.coder_workspace.me.name}"
75+
machine_type = "e2-medium"
76+
network_interface {
77+
network = "default"
78+
access_config {
79+
// Ephemeral public IP
80+
}
81+
}
82+
boot_disk {
83+
initialize_params {
84+
image = module.gce-container.source_image
85+
}
86+
}
87+
service_account {
88+
email = data.google_compute_default_service_account.default.email
89+
scopes = ["cloud-platform"]
90+
}
91+
metadata = {
92+
"gce-container-declaration" = module.gce-container.metadata_value
93+
}
94+
labels = {
95+
container-vm = module.gce-container.vm_container_label
96+
}
97+
}
98+
99+
resource "coder_agent_instance" "dev" {
100+
count = data.coder_workspace.me.start_count
101+
agent_id = coder_agent.dev.id
102+
instance_id = google_compute_instance.dev[0].instance_id
103+
}

go.mod

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ require (
6767
github.com/hashicorp/hcl/v2 v2.11.1
6868
github.com/hashicorp/terraform-config-inspect v0.0.0-20211115214459-90acf1ca460f
6969
github.com/hashicorp/terraform-exec v0.15.0
70+
github.com/hashicorp/terraform-json v0.13.0
7071
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87
7172
github.com/jedib0t/go-pretty/v6 v6.3.1
7273
github.com/justinas/nosurf v1.1.1
@@ -162,7 +163,6 @@ require (
162163
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
163164
github.com/hashicorp/go-multierror v1.1.1 // indirect
164165
github.com/hashicorp/hcl v1.0.0 // indirect
165-
github.com/hashicorp/terraform-json v0.13.0 // indirect
166166
github.com/imdario/mergo v0.3.12 // indirect
167167
github.com/inconshreveable/mousetrap v1.0.0 // indirect
168168
github.com/josharian/intern v1.0.0 // indirect

provisioner/terraform/provision.go

+73-59
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/awalterschulze/gographviz"
1616
"github.com/hashicorp/terraform-exec/tfexec"
17+
tfjson "github.com/hashicorp/terraform-json"
1718
"github.com/mitchellh/mapstructure"
1819
"golang.org/x/xerrors"
1920

@@ -88,15 +89,24 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro
8889
})
8990
}
9091
}()
92+
terraformEnv := map[string]string{}
93+
// Required for "terraform init" to find "git" to
94+
// clone Terraform modules.
95+
for _, env := range os.Environ() {
96+
parts := strings.SplitN(env, "=", 2)
97+
if len(parts) < 2 {
98+
continue
99+
}
100+
terraformEnv[parts[0]] = parts[1]
101+
}
91102
// Only Linux reliably works with the Terraform plugin
92103
// cache directory. It's unknown why this is.
93104
if t.cachePath != "" && runtime.GOOS == "linux" {
94-
err = terraform.SetEnv(map[string]string{
95-
"TF_PLUGIN_CACHE_DIR": t.cachePath,
96-
})
97-
if err != nil {
98-
return xerrors.Errorf("set terraform plugin cache dir: %w", err)
99-
}
105+
terraformEnv["TF_PLUGIN_CACHE_DIR"] = t.cachePath
106+
}
107+
err = terraform.SetEnv(terraformEnv)
108+
if err != nil {
109+
return xerrors.Errorf("set terraform env: %w", err)
100110
}
101111
terraform.SetStdout(writer)
102112
t.logger.Debug(shutdown, "running initialization")
@@ -320,40 +330,22 @@ func parseTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, planfi
320330
agent.StartupScript = startupScript
321331
}
322332
}
323-
if _, has := resource.Expressions["instance_id"]; has {
324-
// This is a dynamic value. If it's expressed, we know
325-
// it's at least an instance ID, which is better than nothing.
326-
agent.Auth = &proto.Agent_InstanceId{
327-
InstanceId: "",
328-
}
329-
}
330333

331334
agents[resource.Address] = agent
332335
}
336+
333337
for _, resource := range plan.PlannedValues.RootModule.Resources {
334-
if resource.Type == "coder_agent" {
338+
if resource.Mode == tfjson.DataResourceMode {
335339
continue
336340
}
337-
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
338-
resourceNode, exists := resourceDependencies[resourceKey]
339-
if !exists {
341+
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
340342
continue
341343
}
342-
// Associate resources that depend on an agent.
343-
resourceAgents := make([]*proto.Agent, 0)
344-
for _, dep := range resourceNode {
345-
var has bool
346-
agent, has := agents[dep]
347-
if !has {
348-
continue
349-
}
350-
resourceAgents = append(resourceAgents, agent)
351-
}
352-
344+
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
353345
resources = append(resources, &proto.Resource{
354346
Name: resource.Name,
355347
Type: resource.Type,
356-
Agents: resourceAgents,
348+
Agents: findAgents(resourceDependencies, agents, resourceKey),
357349
})
358350
}
359351

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

462454
for _, resource := range state.Values.RootModule.Resources {
463-
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
455+
if resource.Mode == tfjson.DataResourceMode {
464456
continue
465457
}
466-
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
467-
resourceNode, exists := resourceDependencies[resourceKey]
468-
if !exists {
458+
if resource.Type == "coder_agent" || resource.Type == "coder_agent_instance" {
469459
continue
470460
}
471-
// Associate resources that depend on an agent.
472-
resourceAgents := make([]*proto.Agent, 0)
473-
for _, dep := range resourceNode {
474-
var has bool
475-
agent, has := agents[dep]
476-
if !has {
477-
continue
478-
}
479-
resourceAgents = append(resourceAgents, agent)
480-
461+
resourceKey := strings.Join([]string{resource.Type, resource.Name}, ".")
462+
resourceAgents := findAgents(resourceDependencies, agents, resourceKey)
463+
for _, agent := range resourceAgents {
481464
// Didn't use instance identity.
482465
if agent.GetToken() != "" {
483466
continue
484467
}
485468

486469
key, isValid := map[string]string{
487-
"google_compute_instance": "instance_id",
488-
"aws_instance": "id",
470+
"google_compute_instance": "instance_id",
471+
"aws_instance": "id",
472+
"azurerm_linux_virtual_machine": "id",
473+
"azurerm_windows_virtual_machine": "id",
489474
}[resource.Type]
490475
if !isValid {
491476
// The resource type doesn't support
@@ -571,21 +556,50 @@ func findDirectDependencies(rawGraph string) (map[string][]string, error) {
571556
continue
572557
}
573558
label = strings.Trim(label, `"`)
559+
direct[label] = findDependenciesWithLabels(graph, node.Name)
560+
}
574561

575-
dependencies := make([]string, 0)
576-
for destination := range graph.Edges.SrcToDsts[node.Name] {
577-
dependencyNode, exists := graph.Nodes.Lookup[destination]
578-
if !exists {
579-
continue
580-
}
581-
label, exists := dependencyNode.Attrs["label"]
582-
if !exists {
583-
continue
584-
}
585-
label = strings.Trim(label, `"`)
586-
dependencies = append(dependencies, label)
562+
return direct, nil
563+
}
564+
565+
// findDependenciesWithLabels recursively finds nodes with labels (resource and data nodes)
566+
// to build a dependency tree.
567+
func findDependenciesWithLabels(graph *gographviz.Graph, nodeName string) []string {
568+
dependencies := make([]string, 0)
569+
for destination := range graph.Edges.SrcToDsts[nodeName] {
570+
dependencyNode, exists := graph.Nodes.Lookup[destination]
571+
if !exists {
572+
continue
587573
}
588-
direct[label] = dependencies
574+
label, exists := dependencyNode.Attrs["label"]
575+
if !exists {
576+
dependencies = append(dependencies, findDependenciesWithLabels(graph, dependencyNode.Name)...)
577+
continue
578+
}
579+
label = strings.Trim(label, `"`)
580+
dependencies = append(dependencies, label)
589581
}
590-
return direct, nil
582+
return dependencies
583+
}
584+
585+
// findAgents recursively searches through resource dependencies
586+
// to find associated agents. Nested is required for indirect
587+
// dependency matching.
588+
func findAgents(resourceDependencies map[string][]string, agents map[string]*proto.Agent, resourceKey string) []*proto.Agent {
589+
resourceNode, exists := resourceDependencies[resourceKey]
590+
if !exists {
591+
return []*proto.Agent{}
592+
}
593+
// Associate resources that depend on an agent.
594+
resourceAgents := make([]*proto.Agent, 0)
595+
for _, dep := range resourceNode {
596+
var has bool
597+
agent, has := agents[dep]
598+
if !has {
599+
resourceAgents = append(resourceAgents, findAgents(resourceDependencies, agents, dep)...)
600+
continue
601+
}
602+
resourceAgents = append(resourceAgents, agent)
603+
}
604+
return resourceAgents
591605
}

0 commit comments

Comments
 (0)