Skip to content

Commit 391640b

Browse files
committed
Add dry run for provisioners
1 parent 0e7f380 commit 391640b

File tree

12 files changed

+586
-374
lines changed

12 files changed

+586
-374
lines changed

coderd/coderdtest/coderdtest_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestNew(t *testing.T) {
2828
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
2929
history, err := client.CreateWorkspaceHistory(context.Background(), "me", workspace.Name, coderd.CreateWorkspaceHistoryRequest{
3030
ProjectVersionID: version.ID,
31-
Transition: database.WorkspaceTransitionCreate,
31+
Transition: database.WorkspaceTransitionStart,
3232
})
3333
require.NoError(t, err)
3434
coderdtest.AwaitWorkspaceHistoryProvisioned(t, client, "me", workspace.Name, history.Name)

coderd/provisionerdaemons.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,17 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty
209209

210210
// Compute parameters for the workspace to consume.
211211
parameters, err := projectparameter.Compute(ctx, server.Database, projectparameter.Scope{
212-
OrganizationID: organization.ID,
213-
ProjectID: project.ID,
214-
ProjectVersionID: projectVersion.ID,
215-
UserID: user.ID,
216-
WorkspaceID: workspace.ID,
217-
WorkspaceHistoryID: workspaceHistory.ID,
212+
OrganizationID: organization.ID,
213+
ProjectID: project.ID,
214+
ProjectVersionID: projectVersion.ID,
215+
UserID: sql.NullString{
216+
String: user.ID,
217+
Valid: true,
218+
},
219+
WorkspaceID: uuid.NullUUID{
220+
UUID: workspace.ID,
221+
Valid: true,
222+
},
218223
})
219224
if err != nil {
220225
return nil, failJob(fmt.Sprintf("compute parameters: %s", err))

database/dump.sql

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

database/migrations/000002_projects.up.sql

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,14 @@ CREATE TABLE project_version_log (
113113
output varchar(1024) NOT NULL
114114
);
115115

116-
CREATE TYPE workspace_transition AS ENUM (
117-
'start',
118-
'stop',
119-
'delete'
120-
);
121-
122116
-- Stores resources for a specific project version and workspace transition.
123117
-- This is to display resources for the started and stopped transitions.
124118
CREATE TABLE project_version_resource (
125119
id uuid NOT NULL UNIQUE,
126120
project_version_id uuid NOT NULL REFERENCES project_version (id) ON DELETE CASCADE,
127121
created_at timestamptz NOT NULL,
128-
transition workspace_transition NOT NULL,
122+
destroy_on_stop boolean NOT NULL,
129123
type varchar(256) NOT NULL,
130124
name varchar(64) NOT NULL,
131-
UNIQUE(project_version_id, transition, type, name)
125+
UNIQUE(project_version_id, type, name)
132126
);

database/migrations/000003_workspaces.up.sql

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ CREATE TABLE workspace (
88
UNIQUE(owner_id, name)
99
);
1010

11+
CREATE TYPE workspace_transition AS ENUM (
12+
'start',
13+
'stop',
14+
'delete'
15+
);
16+
1117
-- Workspace transition represents a change in workspace state.
1218
CREATE TABLE workspace_history (
1319
id uuid NOT NULL UNIQUE,
@@ -27,18 +33,11 @@ CREATE TABLE workspace_history (
2733
UNIQUE(workspace_id, name)
2834
);
2935

30-
CREATE TYPE workspace_resource_state AS ENUM (
31-
'created',
32-
'changed',
33-
'destroyed'
34-
);
35-
3636
-- Cloud resources produced by a provision job.
3737
CREATE TABLE workspace_resource (
3838
id uuid NOT NULL UNIQUE,
3939
created_at timestamptz NOT NULL,
4040
workspace_history_id uuid NOT NULL REFERENCES workspace_history (id) ON DELETE CASCADE,
41-
state workspace_resource_state NOT NULL,
4241
-- Resource type produced by a provisioner.
4342
-- eg. "google_compute_instance"
4443
type varchar(256) NOT NULL,

database/models.go

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

provisioner/terraform/provision.go

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package terraform
22

33
import (
44
"bufio"
5+
"context"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -38,12 +39,6 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP
3839
return xerrors.Errorf("terraform version %q is too old. required >= %q", version.String(), minimumTerraformVersion.String())
3940
}
4041

41-
env := map[string]string{
42-
// Makes sequential runs significantly faster.
43-
// https://github.com/hashicorp/terraform/blob/d35bc0531255b496beb5d932f185cbcdb2d61a99/internal/command/cliconfig/cliconfig.go#L24
44-
"TF_PLUGIN_CACHE_DIR": os.ExpandEnv("$HOME/.terraform.d/plugin-cache"),
45-
}
46-
4742
reader, writer := io.Pipe()
4843
defer reader.Close()
4944
defer writer.Close()
@@ -68,6 +63,107 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP
6863
}
6964
t.logger.Debug(ctx, "ran initialization")
7065

66+
if request.DryRun {
67+
return t.runTerraformPlan(ctx, terraform, request, stream)
68+
} else {
69+
return t.runTerraformApply(ctx, terraform, request, stream, statefilePath)
70+
}
71+
}
72+
73+
func (t *terraform) runTerraformPlan(ctx context.Context, terraform *tfexec.Terraform, request *proto.Provision_Request, stream proto.DRPCProvisioner_ProvisionStream) error {
74+
env := map[string]string{}
75+
options := []tfexec.PlanOption{tfexec.JSON(true)}
76+
for _, param := range request.ParameterValues {
77+
switch param.DestinationScheme {
78+
case proto.ParameterDestination_ENVIRONMENT_VARIABLE:
79+
env[param.Name] = param.Value
80+
case proto.ParameterDestination_PROVISIONER_VARIABLE:
81+
options = append(options, tfexec.Var(fmt.Sprintf("%s=%s", param.Name, param.Value)))
82+
default:
83+
return xerrors.Errorf("unsupported parameter type %q for %q", param.DestinationScheme, param.Name)
84+
}
85+
}
86+
err := terraform.SetEnv(env)
87+
if err != nil {
88+
return xerrors.Errorf("apply environment variables: %w", err)
89+
}
90+
91+
resources := make([]*proto.Resource, 0)
92+
reader, writer := io.Pipe()
93+
defer reader.Close()
94+
defer writer.Close()
95+
closeChan := make(chan struct{})
96+
go func() {
97+
defer close(closeChan)
98+
decoder := json.NewDecoder(reader)
99+
for {
100+
var log terraformProvisionLog
101+
err := decoder.Decode(&log)
102+
if err != nil {
103+
return
104+
}
105+
106+
logLevel, err := convertTerraformLogLevel(log.Level)
107+
if err != nil {
108+
// Not a big deal, but we should handle this at some point!
109+
continue
110+
}
111+
_ = stream.Send(&proto.Provision_Response{
112+
Type: &proto.Provision_Response_Log{
113+
Log: &proto.Log{
114+
Level: logLevel,
115+
Output: log.Message,
116+
},
117+
},
118+
})
119+
120+
if log.Change != nil && log.Change.Action == "create" {
121+
resources = append(resources, &proto.Resource{
122+
Name: log.Change.Resource.ResourceName,
123+
Type: log.Change.Resource.ResourceType,
124+
})
125+
}
126+
127+
if log.Diagnostic == nil {
128+
continue
129+
}
130+
131+
// If the diagnostic is provided, let's provide a bit more info!
132+
logLevel, err = convertTerraformLogLevel(log.Diagnostic.Severity)
133+
if err != nil {
134+
continue
135+
}
136+
_ = stream.Send(&proto.Provision_Response{
137+
Type: &proto.Provision_Response_Log{
138+
Log: &proto.Log{
139+
Level: logLevel,
140+
Output: log.Diagnostic.Detail,
141+
},
142+
},
143+
})
144+
}
145+
}()
146+
147+
terraform.SetStdout(writer)
148+
t.logger.Debug(ctx, "running plan")
149+
_, err = terraform.Plan(ctx, options...)
150+
if err != nil {
151+
return xerrors.Errorf("apply terraform: %w", err)
152+
}
153+
t.logger.Debug(ctx, "ran plan")
154+
<-closeChan
155+
156+
return stream.Send(&proto.Provision_Response{
157+
Type: &proto.Provision_Response_Complete{
158+
Complete: &proto.Provision_Complete{
159+
Resources: resources,
160+
},
161+
},
162+
})
163+
}
164+
165+
func (t *terraform) runTerraformApply(ctx context.Context, terraform *tfexec.Terraform, request *proto.Provision_Request, stream proto.DRPCProvisioner_ProvisionStream, statefilePath string) error {
166+
env := map[string]string{}
71167
options := []tfexec.ApplyOption{tfexec.JSON(true)}
72168
for _, param := range request.ParameterValues {
73169
switch param.DestinationScheme {
@@ -79,12 +175,12 @@ func (t *terraform) Provision(request *proto.Provision_Request, stream proto.DRP
79175
return xerrors.Errorf("unsupported parameter type %q for %q", param.DestinationScheme, param.Name)
80176
}
81177
}
82-
err = terraform.SetEnv(env)
178+
err := terraform.SetEnv(env)
83179
if err != nil {
84180
return xerrors.Errorf("apply environment variables: %w", err)
85181
}
86182

87-
reader, writer = io.Pipe()
183+
reader, writer := io.Pipe()
88184
defer reader.Close()
89185
defer writer.Close()
90186
go func() {
@@ -171,6 +267,17 @@ type terraformProvisionLog struct {
171267
Message string `json:"@message"`
172268

173269
Diagnostic *terraformProvisionLogDiagnostic `json:"diagnostic"`
270+
Change *terraformProvisionLogChange `json:"change"`
271+
}
272+
273+
type terraformProvisionLogChange struct {
274+
Action string `json:"action"`
275+
Resource *terraformProvisionLogResource `json:"resource"`
276+
}
277+
278+
type terraformProvisionLogResource struct {
279+
ResourceType string `json:"resource_type"`
280+
ResourceName string `json:"resource_name"`
174281
}
175282

176283
type terraformProvisionLogDiagnostic struct {

provisioner/terraform/provision_test.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,42 @@ func TestProvision(t *testing.T) {
8989
"main.tf": `a`,
9090
},
9191
Error: true,
92+
}, {
93+
Name: "dryrun-single-resource",
94+
Files: map[string]string{
95+
"main.tf": `resource "null_resource" "A" {}`,
96+
},
97+
Request: &proto.Provision_Request{
98+
DryRun: true,
99+
},
100+
Response: &proto.Provision_Response{
101+
Type: &proto.Provision_Response_Complete{
102+
Complete: &proto.Provision_Complete{
103+
Resources: []*proto.Resource{{
104+
Name: "A",
105+
Type: "null_resource",
106+
}},
107+
},
108+
},
109+
},
110+
}, {
111+
Name: "dryrun-conditional-single-resource",
112+
Files: map[string]string{
113+
"main.tf": `
114+
variable "test" {
115+
default = "no"
116+
}
117+
resource "null_resource" "A" {
118+
count = var.test == "yes" ? 1 : 0
119+
}`,
120+
},
121+
Response: &proto.Provision_Response{
122+
Type: &proto.Provision_Response_Complete{
123+
Complete: &proto.Provision_Complete{
124+
Resources: nil,
125+
},
126+
},
127+
},
92128
}} {
93129
testCase := testCase
94130
t.Run(testCase.Name, func(t *testing.T) {
@@ -106,6 +142,7 @@ func TestProvision(t *testing.T) {
106142
if testCase.Request != nil {
107143
request.ParameterValues = testCase.Request.ParameterValues
108144
request.State = testCase.Request.State
145+
request.DryRun = testCase.Request.DryRun
109146
}
110147
response, err := api.Provision(ctx, request)
111148
require.NoError(t, err)
@@ -125,7 +162,9 @@ func TestProvision(t *testing.T) {
125162
}
126163

127164
require.NoError(t, err)
128-
require.Greater(t, len(msg.GetComplete().State), 0)
165+
if !request.DryRun {
166+
require.Greater(t, len(msg.GetComplete().State), 0)
167+
}
129168

130169
resourcesGot, err := json.Marshal(msg.GetComplete().Resources)
131170
require.NoError(t, err)

0 commit comments

Comments
 (0)