Skip to content

Commit b41750d

Browse files
deansheatherkylecarbs
authored andcommitted
feat: run a terraform plan before creating workspaces with the given template parameters (#1732)
1 parent 0c7bc32 commit b41750d

22 files changed

+1422
-218
lines changed

cli/cliui/provisionerjob.go

+29-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cliui
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"io"
@@ -35,6 +36,9 @@ type ProvisionerJobOptions struct {
3536
FetchInterval time.Duration
3637
// Verbose determines whether debug and trace logs will be shown.
3738
Verbose bool
39+
// Silent determines whether log output will be shown unless there is an
40+
// error.
41+
Silent bool
3842
}
3943

4044
// ProvisionerJob renders a provisioner job with interactive cancellation.
@@ -133,12 +137,30 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
133137
return xerrors.Errorf("logs: %w", err)
134138
}
135139

140+
var (
141+
// logOutput is where log output is written
142+
logOutput = writer
143+
// logBuffer is where logs are buffered if opts.Silent is true
144+
logBuffer = &bytes.Buffer{}
145+
)
146+
if opts.Silent {
147+
logOutput = logBuffer
148+
}
149+
flushLogBuffer := func() {
150+
if opts.Silent {
151+
_, _ = io.Copy(writer, logBuffer)
152+
}
153+
}
154+
136155
ticker := time.NewTicker(opts.FetchInterval)
156+
defer ticker.Stop()
137157
for {
138158
select {
139159
case err = <-errChan:
160+
flushLogBuffer()
140161
return err
141162
case <-ctx.Done():
163+
flushLogBuffer()
142164
return ctx.Err()
143165
case <-ticker.C:
144166
updateJob()
@@ -160,8 +182,10 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
160182
}
161183
err = xerrors.New(job.Error)
162184
jobMutex.Unlock()
185+
flushLogBuffer()
163186
return err
164187
}
188+
165189
output := ""
166190
switch log.Level {
167191
case codersdk.LogLevelTrace, codersdk.LogLevelDebug:
@@ -176,14 +200,17 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
176200
case codersdk.LogLevelInfo:
177201
output = log.Output
178202
}
203+
179204
jobMutex.Lock()
180205
if log.Stage != currentStage && log.Stage != "" {
181206
updateStage(log.Stage, log.CreatedAt)
182207
jobMutex.Unlock()
183208
continue
184209
}
185-
_, _ = fmt.Fprintf(writer, "%s %s\n", Styles.Placeholder.Render(" "), output)
186-
didLogBetweenStage = true
210+
_, _ = fmt.Fprintf(logOutput, "%s %s\n", Styles.Placeholder.Render(" "), output)
211+
if !opts.Silent {
212+
didLogBetweenStage = true
213+
}
187214
jobMutex.Unlock()
188215
}
189216
}

cli/create.go

+33-4
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,40 @@ func create() *cobra.Command {
170170
}
171171
_, _ = fmt.Fprintln(cmd.OutOrStdout())
172172

173-
resources, err := client.TemplateVersionResources(cmd.Context(), templateVersion.ID)
173+
// Run a dry-run with the given parameters to check correctness
174+
after := time.Now()
175+
dryRun, err := client.CreateTemplateVersionDryRun(cmd.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
176+
WorkspaceName: workspaceName,
177+
ParameterValues: parameters,
178+
})
174179
if err != nil {
175-
return err
180+
return xerrors.Errorf("begin workspace dry-run: %w", err)
181+
}
182+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Planning workspace...")
183+
err = cliui.ProvisionerJob(cmd.Context(), cmd.OutOrStdout(), cliui.ProvisionerJobOptions{
184+
Fetch: func() (codersdk.ProvisionerJob, error) {
185+
return client.TemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
186+
},
187+
Cancel: func() error {
188+
return client.CancelTemplateVersionDryRun(cmd.Context(), templateVersion.ID, dryRun.ID)
189+
},
190+
Logs: func() (<-chan codersdk.ProvisionerJobLog, error) {
191+
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, after)
192+
},
193+
// Don't show log output for the dry-run unless there's an error.
194+
Silent: true,
195+
})
196+
if err != nil {
197+
// TODO (Dean): reprompt for parameter values if we deem it to
198+
// be a validation error
199+
return xerrors.Errorf("dry-run workspace: %w", err)
176200
}
201+
202+
resources, err := client.TemplateVersionDryRunResources(cmd.Context(), templateVersion.ID, dryRun.ID)
203+
if err != nil {
204+
return xerrors.Errorf("get workspace dry-run resources: %w", err)
205+
}
206+
177207
err = cliui.WorkspaceResources(cmd.OutOrStdout(), resources, cliui.WorkspaceResourcesOptions{
178208
WorkspaceName: workspaceName,
179209
// Since agent's haven't connected yet, hiding this makes more sense.
@@ -192,7 +222,6 @@ func create() *cobra.Command {
192222
return err
193223
}
194224

195-
before := time.Now()
196225
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.CreateWorkspaceRequest{
197226
TemplateID: template.ID,
198227
Name: workspaceName,
@@ -204,7 +233,7 @@ func create() *cobra.Command {
204233
return err
205234
}
206235

207-
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, before)
236+
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, after)
208237
if err != nil {
209238
return err
210239
}

cli/create_test.go

+48
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli_test
22

33
import (
44
"context"
5+
"database/sql"
56
"fmt"
67
"os"
78
"testing"
@@ -12,6 +13,8 @@ import (
1213

1314
"github.com/coder/coder/cli/clitest"
1415
"github.com/coder/coder/coderd/coderdtest"
16+
"github.com/coder/coder/coderd/database"
17+
"github.com/coder/coder/codersdk"
1518
"github.com/coder/coder/provisioner/echo"
1619
"github.com/coder/coder/provisionersdk/proto"
1720
"github.com/coder/coder/pty/ptytest"
@@ -249,6 +252,7 @@ func TestCreate(t *testing.T) {
249252
<-doneChan
250253
removeTmpDirUntilSuccess(t, tempDir)
251254
})
255+
252256
t.Run("WithParameterFileNotContainingTheValue", func(t *testing.T) {
253257
t.Parallel()
254258
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
@@ -279,6 +283,50 @@ func TestCreate(t *testing.T) {
279283
<-doneChan
280284
removeTmpDirUntilSuccess(t, tempDir)
281285
})
286+
287+
t.Run("FailedDryRun", func(t *testing.T) {
288+
t.Parallel()
289+
client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true})
290+
user := coderdtest.CreateFirstUser(t, client)
291+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
292+
Parse: echo.ParseComplete,
293+
ProvisionDryRun: []*proto.Provision_Response{
294+
{
295+
Type: &proto.Provision_Response_Complete{
296+
Complete: &proto.Provision_Complete{
297+
Error: "test error",
298+
},
299+
},
300+
},
301+
},
302+
})
303+
304+
// The template import job should end up failed, but we need it to be
305+
// succeeded so the dry-run can begin.
306+
version = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
307+
require.Equal(t, codersdk.ProvisionerJobFailed, version.Job.Status, "job is not failed")
308+
err := api.Database.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{
309+
ID: version.Job.ID,
310+
CompletedAt: sql.NullTime{
311+
Time: time.Now(),
312+
Valid: true,
313+
},
314+
UpdatedAt: time.Now(),
315+
Error: sql.NullString{},
316+
})
317+
require.NoError(t, err, "update provisioner job")
318+
319+
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
320+
cmd, root := clitest.New(t, "create", "test")
321+
clitest.SetupConfig(t, client, root)
322+
pty := ptytest.New(t)
323+
cmd.SetIn(pty.Input())
324+
cmd.SetOut(pty.Output())
325+
326+
err = cmd.Execute()
327+
require.Error(t, err)
328+
require.ErrorContains(t, err, "dry-run workspace")
329+
})
282330
}
283331

284332
func createTestParseResponseWithDefault(defaultValue string) []*proto.Parse_Response {

coderd/coderd.go

+7
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ func New(options *Options) *API {
207207
r.Get("/parameters", api.templateVersionParameters)
208208
r.Get("/resources", api.templateVersionResources)
209209
r.Get("/logs", api.templateVersionLogs)
210+
r.Route("/dry-run", func(r chi.Router) {
211+
r.Post("/", api.postTemplateVersionDryRun)
212+
r.Get("/{jobID}", api.templateVersionDryRun)
213+
r.Get("/{jobID}/resources", api.templateVersionDryRunResources)
214+
r.Get("/{jobID}/logs", api.templateVersionDryRunLogs)
215+
r.Patch("/{jobID}/cancel", api.patchTemplateVersionDryRunCancel)
216+
})
210217
})
211218
r.Route("/users", func(r chi.Router) {
212219
r.Get("/first", api.firstUser)

coderd/coderd_test.go

+26
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
9696
require.NoError(t, err, "upload file")
9797
workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
9898
require.NoError(t, err, "workspace resources")
99+
templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
100+
ParameterValues: []codersdk.CreateParameterRequest{},
101+
})
102+
require.NoError(t, err, "template version dry-run")
99103

100104
// Always fail auth from this point forward
101105
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
@@ -262,6 +266,27 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
262266
AssertAction: rbac.ActionRead,
263267
AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID).WithID(template.ID.String()),
264268
},
269+
"POST:/api/v2/templateversions/{templateversion}/dry-run": {
270+
// The first check is to read the template
271+
AssertAction: rbac.ActionRead,
272+
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
273+
},
274+
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}": {
275+
AssertAction: rbac.ActionRead,
276+
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
277+
},
278+
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/resources": {
279+
AssertAction: rbac.ActionRead,
280+
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
281+
},
282+
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/logs": {
283+
AssertAction: rbac.ActionRead,
284+
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
285+
},
286+
"PATCH:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/cancel": {
287+
AssertAction: rbac.ActionRead,
288+
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
289+
},
265290
"GET:/api/v2/provisionerdaemons": {
266291
StatusCode: http.StatusOK,
267292
AssertObject: rbac.ResourceProvisionerDaemon.WithID(provisionerds[0].ID.String()),
@@ -350,6 +375,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
350375
route = strings.ReplaceAll(route, "{hash}", file.Hash)
351376
route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String())
352377
route = strings.ReplaceAll(route, "{templateversion}", version.ID.String())
378+
route = strings.ReplaceAll(route, "{templateversiondryrun}", templateVersionDryRun.ID.String())
353379
route = strings.ReplaceAll(route, "{templatename}", template.Name)
354380
// Only checking org scoped params here
355381
route = strings.ReplaceAll(route, "{scope}", string(organizationParam.Scope))

coderd/database/dump.sql

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
2+
-- EXISTS".
3+
4+
-- Delete all jobs that use the new enum value.
5+
DELETE FROM
6+
provisioner_jobs
7+
WHERE
8+
type = 'template_version_dry_run'
9+
;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TYPE provisioner_job_type
2+
ADD VALUE IF NOT EXISTS 'template_version_dry_run';

coderd/database/models.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/parameter/compute.go

+14-5
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import (
1313

1414
// ComputeScope targets identifiers to pull parameters from.
1515
type ComputeScope struct {
16-
TemplateImportJobID uuid.UUID
17-
OrganizationID uuid.UUID
18-
UserID uuid.UUID
19-
TemplateID uuid.NullUUID
20-
WorkspaceID uuid.NullUUID
16+
TemplateImportJobID uuid.UUID
17+
OrganizationID uuid.UUID
18+
UserID uuid.UUID
19+
TemplateID uuid.NullUUID
20+
WorkspaceID uuid.NullUUID
21+
AdditionalParameterValues []database.ParameterValue
2122
}
2223

2324
type ComputeOptions struct {
@@ -142,6 +143,14 @@ func Compute(ctx context.Context, db database.Store, scope ComputeScope, options
142143
}
143144
}
144145

146+
// Finally, any additional parameter values declared in the input
147+
for _, v := range scope.AdditionalParameterValues {
148+
err = compute.injectSingle(v, false)
149+
if err != nil {
150+
return nil, xerrors.Errorf("inject single parameter value: %w", err)
151+
}
152+
}
153+
145154
values := make([]ComputedValue, 0, len(compute.computedParameterByName))
146155
for _, value := range compute.computedParameterByName {
147156
values = append(values, value)

0 commit comments

Comments
 (0)