Skip to content

Commit 6be8a37

Browse files
authored
feat: run a terraform plan before creating workspaces with the given template parameters (#1732)
1 parent cc87a0c commit 6be8a37

22 files changed

+1422
-218
lines changed

cli/cliui/provisionerjob.go

Lines changed: 29 additions & 2 deletions
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

Lines changed: 33 additions & 4 deletions
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

Lines changed: 48 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 26 additions & 0 deletions
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

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 9 additions & 0 deletions
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+
;
Lines changed: 2 additions & 0 deletions
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

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

coderd/parameter/compute.go

Lines changed: 14 additions & 5 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy