-
Notifications
You must be signed in to change notification settings - Fork 157
/
workspace_run_helpers.go
355 lines (305 loc) · 11.4 KB
/
workspace_run_helpers.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package tfe
import (
"fmt"
"log"
"math"
"math/rand"
"time"
"github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
func createWorkspaceRun(d *schema.ResourceData, meta interface{}, isDestroyRun bool, currentRetryAttempts int) error {
var runArgs map[string]interface{}
if isDestroyRun {
// destroy block is optional, if it is not set then destroy action is noop for a destroy type run
destroyArgs, ok := d.GetOk("destroy")
if !ok {
return nil
}
runArgs = destroyArgs.([]interface{})[0].(map[string]interface{})
} else {
createArgs, ok := d.GetOk("apply")
if !ok {
// apply block is optional, if it is not set then set a random ID to allow for consistent result after apply ops
d.SetId(fmt.Sprintf("%d", rand.New(rand.NewSource(time.Now().UnixNano())).Int()))
return nil
}
runArgs = createArgs.([]interface{})[0].(map[string]interface{})
}
retryBOMin := runArgs["retry_backoff_min"].(int)
retryBOMax := runArgs["retry_backoff_max"].(int)
retry := runArgs["retry"].(bool)
retryMaxAttempts := runArgs["retry_attempts"].(int)
isInitialRunAttempt := currentRetryAttempts == 0
// only perform exponential backoff during retries, not during initial attempt
if !isInitialRunAttempt {
select {
case <-ctx.Done():
return fmt.Errorf("context canceled: %w", ctx.Err())
case <-time.After(backoff(float64(retryBOMin), float64(retryBOMax), currentRetryAttempts)):
}
}
config := meta.(ConfiguredClient)
workspaceID := d.Get("workspace_id").(string)
log.Printf("[DEBUG] Read workspace by ID %s", workspaceID)
ws, err := config.Client.Workspaces.ReadByID(ctx, workspaceID)
if err != nil {
return fmt.Errorf(
"error reading workspace %s: %w", workspaceID, err)
}
runConfig := tfe.RunCreateOptions{
Workspace: ws,
IsDestroy: tfe.Bool(isDestroyRun),
Message: tfe.String(fmt.Sprintf(
"Triggered by tfe_workspace_run resource via terraform-provider-tfe on %s",
time.Now().Format(time.UnixDate),
)),
// autoapply is set to false to give the tfe_workspace_run resource full control of run confirmation.
AutoApply: tfe.Bool(false),
}
log.Printf("[DEBUG] Create run for workspace: %s", ws.ID)
run, err := config.Client.Runs.Create(ctx, runConfig)
if err != nil {
return fmt.Errorf(
"error creating run for workspace %s: %w", ws.ID, err)
}
if run == nil {
log.Printf("[ERROR] The client returned both a nil run and nil error, this should not happen")
return fmt.Errorf(
"the client returned both a nil run and nil error for workspace %s, this should not happen", ws.ID)
}
log.Printf("[DEBUG] Run %s created for workspace %s", run.ID, ws.ID)
isPlanOp := true
run, err = awaitRun(meta, run.ID, ws.ID, ws.Organization.Name, isPlanOp, planPendingStatuses, planTerminalStatuses)
if err != nil {
return err
}
if run.Status == tfe.RunErrored || run.Status == tfe.RunStatus(tfe.PolicySoftFailed) {
if retry && currentRetryAttempts < retryMaxAttempts {
currentRetryAttempts++
log.Printf("[INFO] Run errored during plan, retrying run, retry count: %d", currentRetryAttempts)
return createWorkspaceRun(d, meta, isDestroyRun, currentRetryAttempts)
}
return fmt.Errorf("run errored during plan, use the run ID %s to debug error", run.ID)
}
// A run is complete when it is successfully planned with no changes to apply
if run.Status == tfe.RunPlannedAndFinished {
log.Printf("[INFO] Plan finished, no changes to apply")
d.SetId(run.ID)
return nil
}
if run.Status == tfe.RunPolicyOverride {
log.Printf("[INFO] Policy check soft-failed, awaiting manual override for run %q", run.ID)
run, err = awaitRun(meta, run.ID, ws.ID, ws.Organization.Name, isPlanOp, map[tfe.RunStatus]bool{tfe.RunPolicyOverride: true}, confirmationDoneStatuses)
if err != nil {
return err
}
}
manualConfirm := runArgs["manual_confirm"].(bool)
// if human approval is required, an apply will auto kick off when run is manually approved
if manualConfirm {
confirmationPendingStatus := map[tfe.RunStatus]bool{}
confirmationPendingStatus[run.Status] = true
log.Printf("[INFO] Plan complete, waiting for manual confirm before proceeding run %q", run.ID)
run, err = awaitRun(meta, run.ID, ws.ID, ws.Organization.Name, isPlanOp, confirmationPendingStatus, confirmationDoneStatuses)
if err != nil {
return err
}
} else {
// if human approval is NOT required, go ahead and kick off an apply
log.Printf("[INFO] Plan complete, confirming an apply for run %q", run.ID)
err = config.Client.Runs.Apply(ctx, run.ID, tfe.RunApplyOptions{
Comment: tfe.String(fmt.Sprintf("Run confirmed by tfe_workspace_run resource via terraform-provider-tfe on %s",
time.Now().Format(time.UnixDate))),
})
if err != nil {
refreshed, fetchErr := config.Client.Runs.Read(ctx, run.ID)
if fetchErr != nil {
err = fmt.Errorf("%w\n additionally, got an error while reading the run: %s", err, fetchErr.Error())
}
return fmt.Errorf("run errored while applying run %s (waited til status %s, currently status %s): %w", run.ID, run.Status, refreshed.Status, err)
}
}
/**
Checking for waitForRun arg towards the tail of this function ensures that all
human actions necessary above has already been done.
**/
waitForRun := runArgs["wait_for_run"].(bool)
if !waitForRun {
d.SetId(run.ID)
return nil
}
isPlanOp = false
run, err = awaitRun(meta, run.ID, ws.ID, ws.Organization.Name, isPlanOp, applyPendingStatuses, applyDoneStatuses)
if err != nil {
return err
}
switch run.Status {
case tfe.RunApplied:
log.Printf("[INFO] Apply complete for run %q", run.ID)
d.SetId(run.ID)
return nil
case tfe.RunErrored:
if retry && currentRetryAttempts < retryMaxAttempts {
currentRetryAttempts++
log.Printf("[INFO] Run errored during apply, retrying run, retry count: %d", currentRetryAttempts)
return createWorkspaceRun(d, meta, isDestroyRun, currentRetryAttempts)
}
return fmt.Errorf("run errored during apply, use the run ID %s to debug error", run.ID)
default:
// unexpected run states including canceled and discarded is handled by this block
return fmt.Errorf("run %s entered unexpected state %s, expected %s state", run.ID, run.Status, tfe.RunApplied)
}
}
func awaitRun(meta interface{}, runID string, wsID string, organization string, isPlanOp bool, runPendingStatus map[tfe.RunStatus]bool,
runTerminalStatus map[tfe.RunStatus]bool) (*tfe.Run, error) {
config := meta.(ConfiguredClient)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return nil, fmt.Errorf("context canceled: %w", ctx.Err())
case <-time.After(backoff(backoffMin, backoffMax, i)):
log.Printf("[DEBUG] Polling run %s", runID)
run, err := config.Client.Runs.Read(ctx, runID)
if err != nil {
log.Printf("[ERROR] Could not read run %s: %v", runID, err)
continue
}
_, runHasEnded := runTerminalStatus[run.Status]
_, runIsInProgress := runPendingStatus[run.Status]
switch {
case runHasEnded:
log.Printf("[INFO] Run %s has reached a terminal state: %s", runID, run.Status)
return run, nil
case runIsInProgress:
log.Printf("[DEBUG] Reading workspace %s", wsID)
ws, err := config.Client.Workspaces.ReadByID(ctx, wsID)
if err != nil {
log.Printf("[ERROR] Unable to read workspace %s: %v", wsID, err)
continue
}
// if the workspace is locked and the current run has not started, assume that workspace was locked for other purposes.
// display a message to indicate that the workspace is waiting to be manually unlocked before the run can proceed
if ws.Locked && ws.CurrentRun != nil {
currentRun, err := config.Client.Runs.Read(ctx, ws.CurrentRun.ID)
if err != nil {
log.Printf("[ERROR] Unable to read current run %s: %v", ws.CurrentRun.ID, err)
continue
}
if currentRun.Status == tfe.RunPending {
log.Printf("[INFO] Waiting for manually locked workspace to be unlocked")
continue
}
}
// if this run is the current run in it's workspace, display it's position in the organization queue
if ws.CurrentRun != nil && ws.CurrentRun.ID == runID {
runPositionInOrg, err := readRunPositionInOrgQueue(meta, runID, organization)
if err != nil {
log.Printf("[ERROR] Unable to read run position in organization queue %v", err)
continue
}
orgCapacity, err := config.Client.Organizations.ReadCapacity(ctx, organization)
if err != nil {
log.Printf("[ERROR] Unable to read capacity for organization %s: %v", organization, err)
continue
}
if runPositionInOrg > 0 {
log.Printf("[INFO] Waiting for %d queued run(s) before starting run", runPositionInOrg-orgCapacity.Running)
continue
}
}
// if this run is not the current run in it's workspace, display it's position in the workspace queue
runPositionInWorkspace, err := readRunPositionInWorkspaceQueue(meta, runID, wsID, isPlanOp, ws.CurrentRun)
if err != nil {
log.Printf("[ERROR] Unable to read run position in workspace queue %v", err)
continue
}
if runPositionInWorkspace > 0 {
log.Printf(
"[INFO] Waiting for %d run(s) to finish in workspace %s before being queued...",
runPositionInWorkspace,
ws.Name,
)
continue
}
log.Printf("[INFO] Waiting for run %s, status is %s", runID, run.Status)
default:
log.Printf("[INFO] Run %s has entered state: %s", runID, run.Status)
return run, nil
}
}
}
}
func readRunPositionInOrgQueue(meta interface{}, runID string, organization string) (int, error) {
config := meta.(ConfiguredClient)
position := 0
options := tfe.ReadRunQueueOptions{}
for {
runQueue, err := config.Client.Organizations.ReadRunQueue(ctx, organization, options)
if err != nil {
return position, fmt.Errorf("unable to read run queue for organization %s: %w", organization, err)
}
for _, item := range runQueue.Items {
if runID == item.ID {
position = item.PositionInQueue
return position, nil
}
}
// Exit the loop when we've seen all pages.
if runQueue.CurrentPage >= runQueue.TotalPages {
break
}
options.PageNumber = runQueue.NextPage
}
return position, nil
}
func readRunPositionInWorkspaceQueue(meta interface{}, runID string, wsID string, isPlanOp bool, currentRun *tfe.Run) (int, error) {
config := meta.(ConfiguredClient)
position := 0
options := tfe.RunListOptions{}
found := false
for {
runList, err := config.Client.Runs.List(ctx, wsID, &options)
if err != nil {
return position, fmt.Errorf("unable to read run list for workspace %s: %w", wsID, err)
}
for _, item := range runList.Items {
if !found {
if runID == item.ID {
found = true
}
continue
}
// ignore runs with final states while computing queue count
switch item.Status {
case tfe.RunApplied, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunErrored, tfe.RunPlannedAndFinished:
continue
case tfe.RunPlanned:
if isPlanOp {
continue
}
}
position++
if currentRun != nil && currentRun.ID == item.ID {
return position, nil
}
}
// Exit the loop when we've seen all pages.
if runList.CurrentPage >= runList.TotalPages {
break
}
options.PageNumber = runList.NextPage
}
return position, nil
}
// perform exponential backoff based on the iteration and
// limited by the provided min and max durations in milliseconds.
func backoff(min, max float64, iter int) time.Duration {
backoff := math.Pow(2, float64(iter)/5) * min
if backoff > max {
backoff = max
}
return time.Duration(backoff) * time.Millisecond
}