1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* Pauses progression of a deployment while external authorisation is requested. |
5
|
|
|
* This is performed via the default messaging service specified. |
6
|
|
|
* |
7
|
|
|
* Configure using the below code in your deploy.yml |
8
|
|
|
* |
9
|
|
|
* MessagingArguments is an arbitrary array of arguments which is understood by |
10
|
|
|
* the service specified as the ConfirmationMessagingService for this project. |
11
|
|
|
* See deploynaut/_config/messaging.yml for the default service configuration. |
12
|
|
|
* See deploynaut/_config/pipeline.yml for the default step configuration |
13
|
|
|
* |
14
|
|
|
* <code> |
15
|
|
|
* Steps: |
16
|
|
|
* RequestConfirmationStep: |
17
|
|
|
* Class: UserConfirmationStep |
18
|
|
|
* MaxDuration: 604800 # Auto time out after a week |
19
|
|
|
* Recipients: |
20
|
|
|
* - 021971373 |
21
|
|
|
* - [email protected] |
22
|
|
|
* # Time delay between each of the above recipients being sent out |
23
|
|
|
* RecipientsDelay: 4000 |
24
|
|
|
* Permissions: |
25
|
|
|
* # Permissions required to allow deployment. Ensure that the recipients above are assigned this |
26
|
|
|
* - APPROVE_DEPLOYMENT |
27
|
|
|
* Messages: |
28
|
|
|
* # Messages sent to all users (including <requester>) |
29
|
|
|
* Reject: 'Deployment for <project>/<environment> has been rejected' |
30
|
|
|
* Approve: 'Deployment for <project>/<environment> has been approved' |
31
|
|
|
* TimeOut: 'Deployment approval for <project>/<environment> has timed out due to no response' |
32
|
|
|
* # Messages only sent to requester |
33
|
|
|
* Request-Requester: 'You requested approval for deployment of <project>/<environment>. Cancel? <abortlink>' |
34
|
|
|
* # Messages only sent to specified recipients |
35
|
|
|
* Request-Recipient: 'Deployment for <project>/<environment> requested by <requester>. Approve? <approvelink>' |
36
|
|
|
* Subjects: |
37
|
|
|
* # Subject line for all users |
38
|
|
|
* Reject: 'Deployment for <project>/<environment>: Rejected' |
39
|
|
|
* Approve: 'Deployment for <project>/<environment>: Approved' |
40
|
|
|
* TimeOut: 'Deployment for <project>/<environment>: Timeout' |
41
|
|
|
* Request: 'Deployment for <project>/<environment>: Requested' |
42
|
|
|
* ServiceArguments: |
43
|
|
|
* # Additional arguments that make sense to the ConfirmationMessagingService |
44
|
|
|
* from: [email protected] |
45
|
|
|
* reply-to: [email protected] |
46
|
|
|
* </code> |
47
|
|
|
* |
48
|
|
|
* @method Member Responder() Member who has given an approval for this request |
49
|
|
|
* @property int $ResponderID |
50
|
|
|
* @property string $Approval |
51
|
|
|
* @property int $NotifiedGroup |
52
|
|
|
* @package deploynaut |
53
|
|
|
* @subpackage pipeline |
54
|
|
|
*/ |
55
|
|
|
class UserConfirmationStep extends LongRunningPipelineStep { |
56
|
|
|
|
57
|
|
|
/** |
58
|
|
|
* Messages |
59
|
|
|
*/ |
60
|
|
|
const ALERT_APPROVE = 'Approve'; |
61
|
|
|
const ALERT_TIMEOUT = 'TimeOut'; |
62
|
|
|
const ALERT_REQUEST = 'Request'; |
63
|
|
|
const ALERT_REJECT = 'Reject'; |
64
|
|
|
|
65
|
|
|
/** |
66
|
|
|
* Message roles |
67
|
|
|
*/ |
68
|
|
|
const ROLE_REQUESTER = 'Requester'; |
69
|
|
|
const ROLE_RECIPIENT = 'Recipient'; |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* @var array |
73
|
|
|
*/ |
74
|
|
|
private static $db = array( |
75
|
|
|
// A finished step is approved and a failed step is rejected. |
76
|
|
|
// Aborted confirmation is left as None |
77
|
|
|
'Approval' => "Enum('Approved,Rejected,None', 'None')", |
78
|
|
|
// If RecipientsDelay is specified, this value records the index of the most recently notified |
79
|
|
|
// group of users. This will be incremented once another level of fallback has been notified. |
80
|
|
|
// E.g. once primary admin has been notified, the secondary admin can be notified, and this |
81
|
|
|
// is incremented |
82
|
|
|
'NotifiedGroup' => 'Int' |
83
|
|
|
); |
84
|
|
|
|
85
|
|
|
/** |
86
|
|
|
* @var array |
87
|
|
|
*/ |
88
|
|
|
private static $defaults = array( |
89
|
|
|
'Approval' => 'None', |
90
|
|
|
'NotifiedGroup' => 0 |
91
|
|
|
); |
92
|
|
|
|
93
|
|
|
/** |
94
|
|
|
* @var array |
95
|
|
|
*/ |
96
|
|
|
private static $has_one = array( |
97
|
|
|
'Responder' => 'Member' |
98
|
|
|
); |
99
|
|
|
|
100
|
|
|
/** |
101
|
|
|
* This step depends on a configured messaging service |
102
|
|
|
* |
103
|
|
|
* @config |
104
|
|
|
* @var array |
105
|
|
|
*/ |
106
|
|
|
private static $dependencies = array( |
107
|
|
|
'MessagingService' => '%$ConfirmationMessagingService' |
108
|
|
|
); |
109
|
|
|
|
110
|
|
|
/** |
111
|
|
|
* Currently assigned messaging service |
112
|
|
|
* |
113
|
|
|
* @var ConfirmationMessagingService |
114
|
|
|
*/ |
115
|
|
|
private $messagingService = null; |
116
|
|
|
|
117
|
|
|
/** |
118
|
|
|
* Assign a messaging service for this step |
119
|
|
|
* |
120
|
|
|
* @param ConfirmationMessagingService $service |
121
|
|
|
*/ |
122
|
|
|
public function setMessagingService(ConfirmationMessagingService $service) { |
123
|
|
|
$this->messagingService = $service; |
124
|
|
|
} |
125
|
|
|
|
126
|
|
|
/** |
127
|
|
|
* Get the currently configured messaging service |
128
|
|
|
* |
129
|
|
|
* @return ConfirmationMessagingService |
130
|
|
|
*/ |
131
|
|
|
public function getMessagingService() { |
132
|
|
|
return $this->messagingService; |
133
|
|
|
} |
134
|
|
|
|
135
|
|
|
/** |
136
|
|
|
* Determine if the confirmation has been responded to (ether with acceptance, rejection, or cancelled) |
137
|
|
|
* |
138
|
|
|
* @return boolean |
139
|
|
|
*/ |
140
|
|
|
public function hasResponse() { |
141
|
|
|
return $this->Approval !== 'None'; |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
public function start() { |
145
|
|
|
parent::start(); |
146
|
|
|
|
147
|
|
|
// Just in case this step is being mistakenly restarted |
148
|
|
|
if($this->hasResponse()) { |
149
|
|
|
$this->log("{$this->Title} has already been processed with a response of {$this->Approval}"); |
150
|
|
|
$this->markFailed(); |
151
|
|
|
return false; |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
// Begin or process this step |
155
|
|
|
switch($this->Status) { |
156
|
|
|
case 'Started': |
157
|
|
|
return $this->checkStatus(); |
158
|
|
|
case 'Queued': |
159
|
|
|
return $this->startApproval(); |
160
|
|
|
default: |
161
|
|
|
$this->log("Unable to process {$this->Title} with status of {$this->Status}"); |
162
|
|
|
$this->markFailed(); |
163
|
|
|
return false; |
164
|
|
|
} |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
/** |
168
|
|
|
* Can the current user approve this pipeline? |
169
|
|
|
* |
170
|
|
|
* @param Member|null $member |
171
|
|
|
* @return boolean |
172
|
|
|
*/ |
173
|
|
|
public function canApprove($member = null) { |
174
|
|
|
return $this->Pipeline()->Environment()->canApprove($member); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
/** |
178
|
|
|
* When the recipient wishes to approve this request |
179
|
|
|
* |
180
|
|
|
* @return boolean True if successful |
181
|
|
|
*/ |
182
|
|
View Code Duplication |
public function approve() { |
|
|
|
|
183
|
|
|
// Check permission |
184
|
|
|
if(!$this->canApprove()) { |
185
|
|
|
return Security::permissionFailure( |
186
|
|
|
null, |
187
|
|
|
_t("UserConfirmationStep.DENYAPPROVE", "You do not have permission to approve this deployment") |
188
|
|
|
); |
189
|
|
|
} |
190
|
|
|
|
191
|
|
|
// Skip subsequent approvals if already approved / rejected |
192
|
|
|
if($this->hasResponse()) { |
193
|
|
|
return; |
194
|
|
|
} |
195
|
|
|
|
196
|
|
|
// Approve |
197
|
|
|
$this->Approval = 'Approved'; |
198
|
|
|
$this->log("{$this->Title} has been approved"); |
199
|
|
|
$this->ResponderID = Member::currentUserID(); |
200
|
|
|
$this->finish(); |
201
|
|
|
$this->sendMessage(self::ALERT_APPROVE); |
202
|
|
|
return true; |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
/** |
206
|
|
|
* When the recipient wishes to reject this request |
207
|
|
|
* |
208
|
|
|
* @return boolean True if successful |
209
|
|
|
*/ |
210
|
|
View Code Duplication |
public function reject() { |
|
|
|
|
211
|
|
|
// Check permission |
212
|
|
|
if(!$this->canApprove()) { |
213
|
|
|
return Security::permissionFailure( |
214
|
|
|
null, |
215
|
|
|
_t("UserConfirmationStep.DENYREJECT", "You do not have permission to reject this deployment") |
216
|
|
|
); |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
// Skip subsequent approvals if already approved / rejected |
220
|
|
|
if($this->hasResponse()) { |
221
|
|
|
return; |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
// Reject |
225
|
|
|
$this->Approval = 'Rejected'; |
226
|
|
|
$this->log("{$this->Title} has been rejected"); |
227
|
|
|
$this->ResponderID = Member::currentUserID(); |
228
|
|
|
$this->markFailed(false); |
229
|
|
|
$this->sendMessage(self::ALERT_REJECT); |
230
|
|
|
return true; |
231
|
|
|
} |
232
|
|
|
|
233
|
|
|
/** |
234
|
|
|
* Report the status of the current request queue and makes sure it hasn't overrun it's time allowed |
235
|
|
|
* |
236
|
|
|
* @return boolean True if not failed |
237
|
|
|
*/ |
238
|
|
|
protected function checkStatus() { |
239
|
|
|
// For running or queued tasks ensure that we have not exceeded |
240
|
|
|
// a reasonable time-elapsed to consider this job inactive |
241
|
|
|
if($this->isTimedOut()) { |
242
|
|
|
$days = round($this->MaxDuration / (24.0 * 3600.0), 1); |
243
|
|
|
$logMessage = "{$this->Title} took longer than {$this->MaxDuration} seconds ($days days) to be approved " |
244
|
|
|
. "and has timed out"; |
245
|
|
|
$this->log($logMessage); |
246
|
|
|
$this->markFailed(); |
247
|
|
|
$this->sendMessage(self::ALERT_TIMEOUT); |
248
|
|
|
return false; |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
// If operating on a delayed notification schedule, determine if there are further groups who should be notified |
252
|
|
|
if($delay = $this->getConfigSetting('RecipientsDelay')) { |
253
|
|
|
// Check group that should have been notified by now |
254
|
|
|
$age = $this->getAge(); |
255
|
|
|
$escallateGroup = intval($age / $delay); |
256
|
|
|
$recipients = $this->getConfigSetting('Recipients'); |
257
|
|
|
$lastGroup = count($recipients) - 1; |
258
|
|
|
// If we can notify the next group, do so |
259
|
|
|
// We only escallate one group at a time to ensure correct order is followed |
260
|
|
|
if($this->NotifiedGroup < $lastGroup && $this->NotifiedGroup < $escallateGroup) { |
261
|
|
|
$this->NotifiedGroup++; |
262
|
|
|
$groupDescription = is_array($recipients[$this->NotifiedGroup]) |
263
|
|
|
? implode(',', $recipients[$this->NotifiedGroup]) |
264
|
|
|
: $recipients[$this->NotifiedGroup]; |
265
|
|
|
$this->log("Escalating approval request to group {$this->NotifiedGroup}: '$groupDescription'"); |
266
|
|
|
// Send to selected group |
267
|
|
|
$this->sendMessage(self::ALERT_REQUEST, $this->NotifiedGroup); |
268
|
|
|
$this->write(); |
269
|
|
|
return true; |
270
|
|
|
} |
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
|
274
|
|
|
// While still running report no error, waiting for resque job to eventually finish. |
275
|
|
|
// Since this could potentially fill the log with hundreds of thousands of messages, |
276
|
|
|
// if it takes a few days to get a response, don't write anything. |
277
|
|
|
return true; |
278
|
|
|
} |
279
|
|
|
|
280
|
|
|
/** |
281
|
|
|
* Initiate the approval process |
282
|
|
|
*/ |
283
|
|
|
public function startApproval() { |
284
|
|
|
$this->Status = 'Started'; |
285
|
|
|
$this->log("Starting {$this->Title}..."); |
286
|
|
|
// Determine if we should use delayed notification |
287
|
|
|
$recipientGroup = 'all'; |
288
|
|
|
if($this->getConfigSetting('RecipientsDelay')) { |
289
|
|
|
$this->NotifiedGroup = $recipientGroup = 0; |
290
|
|
|
} |
291
|
|
|
// Send to selected group |
292
|
|
|
$this->sendMessage(self::ALERT_REQUEST, $recipientGroup); |
293
|
|
|
$this->write(); |
294
|
|
|
return true; |
295
|
|
|
} |
296
|
|
|
|
297
|
|
|
/** |
298
|
|
|
* Finds a message template for a given role and message |
299
|
|
|
* |
300
|
|
|
* @param string $role Role name for role-customised messages. Usually 'Requester' or 'Recipient' |
301
|
|
|
* @param string $messageID Message ID |
302
|
|
|
* @return array Resulting array(subject, message) |
303
|
|
|
*/ |
304
|
|
|
protected function generateMessageTemplate($role, $messageID) { |
305
|
|
|
$subject = $this->getConfigSetting('Subjects', "$messageID-$role") |
306
|
|
|
?: $this->getConfigSetting('Subjects', $messageID); |
307
|
|
|
$message = $this->getConfigSetting('Messages', "$messageID-$role") |
308
|
|
|
?: $this->getConfigSetting('Messages', $messageID); |
309
|
|
|
$substitutions = $this->getReplacements(); |
310
|
|
|
return $this |
311
|
|
|
->Pipeline() |
312
|
|
|
->injectMessageReplacements($message, $subject, $substitutions); |
313
|
|
|
} |
314
|
|
|
|
315
|
|
|
/** |
316
|
|
|
* Retrieve message replacements |
317
|
|
|
* |
318
|
|
|
* @return array |
319
|
|
|
*/ |
320
|
|
|
public function getReplacements() { |
321
|
|
|
// Get member who began this request |
322
|
|
|
return array_merge( |
323
|
|
|
$this->Pipeline()->getReplacements(), |
324
|
|
|
array( |
325
|
|
|
// Note that this actually displays the link to the interface to approve, |
326
|
|
|
// not the direct link to the approve action |
327
|
|
|
'<approvelink>' => Director::absoluteURL($this->Pipeline()->Environment()->Link()) |
328
|
|
|
) |
329
|
|
|
); |
330
|
|
|
} |
331
|
|
|
|
332
|
|
|
/** |
333
|
|
|
* Sends a message to a specified recipient(s) |
334
|
|
|
* |
335
|
|
|
* @param string $messageID Message ID. One of 'Reject', 'Approve', 'TimeOut' or 'Request' |
336
|
|
|
* @param mixed $recipientGroup Either a numeric index of the next recipient to send to, or "all" for all |
337
|
|
|
* This is used for delayed notification so that failover recipients can be notified. |
338
|
|
|
* @return boolean|null True if successful |
339
|
|
|
*/ |
340
|
|
|
protected function sendMessage($messageID, $recipientGroup = 'all') { |
341
|
|
|
// Add additionally configured arguments |
342
|
|
|
$arguments = $this->getConfigSetting('ServiceArguments') ?: array(); |
343
|
|
|
|
344
|
|
|
// Get member who began this request |
345
|
|
|
$author = $this->Pipeline()->Author(); |
346
|
|
|
|
347
|
|
|
// Send message to requester |
348
|
|
|
if($recipientGroup === 'all' || $recipientGroup === 0) { |
349
|
|
|
list($subject, $message) = $this->generateMessageTemplate(self::ROLE_REQUESTER, $messageID); |
350
|
|
|
if($subject && $message) { |
351
|
|
|
$this->log("{$this->Title} sending $messageID message to {$author->Email}"); |
352
|
|
|
$extra = array('subject' => $subject); |
353
|
|
|
$this->messagingService->sendMessage($this, $message, $author, array_merge($arguments, $extra)); |
354
|
|
|
} |
355
|
|
|
} |
356
|
|
|
|
357
|
|
|
// Filter recipients based on group |
358
|
|
|
$recipients = $this->getConfigSetting('Recipients'); |
359
|
|
|
if(is_array($recipients) && $recipientGroup !== 'all') { |
360
|
|
|
$recipients = isset($recipients[$recipientGroup]) |
361
|
|
|
? $recipients[$recipientGroup] |
362
|
|
|
: null; |
363
|
|
|
} |
364
|
|
|
if(empty($recipients)) { |
365
|
|
|
$this->log("Skipping sending message to empty recipients"); |
366
|
|
|
return; |
367
|
|
|
} |
368
|
|
|
|
369
|
|
|
// Send to recipients |
370
|
|
|
list($subject, $message) = $this->generateMessageTemplate(self::ROLE_RECIPIENT, $messageID); |
371
|
|
|
if($subject && $message && $recipients) { |
372
|
|
|
$recipientsStr = is_array($recipients) ? implode(',', $recipients) : $recipients; |
373
|
|
|
$this->log("{$this->Title} sending $messageID message to $recipientsStr"); |
374
|
|
|
$extra = array('subject' => $subject); |
375
|
|
|
$this->messagingService->sendMessage($this, $message, $recipients, array_merge($arguments, $extra)); |
376
|
|
|
} |
377
|
|
|
} |
378
|
|
|
|
379
|
|
|
public function getRunningDescription() { |
380
|
|
|
|
381
|
|
|
// Don't show options if this step has already been confirmed |
382
|
|
|
if($this->hasResponse() || !$this->isRunning()) { |
383
|
|
|
return; |
384
|
|
|
} |
385
|
|
|
|
386
|
|
|
return 'This deployment is currently awaiting approval before it can complete.'; |
387
|
|
|
} |
388
|
|
|
|
389
|
|
|
public function allowedActions() { |
390
|
|
|
// Don't show options if this step has already been confirmed or can't be confirmed |
391
|
|
|
if($this->hasResponse() || !$this->isRunning() || !$this->canApprove()) { |
392
|
|
|
return parent::allowedActions(); |
393
|
|
|
} |
394
|
|
|
|
395
|
|
|
// Return actions |
396
|
|
|
return array( |
397
|
|
|
'approve' => array( |
398
|
|
|
'ButtonText' => 'Approve', |
399
|
|
|
'ButtonType' => 'btn-success', |
400
|
|
|
'Link' => $this->Pipeline()->StepLink('approve'), |
401
|
|
|
'Title' => 'Approves the current deployment, allowing it to continue' |
402
|
|
|
), |
403
|
|
|
'reject' => array( |
404
|
|
|
'ButtonText' => 'Reject', |
405
|
|
|
'ButtonType' => 'btn-danger', |
406
|
|
|
'Link' => $this->Pipeline()->StepLink('reject'), |
407
|
|
|
'Title' => 'Deny the request to release this deployment' |
408
|
|
|
) |
409
|
|
|
); |
410
|
|
|
} |
411
|
|
|
|
412
|
|
|
} |
413
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.