Completed
Pull Request — master (#488)
by Helpful
1295:51 queued 1292:33
created

Pipeline::isAborted()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 3
rs 10
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
3
/**
4
 * Class Pipeline
5
 * A Pipeline represents one action (e.g. 'Deploy'), separated out into multiple {@link PipelineStep} objects. A
6
 * Pipeline acts on a single Git SHA, and processes that SHA through multiple steps (e.g. smoketesting it, loading a
7
 * maintenance page up, deploying the SHA, smoketesting the site after deploy, removing the maintenance page.
8
 *
9
 * Pipeline defines a series of "Steps" through the YML configuration for the "regular" execution, but it also hardcodes
10
 * rollback steps separately. These are handled in a special way if the "regular" steps fail. See beginRollback below.
11
 * The rollback step progression is handled by checkPipelineStatus as normal though.
12
 *
13
 * If the regular steps fail, they must notify the pipeline via markFailed. This will either fail the pipeline or put it
14
 * in the "Rollback" state (this appends the rollback steps to the end of the step list automatically). These extra
15
 * steps are in fact no different to regular steps - they must succeed and fail in the standard way.
16
 *
17
 * Regardless of the rollback outcome, the processing will always end up in finaliseRollback - either via step calling
18
 * markFailed, or by checkPipelineStatus running out of steps to run. This concludes the pipeline.
19
 *
20
 * So to recap, the last functions that are called on Pipeline are:
21
 * - markComplete, if the pipeline has been successful (i.e. no rollback)
22
 * - markFailed, if a step has failed and there is no possibility of rollback.
23
 * - finaliseRollback, when rollback was executed, regardless whether it was successful or not.
24
 *
25
 * Here is an example configuration that utilises all options provided by this class:
26
 *
27
 * <code>
28
 * PipelineConfig:
29
 *   DependsOnProject: "ss3"
30
 *   DependsOnEnvironment: "deploytest"
31
 *   FilteredCommits: "DNFinishedCommits"
32
 *   Description: >
33
 *     In order to deploy to this environment instance manager confirmation is required.<br />
34
 *     Only successful deployments to the test server are permitted to be selected.
35
 *   # Contacts to notify, as well as the author of this pipeline
36
 *   Tests: # Smoke tests used by both rollback and smoke test
37
 *     Home:
38
 *       URL: http://www.mysite.com/
39
 *       ExpectStatus: 200
40
 *     Videos:
41
 *       URL: http://www.mysite.com/videos/
42
 *       ExpectStatus: 200
43
 *   Recipients:
44
 *     Success:
45
 *       - [email protected]
46
 *     Failure:
47
 *       - [email protected]
48
 *     Abort:
49
 *       - [email protected]
50
 *     RollbackStarted:
51
 *       - [email protected]
52
 *     RollbackSuccess:
53
 *       - [email protected]
54
 *     RollbackFailure:
55
 *       - [email protected]
56
 *   Messages:
57
 *     # Messages sent to all users (including <requester>)
58
 *     Success: 'Deployment for <project>/<environment> has successfully completed.'
59
 *     Failure: 'Deployment for <project>/<environment> has failed.'
60
 *     Abort: 'Deployment for <project>/<environment> has been aborted.'
61
 *     RollbackStarted: 'Deployment failed, rollback for <project>/<environment> has begun.'
62
 *     RollbackSuccess: 'Rollback for <project>/<environment> has successfully completed.'
63
 *     RollbackFailure: 'Rollback for <project>/<environment> has failed.'
64
 *   Subjects:
65
 *     # Subject line for all users
66
 *     Success: 'Deployment for <project>/<environment>: Success'
67
 *     Failure: 'Deployment for <project>/<environment>: Failure'
68
 *     Abort: 'Deployment for <project>/<environment>: Aborted'
69
 *     RollbackStarted: 'Deployment failed, rollback for <project>/<environment> has begun.'
70
 *     RollbackSuccess: 'Rollback for <project>/<environment> has successfully completed.'
71
 *     RollbackFailure: 'Rollback for <project>/<environment> has failed.'
72
 *   ServiceArguments:
73
 *     # Additional arguments that make sense to the ConfirmationMessagingService
74
 *     from: [email protected]
75
 *     reply-to: [email protected]
76
 * RollbackStep1:
77
 *   Class: RollbackStep
78
 *   # ... first step to be performed conditionally (RollbackStep is expected here).
79
 * RollbackStep2:
80
 *   Class: SmokeTestPipelineStep
81
 *   # ... second step to be performed conditionally (SmokeTestPipelineStep is expected here).
82
 * Steps:
83
 *   ... named steps.
84
 * </code>
85
 *
86
 * @see docs/en/pipelines.md for further information
87
 *
88
 *
89
 * @property string $Status
90
 * @property string $Config
91
 * @property string $SHA
92
 * @property bool $DryRun
93
 * @property string $LastMessageSent
94
 *
95
 * @method Member Author()
96
 * @property int AuthorID
97
 * @method DNEnvironment Environment()
98
 * @property int EnvironmentID
99
 * @method PipelineStep CurrentStep()
100
 * @property int CurrentStepID
101
 * @method DNDataTransfer PreviousSnapshot()
102
 * @property int PreviousSnapshotID
103
 * @method DNDeployment PreviousDeployment()
104
 * @property int PreviousDeploymentID
105
 * @method DNDeployment CurrentDeployment()
106
 * @property int CurrentDeploymentID
107
 * @method PipelineStep RollbackStep1()
108
 * @property int RollbackStep1ID
109
 * @method PipelineStep RollbackStep2()
110
 * @property int RollbackStep2ID
111
 *
112
 * @method HasManyList Steps()
113
 */
114
class Pipeline extends DataObject implements PipelineData {
115
116
	/**
117
	 * Messages
118
	 */
119
	const ALERT_ABORT = 'Abort';
120
	const ALERT_SUCCESS = 'Success';
121
	const ALERT_FAILURE = 'Failure';
122
	const ALERT_ROLLBACK_STARTED = 'RollbackStarted';
123
	const ALERT_ROLLBACK_SUCCESS = 'RollbackSuccess';
124
	const ALERT_ROLLBACK_FAILURE = 'RollbackFailure';
125
126
	/**
127
	 * - Status: Current status of this Pipeline. Running means 'currently executing a {@link PipelineStep}'.
128
	 *           See the {@link PipelineControllerTask} class for why this is important.
129
	 * - SHA:    This is the Git SHA that the pipeline is acting on. This is passed into the {@link PipelineStep}
130
	 *           objects so that the steps know what to smoketest, deploy, etc.
131
	 *
132
	 * @var array
133
	 */
134
	private static $db = array(
135
		'Status' => 'Enum("Running,Complete,Failed,Aborted,Rollback,Queued", "Queued")',
136
		'Config' => 'Text', // serialized array of configuration for this pipeline
137
		'SHA' => 'Varchar(255)',
138
		'DryRun' => 'Boolean', // Try if this deployment is a test dryrun
139
		'LastMessageSent' => 'Varchar(255)' // ID of last message sent
140
	);
141
142
	/**
143
	 * - Author:      The {@link Member} object that started this pipeline running.
144
	 * - Environment: The {@link DNEnvironment} that this Pipeline is associated to.
145
	 * - CurrentStep: The current {@link PipelineStep} object that is keeping this pipeline alive. This should be
146
	 *                cleared when the last step is complete.
147
	 *
148
	 * @var array
149
	 */
150
	private static $has_one = array(
151
		'Author' => 'Member',
152
		'Environment' => 'DNEnvironment',
153
		'CurrentStep' => 'PipelineStep',
154
		// to be used for rollbacks
155
		"PreviousSnapshot" => "DNDataTransfer",
156
		"PreviousDeployment" => 'DNDeployment',
157
		"CurrentDeployment" => "DNDeployment",
158
		"RollbackStep1" => "PipelineStep",
159
		"RollbackStep2" => "PipelineStep"
160
	);
161
162
	/**
163
	 * - Steps: These are ordered by the `PipelineStep`.`Order` attribute.
164
	 *
165
	 * @var array
166
	 */
167
	private static $has_many = array(
168
		'Steps' => 'PipelineStep'
169
	);
170
171
	/**
172
	 * @var array
173
	 */
174
	private static $summary_fields = array(
175
		'ID' => 'ID',
176
		'Status' => 'Status',
177
		'SHA' => 'SHA',
178
		'Author.Title' => 'Author',
179
		'CurrentStep.Name' => 'Current Step',
180
		'Created' => 'Created',
181
		'LastEdited' => 'Last Updated'
182
	);
183
184
	/**
185
	 * @var string
186
	 */
187
	private static $default_sort = '"Created" DESC';
188
189
	/**
190
	 * @var array
191
	 */
192
	private static $cast = array(
193
		'RunningDescription' => 'HTMLText'
194
	);
195
196
	/**
197
	 * @config
198
	 * @var array
199
	 */
200
	private static $dependencies = array(
201
		'MessagingService' => '%$ConfirmationMessagingService'
202
	);
203
204
	/**
205
	 * Currently assigned messaging service
206
	 *
207
	 * @var ConfirmationMessagingService
208
	 */
209
	private $messagingService = null;
210
211
	/**
212
	 * @param ConfirmationMessagingService $service
213
	 */
214
	public function setMessagingService(ConfirmationMessagingService $service) {
215
		$this->messagingService = $service;
216
	}
217
218
	/**
219
	 * @return ConfirmationMessagingService
220
	 */
221
	public function getMessagingService() {
222
		return $this->messagingService;
223
	}
224
225
	public function __isset($property) {
226
		// Workaround fixed in https://github.com/silverstripe/silverstripe-framework/pull/3201
227
		// Remove this once we update to a version of framework which supports this
228
		if($property === 'MessagingService') {
229
			return !empty($this->messagingService);
230
		}
231
		return parent::__isset($property);
232
	}
233
234
	/**
235
	 * Retrieve message template replacements
236
	 *
237
	 * @return array
238
	 */
239
	public function getReplacements() {
240
		// Get member who began this request
241
		$author = $this->Author();
242
		$environment = $this->Environment();
243
		return array(
244
			'<abortlink>' => Director::absoluteURL($this->Environment()->Link()),
245
			'<pipelinelink>' => Director::absoluteURL($this->Link()),
246
			'<requester>' => $author->Title,
247
			'<requester-email>' => $author->Email,
248
			'<environment>' => $environment->Name,
249
			'<project>' => $environment->Project()->Name,
250
			'<commitsha>' => $this->SHA
251
		);
252
	}
253
254
	/**
255
	 * Title of this step
256
	 *
257
	 * @return string
258
	 */
259
	public function getTitle() {
260
		return "Pipeline {$this->ID} (Status: {$this->Status})";
261
	}
262
263
	/**
264
	 * @param Member $member
0 ignored issues
show
Documentation introduced by
Should the type for parameter $member not be Member|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
265
	 */
266
	public function canAbort($member = null) {
267
		// Owner can abort
268
		$member = $member ?: Member::currentUser();
269
		if(!$member) {
270
			return false;
271
		}
272
		if($member->ID == $this->AuthorID) {
273
			return true;
274
		}
275
276
		// Check environment permission
277
		return $this->Environment()->canAbort($member);
278
	}
279
280
	/**
281
	 * Get status of currently running step
282
	 *
283
	 * @return string Status description (html format)
284
	 */
285
	public function getRunningDescription() {
286
		if(!$this->isActive()) {
287
			return 'This pipeline is not currently running';
288
		}
289
		$result = '';
290
		if($step = $this->CurrentStep()) {
291
			$result = $step->getRunningDescription();
292
		}
293
		return $result ?: 'This pipeline is currently running';
294
	}
295
296
	/**
297
	 * Get options for the currently running pipeline, if and only if it is currently running
298
	 *
299
	 * @return ArrayList List of items with a Link and Title attribute
300
	 */
301
	public function RunningOptions() {
302
		if(!$this->isActive()) {
303
			return null;
304
		}
305
		$actions = array();
306
307
		// Let current step update the current list of options
308
		if(($step = $this->CurrentStep()) && ($step->isRunning())) {
309
			$actions = $step->allowedActions();
310
		}
311
		return new ArrayList($actions);
312
	}
313
314
	/**
315
	 * Get possible logs for the currently pipeline
316
	 *
317
	 * @return ArrayList List of logs with a Link and Title attribute
318
	 */
319
	public function LogOptions() {
320
		if(!$this->isActive()) {
321
			return null;
322
		}
323
324
		$logs = array();
325
326
		$logs[] = array(
327
			'ButtonText' => 'Pipeline Log',
328
			'Link' => $this->Link()
329
		);
330
331
		if($this->PreviousSnapshotID > 0) {
332
			$logs[] = array(
333
				'ButtonText' => 'Snapshot Log',
334
				'Link' => $this->PreviousSnapshot()->Link()
335
			);
336
		}
337
338
		if($this->CurrentDeploymentID > 0) {
339
			$logs[] = array(
340
				'ButtonText' => 'Deployment Log',
341
				'Link' => $this->CurrentDeployment()->Link()
342
			);
343
		}
344
345
		// Get logs from rollback steps (only for RollbackSteps).
346
		$rollbackSteps = array($this->RollbackStep1(), $this->RollbackStep2());
347
		foreach($rollbackSteps as $rollback) {
348
			if($rollback->exists() && $rollback->ClassName == 'RollbackStep') {
349
				if($rollback->RollbackDeploymentID > 0) {
0 ignored issues
show
Documentation introduced by
The property RollbackDeploymentID does not exist on object<PipelineStep>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
350
					$logs[] = array(
351
						'ButtonText' => 'Rollback Log',
352
						'Link' => $rollback->RollbackDeployment()->Link()
0 ignored issues
show
Documentation Bug introduced by
The method RollbackDeployment does not exist on object<PipelineStep>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
353
					);
354
				}
355
356
				if($rollback->RollbackDatabaseID > 0) {
0 ignored issues
show
Documentation introduced by
The property RollbackDatabaseID does not exist on object<PipelineStep>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
357
					$logs[] = array(
358
						'ButtonText' => 'Rollback DB Log',
359
						'Link' => $rollback->RollbackDatabase()->Link()
0 ignored issues
show
Documentation Bug introduced by
The method RollbackDatabase does not exist on object<PipelineStep>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
360
					);
361
				}
362
			}
363
		}
364
365
		return new ArrayList($logs);
366
	}
367
368
	/**
369
	 * Cached of config merged with defaults
370
	 *
371
	 * @var array|null
372
	 */
373
	protected $mergedConfig;
374
375
	/**
376
	 * Get this pipeline configuration. If the configuration has been serialized
377
	 * and saved into the Config field, it'll use that. If that field is empty,
378
	 * it'll read the YAML file directly and return that instead.
379
	 *
380
	 * @return array
381
	 * @throws Exception
382
	 */
383
	public function getConfigData() {
384
		// Lazy load if necessary
385
		$data = null;
386
		if(!$this->Config && ($data = $this->Environment()->loadPipelineConfig())) {
387
			$this->Config = serialize($data);
388
		}
389
390
		// Merge with defaults
391
		if($this->Config) {
392 View Code Duplication
			if(!$this->mergedConfig) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

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.

Loading history...
393
				$this->mergedConfig = $data ?: unserialize($this->Config);
394
				if($default = self::config()->default_config) {
395
					Config::merge_array_low_into_high($this->mergedConfig, $default);
396
				}
397
			}
398
			return $this->mergedConfig;
399
		}
400
401
		// Fail if no data available
402
		$path = $this->Environment()->getPipelineFilename();
403
		throw new Exception(sprintf('YAML configuration for pipeline not found at path "%s"', $path));
404
	}
405
406
	public function setConfig($data) {
407
		$this->mergedConfig = null;
408
		return parent::setField('Config', $data);
0 ignored issues
show
Comprehensibility Bug introduced by
It seems like you call parent on a different method (setField() instead of setConfig()). Are you sure this is correct? If so, you might want to change this to $this->setField().

This check looks for a call to a parent method whose name is different than the method from which it is called.

Consider the following code:

class Daddy
{
    protected function getFirstName()
    {
        return "Eidur";
    }

    protected function getSurName()
    {
        return "Gudjohnsen";
    }
}

class Son
{
    public function getFirstName()
    {
        return parent::getSurname();
    }
}

The getFirstName() method in the Son calls the wrong method in the parent class.

Loading history...
409
	}
410
411
	/**
412
	 * Retrieve the value of a specific config setting
413
	 *
414
	 * @param string $setting Settings
415
	 * @return mixed Value of setting, or null if not set
416
	 */
417 View Code Duplication
	public function getConfigSetting($setting) {
0 ignored issues
show
Unused Code introduced by
The parameter $setting is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
418
		$source = $this->getConfigData();
419
420
		foreach(func_get_args() as $setting) {
421
			if(empty($source[$setting])) {
422
				return null;
423
			}
424
			$source = $source[$setting];
425
		}
426
427
		return $source;
428
	}
429
430
	/**
431
	 * @return FieldList
432
	 */
433
	public function getCMSFields() {
434
		$fields = new FieldList(new TabSet('Root'));
435
436
		// Main fields
437
		$fields->addFieldsToTab('Root.Main', array(
438
			TextField::create('SHA')
439
				->setDescription('SHA of the commit this pipeline is running against')
440
				->performReadonlyTransformation(),
441
			TextField::create('AuthorName', 'Author', ($author = $this->Author()) ? $author->Title : null)
442
				->setDescription('Person who initiated this pipeline')
443
				->performReadonlyTransformation(),
444
			DropdownField::create('Status', 'Status', $this->dbObject('Status')->enumValues()),
445
			DropdownField::create('CurrentStepID', 'Current Step', $this->Steps()->map('ID', 'TreeTitle')),
446
			TextField::create(
447
				'CurrentDeployment_Label',
448
				'Current Deployment',
449
				$this->CurrentDeployment()->getTitle()
450
			)	->setDescription('Deployment generated by this pipeline')
451
				->performReadonlyTransformation(),
452
		));
453
454
		// Backup fields
455
		$fields->addFieldsToTab('Root.Backups', array(
456
			TextField::create(
457
				'PreviousDeployment_Label',
458
				'Previous Deployment',
459
				$this->PreviousDeployment()->getTitle()
460
			)	->setDescription('Prior deployment to revert to if this pipeline fails')
461
				->performReadonlyTransformation(),
462
			TextField::create(
463
				'PreviousSnapshot_Label',
464
				'Previous DB Snapshot',
465
				$this->PreviousSnapshot()->getTitle()
466
			)	->setDescription('Database backup to revert to if this pipeline fails')
467
				->performReadonlyTransformation()
468
		));
469
470
		if($log = $this->LogContent()) {
471
			$fields->addFieldToTab(
472
				'Root.Main',
473
				ToggleCompositeField::create(
474
					'PipelineLog',
475
					'Pipeline Log',
476
					LiteralField::create('LogText', nl2br(Convert::raw2xml($log)))
477
				)
478
			);
479
		}
480
481
		// Steps
482
		$stepConfig = GridFieldConfig_RecordEditor::create();
483
		$steps = GridField::create('Steps', 'Pipeline Steps', $this->Steps(), $stepConfig);
484
		$fields->addFieldsToTab('Root.PipelineSteps', $steps);
0 ignored issues
show
Documentation introduced by
$steps is of type object<GridField>, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
485
486
		return $fields;
487
	}
488
489
	/**
490
	 * Return a dependent {@link DNEnvironment} based on this pipeline's dependent environment configuration.
491
	 *
492
	 * @return DNEnvironment
493
	 */
494
	public function getDependentEnvironment() {
495
		// dependent environment not available
496
		$projectName = $this->getConfigSetting('PipelineConfig', 'DependsOnProject');
497
		$environmentName = $this->getConfigSetting('PipelineConfig', 'DependsOnEnvironment');
498
		if(empty($projectName) || empty($environmentName)) {
499
			return null;
500
		}
501
502
		$project = DNProject::get()->filter('Name', $projectName)->first();
503
		if(!($project && $project->exists())) {
504
			throw new Exception(sprintf('Could not find dependent project "%s"', $projectName));
505
		}
506
507
		$environment = DNEnvironment::get()->filter(array(
508
			'ProjectID' => $project->ID,
509
			'Name' => $environmentName
510
		))->first();
511
512
		if(!($environment && $environment->exists())) {
513
			throw new Exception(sprintf(
514
				'Could not find dependent environment "%s" in project "%s"',
515
				$environmentName,
516
				$projectName
517
			));
518
		}
519
520
		return $environment;
521
	}
522
523
	/**
524
	 * Generate a step from a name, config, and sort order
525
	 *
526
	 * @throws Exception
527
	 * @param string $name
528
	 * @param array $stepConfig
529
	 * @param int $order
530
	 * @return PipelineStep
531
	 */
532
	protected function generateStep($name, $stepConfig, $order = 0) {
533
		$stepClass = isset($stepConfig['Class']) ? $stepConfig['Class'] : $stepConfig;
534
535
		if(empty($stepClass)) {
536
			throw new Exception(
537
				sprintf('Missing or empty Class specifier for step "%s"', $name)
538
			);
539
		}
540
541
		if(!is_subclass_of($stepClass, 'PipelineStep')) {
542
			throw new Exception(
543
				sprintf('%s is not a valid "Class" field name for step "%s"', var_export($stepClass, true), $name)
544
			);
545
		}
546
547
		$step = $stepClass::create();
548
		$step->Name = $name;
549
		$step->PipelineID = $this->ID;
550
		$step->Order = $order;
551
		$step->Status = 'Queued';
552
		$step->Config = serialize($stepConfig);
553
		$step->write();
554
555
		return $step;
556
	}
557
558
	/**
559
	 * Starts the pipeline process.
560
	 *
561
	 * Reads a YAML configuration from the linked {@link DNEnvironment}
562
	 * and builds the {@link PipelineStep} objects and runs them.
563
	 *
564
	 * Note that this method doesn't actually start any {@link PipelineStep} objects, that is handled by
565
	 * {@link self::checkPipelineStatus()}, and the daemon running the process.
566
	 *
567
	 * @throws LogicException
568
	 * @return boolean
569
	 */
570
	public function start() {
571
		// Ensure there are no other running {@link Pipeline} objects for this {@link DNEnvironment}
572
		// Requires that $this->EnvironmentID has been set
573
		$env = $this->Environment();
574
		if(!($env && $env->exists())) {
575
			throw new LogicException("This pipeline needs a valid environment to run on.");
576
		}
577
578
		if($env->HasCurrentPipeline()) {
579
			throw new LogicException("You can only run one pipeline at a time on this environment.");
580
		}
581
582
		$this->write(); // ensure we've written this record first
583
584
		// Instantiate steps.
585
		foreach($this->getConfigSetting('Steps') as $name => $stepConfig) {
586
			$this->pushPipelineStep($name, $stepConfig);
587
		}
588
589
		$this->Status = 'Running';
590
		$this->write();
591
592
		$this->log('Started logging for this pipeline!');
593
594
		return true;
595
	}
596
597
	/**
598
	 * Mark this Pipeline as completed.
599
	 */
600
	public function markComplete() {
601
		$this->Status = "Complete";
602
		$this->log("Pipeline completed successfully.");
603
		$this->write();
604
		// Some steps may pre-emptively send a success message before the pipeline itself has completed
605
		if($this->LastMessageSent !== self::ALERT_SUCCESS) {
606
			$this->sendMessage(self::ALERT_SUCCESS);
607
		}
608
	}
609
610
	/**
611
	 * @return bool true if this Pipeline has successfully completed all {@link PipelineStep} steps already.
612
	 */
613
	public function isComplete() {
614
		return $this->Status == "Complete";
615
	}
616
617
	/**
618
	 * True if the pipeline is running but NOT doing a rollback
619
	 *
620
	 * @return bool
621
	 */
622
	public function isRunning() {
623
		return $this->Status == "Running";
624
	}
625
626
	/**
627
	 * True if the pipeline is running or doing a rollback
628
	 *
629
	 * @return bool
630
	 */
631
	public function isActive() {
632
		return $this->isRunning() || $this->isRollback();
633
	}
634
635
	/**
636
	 * Push a step to the end of a pipeline
637
	 *
638
	 * @param string $name
639
	 * @param array $stepConfig
640
	 * @return PipelineStep
641
	 */
642
	private function pushPipelineStep($name, $stepConfig) {
643
		$lastStep = $this->Steps()->sort("Order DESC")->first();
644
		$order = $lastStep ? $lastStep->Order + 1 : 1;
645
		return $this->generateStep($name, $stepConfig, $order);
646
	}
647
648
	/**
649
	 * The rollback has finished - close the pipeline and send relevant messages.
650
	 */
651
	protected function finaliseRollback() {
652
653
		// Figure out the status by inspecting specific rollback steps.
654
		$success = true;
655
		$rollback1 = $this->RollbackStep1();
656
		$rollback2 = $this->RollbackStep2();
657
		if(!empty($rollback1) && $rollback1->Status == 'Failed') {
658
			$success = false;
659
		}
660
		if(!empty($rollback2) && $rollback2->Status == 'Failed') {
661
			$success = false;
662
		}
663
664
		// Send messages.
665
		if($success) {
666
			$this->log("Pipeline failed, but rollback completed successfully.");
667
			$this->sendMessage(self::ALERT_ROLLBACK_SUCCESS);
668
		} else {
669
			$this->log("Pipeline failed, rollback failed.");
670
			$this->sendMessage(self::ALERT_ROLLBACK_FAILURE);
671
		}
672
673
		// Finish off the pipeline - rollback will only be triggered on a failed pipeline.
674
		$this->Status = 'Failed';
675
		$this->write();
676
	}
677
678
	/**
679
	 * Initiate a rollback. Moves the pipeline to the 'Rollback' status.
680
	 */
681
	protected function beginRollback() {
682
		$this->log("Beginning rollback...");
683
		$this->sendMessage(self::ALERT_ROLLBACK_STARTED);
684
685
		// Add rollback step.
686
		$configRollback1 = $this->getConfigSetting('RollbackStep1');
687
		$stepRollback1 = $this->pushPipelineStep('RollbackStep1', $configRollback1);
688
		$this->RollbackStep1ID = $stepRollback1->ID;
689
		$this->CurrentStepID = $stepRollback1->ID;
690
		$this->Status = 'Rollback';
691
692
		// Add smoke test step, if available, for later processing.
693
		$configRollback2 = $this->getConfigSetting('RollbackStep2');
694
		if($configRollback2) {
695
			$stepRollback2 = $this->pushPipelineStep('RollbackStep2', $configRollback2);
696
			$this->RollbackStep2ID = $stepRollback2->ID;
697
		}
698
699
		$this->write();
700
701
		$stepRollback1->start();
702
	}
703
704
	/**
705
	 * Check if pipeline currently permits a rollback.
706
	 * This could be influenced by both the current state and by the specific configuration.
707
	 *
708
	 * @return boolean
709
	 */
710
	protected function canStartRollback() {
711
		// The rollback cannot run twice.
712
		if($this->isRollback()) {
713
			return false;
714
		}
715
716
		// Rollbacks must be configured.
717
		if(!$this->getConfigSetting('RollbackStep1')) {
718
			return false;
719
		}
720
721
		// On dryrun let rollback run
722
		if($this->DryRun) {
723
			return true;
724
		}
725
726
		// Pipeline must have ran a deployment to be able to rollback.
727
		$deploy = $this->CurrentDeployment();
728
		$previous = $this->PreviousDeployment();
729
		if(!$deploy->exists() || !$previous->exists()) {
730
			return false;
731
		}
732
733
		return true;
734
	}
735
736
	/**
737
	 * Notify Pipeline that a step has failed and failure processing should kick in. If rollback steps are present
738
	 * the pipeline will be put into 'Rollback' state. After rollback is complete, regardless of the rollback result,
739
	 * the pipeline will be failed.
740
	 *
741
	 * @param bool $notify Set to false to disable notifications for this failure
742
	 */
743
	public function markFailed($notify = true) {
744
		// Abort all running or queued steps.
745
		$steps = $this->Steps();
746
		foreach($steps as $step) {
747
			if($step->isQueued() || $step->isRunning()) {
748
				$step->abort();
749
			}
750
		}
751
752
		if($this->canStartRollback()) {
753
			$this->beginRollback();
754
		} else if($this->isRollback()) {
755
			$this->finaliseRollback();
756
		} else {
757
			// Not able to roll back - fail immediately.
758
			$this->Status = 'Failed';
759
			$this->log("Pipeline failed, not running rollback (not configured or not applicable yet).");
760
			$this->write();
761
			if($notify) {
762
				$this->sendMessage(self::ALERT_FAILURE);
763
			}
764
		}
765
	}
766
767
	/**
768
	 * @return bool true if this Pipeline failed to execute all {@link PipelineStep} steps successfully
769
	 */
770
	public function isFailed() {
771
		return $this->Status == "Failed";
772
	}
773
774
	/**
775
	 * @return bool true if this Pipeline is rolling back.
776
	 */
777
	public function isRollback() {
778
		return $this->Status == "Rollback";
779
	}
780
781
	/**
782
	 * Mark this Pipeline as aborted
783
	 */
784
	public function markAborted() {
785
		$this->Status = 'Aborted';
786
		$logMessage = sprintf(
787
			"Pipeline processing aborted. %s (%s) aborted the pipeline",
788
			Member::currentUser()->Name,
789
			Member::currentUser()->Email
790
		);
791
		$this->log($logMessage);
792
		$this->write();
793
794
		// Abort all running or queued steps.
795
		$steps = $this->Steps();
796
		foreach($steps as $step) {
797
			if($step->isQueued() || $step->isRunning()) {
798
				$step->abort();
799
			}
800
		}
801
802
		// Send notification to users about this event
803
		$this->sendMessage(self::ALERT_ABORT);
804
	}
805
806
	/**
807
	 * Finds a message template for a given role and message
808
	 *
809
	 * @param string $messageID Message ID
810
	 * @return array Resulting array(subject, message)
811
	 */
812
	protected function generateMessageTemplate($messageID) {
813
		$subject = $this->getConfigSetting('PipelineConfig', 'Subjects', $messageID);
814
		$message = $this->getConfigSetting('PipelineConfig', 'Messages', $messageID);
815
		$substitutions = $this->getReplacements();
816
		return $this->injectMessageReplacements($message, $subject, $substitutions);
817
	}
818
819
	/**
820
	 * Substitute templated variables into the given message and subject
821
	 *
822
	 * @param string $message
823
	 * @param string $subject
824
	 * @param array $substitutions
825
	 * @return array Resulting array(subject, message)
826
	 */
827
	public function injectMessageReplacements($message, $subject, $substitutions) {
828
		// Handle empty messages
829
		if(empty($subject) && empty($message)) {
830
			return array(null, null);
831
		}
832
833
		// Check if there's a role specific message
834
		$subjectText = str_replace(
835
			array_keys($substitutions),
836
			array_values($substitutions),
837
			$subject ?: $message
838
		);
839
		$messageText = str_replace(
840
			array_keys($substitutions),
841
			array_values($substitutions),
842
			$message ?: $subject
843
		);
844
845
846
		return array($subjectText, $messageText);
847
	}
848
849
	/**
850
	 * Sends a specific message to all marked recipients, including the author of this pipeline
851
	 *
852
	 * @param string $messageID Message ID. One of 'Abort', 'Success', or 'Failure', or some custom message
853
	 * @return boolean|null True if successful
854
	 */
855
	public function sendMessage($messageID) {
856
		// Check message, subject, and additional arguments to include
857
		list($subject, $message) = $this->generateMessageTemplate($messageID);
858
		if(empty($subject) || empty($message)) {
859
			$this->log("Skipping sending message. None configured for $messageID");
860
			return true;
861
		}
862
863
		// Save last sent message
864
		$this->LastMessageSent = $messageID;
865
		$this->write();
866
867
		// Setup messaging arguments
868
		$arguments = array_merge(
869
			$this->getConfigSetting('PipelineConfig', 'ServiceArguments') ?: array(),
870
			array('subject' => $subject)
871
		);
872
873
		// Send message to author
874
		if($author = $this->Author()) {
875
			$this->log("Pipeline sending $messageID message to {$author->Email}");
876
			$this->messagingService->sendMessage($this, $message, $author, $arguments);
877
		} else {
878
			$this->log("Skipping sending message to missing author");
879
		}
880
881
		// Get additional recipients
882
		$recipients = $this->getConfigSetting('PipelineConfig', 'Recipients', $messageID);
883
		if(empty($recipients)) {
884
			$this->log("Skipping sending message to empty recipients");
885
		} else {
886
			$recipientsStr = is_array($recipients) ? implode(',', $recipients) : $recipients;
887
			$this->log("Pipeline sending $messageID message to $recipientsStr");
888
			$this->messagingService->sendMessage($this, $message, $recipients, $arguments);
889
		}
890
	}
891
892
	/**
893
	 * @return bool true if this Pipeline has been aborted
894
	 */
895
	public function isAborted() {
896
		return $this->Status === "Aborted";
897
	}
898
899
	/**
900
	 * This method should be called only by the {@link CheckPipelineStatus} controller. It iterates through all the
901
	 * {@link PipelineStep} objects associated with this Pipeline, and finds a place where the pipeline has stalled
902
	 * (where one step has completed, but the next one has yet to start). It will then start the next step if required.
903
	 *
904
	 * We check here whether the {@link PipelineStep} finished successfully, and will mark the Pipeline as Failed if
905
	 * the step failed, but this is only a fallback, and should not be relied upon. The individual {@link PipelineStep}
906
	 * should mark itself as failed and then call {@link Pipeline::markFailed()} directly.
907
	 *
908
	 * If the Pipeline has run out of steps, then it will mark the pipeline as completed.
909
	 */
910
	public function checkPipelineStatus() {
911
		$message = "";
912
913
		if(!$this->isActive()) {
914
			$message = "Pipeline::checkPipelineStatus() should only be called on running or rolling back pipelines.";
915
		}
916
917
		if(!$this->ID || !$this->isInDB()) {
918
			$message = "Pipeline::checkPipelineStatus() can only be called on pipelines already saved.";
919
		}
920
921
		$currentStep = ($this->CurrentStep() && $this->CurrentStep()->isInDB())
922
			? $this->CurrentStep()
923
			: null;
924
925
		if($currentStep && $currentStep->PipelineID != $this->ID) {
926
			$message = sprintf(
927
				"The current step (#%d) has a pipeline ID (#%d) that doesn't match this pipeline's ID (#%d).",
928
				$currentStep->ID,
929
				$currentStep->PipelineID,
930
				$this->ID
931
			);
932
		}
933
934
		if($message) {
935
			$this->log($message);
936
			throw new LogicException($message);
937
		}
938
939
		// Fallback check only: this shouldn't be called unless a {@link PipelineStep} has been implemented incorrectly
940
		if($currentStep && $currentStep->isFailed() && !$this->isFailed() && !$this->isRollback()) {
941
			$this->log(sprintf("Marking pipeline step (#%d) as failed - this pipeline step needs to be amended to mark"
942
				. " the pipeline (as well as itself) as failed to ensure consistency.",
943
				$this->CurrentStep()->ID
944
			));
945
946
			$this->markFailed();
947
			return;
948
		}
949
950
		// If this is the first time the Pipeline is run, then we don't have a CurrentStep, so set it,
951
		// start it running, and return
952
		if(!$currentStep) {
953
			$step = $this->Steps()->first();
954
			$this->CurrentStepID = $step->ID;
955
			$this->write();
956
957
			$this->log("Starting first pipeline step...");
958
			$step->start();
959
		} else if($currentStep->isFinished()) {
960
			// Sort through the list of {@link PipelineStep} objects to find the next step we need to start.
961
			$this->log("Finding next step to execute...");
962
			$nextStep = $this->findNextStep();
963
964
			if(!$nextStep) {
965
966
				// Special handling, since the main pipeline has already failed at this stage.
967
				if($this->isRollback()) {
968
					$this->finaliseRollback();
969
					return false;
970
				}
971
972
				// Double check for any steps that failed, but didn't notify the pipeline via markFailed.
973
				$failedSteps = PipelineStep::get()->filter(array(
974
					'PipelineID' => $this->ID,
975
					'Status' => 'Failed'
976
				))->count();
977
				if($failedSteps) {
978
					$this->log('At least one of the steps has failed marking the pipeline as failed');
979
					$this->markFailed();
980
					return false;
981
				}
982
983
				// We've reached the end of this pipeline successfully!
984
				$this->markComplete();
985
				return;
986
			} else {
987
				$this->CurrentStepID = $nextStep->ID;
988
				$this->write();
989
				// Otherwise, kick off the next step
990
				$this->log(sprintf("Found the next step (#%s), starting it now...", $nextStep->Name));
991
				$nextStep->start();
992
			}
993
		// if the current step is failing run it again
994
		} else if($step = $this->CurrentStep()) {
995
			$step->start();
996
		}
997
	}
998
999
	/**
1000
	 * Finds the next {@link PipelineStep} that needs to execute. Relies on $this->CurrentStep() being a valid step.
1001
	 *
1002
	 * @return DataObject|null The next step in the pipeline, or null if none remain.
1003
	 */
1004 View Code Duplication
	protected function findNextStep() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
1005
		// otherwise get next step in chain
1006
		$currentStep = $this->CurrentStep();
1007
1008
		return $this
1009
			->Steps()
1010
			->filter("Status", "Queued")
1011
			->filter("Order:GreaterThanOrEqual", $currentStep->Order)
1012
			->exclude("ID", $currentStep->ID)
1013
			->sort("Order ASC")
1014
			->first();
1015
	}
1016
1017
	/**
1018
	 * Finds the previous {@link PipelineStep} that executed. Relies on $this->CurrentStep() being a valid step.
1019
	 *
1020
	 * @return DataObject|null The previous step in the pipeline, or null if this is the first.
1021
	 */
1022 View Code Duplication
	public function findPreviousStep() {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

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.

Loading history...
1023
		// otherwise get previous step in chain
1024
		$currentStep = $this->CurrentStep();
1025
1026
		return $this
1027
			->Steps()
1028
			->filter("Status", "Finished")
1029
			->filter("Order:LessThanOrEqual", $currentStep->Order)
1030
			->exclude("ID", $currentStep->ID)
1031
			->sort("Order DESC")
1032
			->first();
1033
	}
1034
1035
	/**
1036
	 * Write to a common log file. This log file will be the same regardless of how often this pipeline is re-created
1037
	 * from the database. To this end, it needs to know the database ID of this pipeline instance, so that it can
1038
	 * generate the correct filename to open.
1039
	 *
1040
	 * This also includes the calling class and method name that called ->log() in the first place, so we can trace
1041
	 * back where it was written from.
1042
	 *
1043
	 * @param string $message The message to log
1044
	 * @throws LogicException Thrown if we can't log yet because we don't know what to log to (no db record yet).
1045
	 */
1046
	public function log($message = "") {
1047
		$log = $this->getLogger();
1048
1049
		// Taken from Debug::caller(), amended for our purposes to filter out the intermediate call to
1050
		// PipelineStep->log(), so that our log message shows where the log message was actually created from.
1051
		$bt = debug_backtrace();
1052
1053
		$index = ($bt[1]['class'] == 'PipelineStep') ? 2 : 1;
1054
1055
		$caller = $bt[$index];
1056
		$caller['line'] = $bt[($index - 1)]['line']; // Overwrite line and file to be the the line/file that actually
1057
		$caller['file'] = $bt[($index - 1)]['file']; // called the function, not where the function is defined.
1058
		// In case it wasn't called from a class
1059
		if(!isset($caller['class'])) {
1060
			$caller['class'] = '';
1061
		}
1062
		// In case it doesn't have a type (wasn't called from class)
1063
		if(!isset($caller['type'])) {
1064
			$caller['type'] = '';
1065
		}
1066
1067
		$log->write(sprintf(
1068
			"[%s::%s() (line %d)] %s",
1069
			$caller['class'],
1070
			$caller['function'],
1071
			$caller['line'],
1072
			$message
1073
		));
1074
	}
1075
1076
	/**
1077
	 * Returns the {@link DeploynautLogFile} instance that will actually write to this log file.
1078
	 *
1079
	 * @return DeploynautLogFile
1080
	 * @throws RuntimeException
1081
	 */
1082
	public function getLogger() {
1083
		if(!$this->isInDB()) {
1084
			throw new RuntimeException("Can't write to a log file until we know the database ID.");
1085
		}
1086
1087
		if(!$this->Environment()) {
1088
			throw new RuntimeException("Can't write to a log file until we have an Environment.");
1089
		}
1090
1091
		if($this->Environment() && !$this->Environment()->Project()) {
1092
			throw new RuntimeException("Can't write to a log file until we have the Environment's project.");
1093
		}
1094
1095
		$environment = $this->Environment();
1096
		$filename = sprintf('%s.pipeline.%d.log', $environment->getFullName('.'), $this->ID);
1097
1098
		return Injector::inst()->createWithArgs('DeploynautLogFile', array($filename));
1099
	}
1100
1101
	/**
1102
	 * @return bool
1103
	 */
1104
	public function getDryRun() {
1105
		return $this->getField('DryRun');
1106
	}
1107
1108
	/**
1109
	 * @param string|null $action
1110
	 *
1111
	 * @return string
1112
	 */
1113
	public function Link($action = null) {
1114
		return Controller::join_links($this->Environment()->Link(), 'pipeline', $this->ID, $action);
1115
	}
1116
1117
	/**
1118
	 * Link to an action on the current step
1119
	 *
1120
	 * @param string|null $action
1121
	 * @return string
1122
	 */
1123
	public function StepLink($action = null) {
1124
		return Controller::join_links($this->Link('step'), $action);
1125
	}
1126
1127
	/**
1128
	 * @return string
1129
	 */
1130
	public function AbortLink() {
1131
		return $this->Link('abort');
1132
	}
1133
1134
	/**
1135
	 * @return string
1136
	 */
1137
	public function LogLink() {
1138
		return $this->Link('log');
1139
	}
1140
1141
	/**
1142
	 * @return string
1143
	 */
1144
	public function LogContent() {
1145
		if($this->exists() && $this->Environment()) {
1146
			$logger = $this->getLogger();
1147
			if($logger->exists()) {
1148
				return $logger->content();
1149
			}
1150
		}
1151
	}
1152
1153
}
1154