Completed
Pull Request — master (#489)
by Helpful
03:18
created

Pipeline   F

Complexity

Total Complexity 145

Size/Duplication

Total Lines 1083
Duplicated Lines 3.88 %

Coupling/Cohesion

Components 1
Dependencies 25
Metric Value
wmc 145
lcom 1
cbo 25
dl 42
loc 1083
rs 0.6104

43 Methods

Rating   Name   Duplication   Size   Complexity  
A setMessagingService() 0 4 1
A getMessagingService() 0 4 1
A __isset() 0 9 2
A getReplacements() 0 15 1
A getTitle() 0 4 1
A canAbort() 0 14 4
A getRunningDescription() 0 11 4
A RunningOptions() 0 13 4
C LogOptions() 0 49 9
C getConfigData() 6 23 7
A setConfig() 0 5 1
A getConfigSetting() 12 13 3
A getCMSFields() 0 56 3
C getDependentEnvironment() 0 29 7
B generateStep() 0 26 4
B start() 0 27 5
A markComplete() 0 10 2
A isComplete() 0 4 1
A isRunning() 0 4 1
A isActive() 0 4 2
A pushPipelineStep() 0 6 2
B finaliseRollback() 0 27 6
A beginRollback() 0 23 2
B canStartRollback() 0 26 6
C markFailed() 0 24 7
A isFailed() 0 4 1
A isRollback() 0 4 1
B markAborted() 0 22 4
A generateMessageTemplate() 0 7 1
B injectMessageReplacements() 0 22 5
C sendMessage() 0 37 7
A isAborted() 0 4 1
F checkPipelineStatus() 0 89 19
A findNextStep() 12 13 1
A findPreviousStep() 12 13 1
B log() 0 30 4
B getLogger() 0 19 5
A getDryRun() 0 4 1
A Link() 0 4 1
A StepLink() 0 4 1
A AbortLink() 0 4 1
A LogLink() 0 4 1
A LogContent() 0 9 4

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Pipeline often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Pipeline, and based on these observations, apply Extract Interface, too.

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
    /**
118
     * Messages
119
     */
120
    const ALERT_ABORT = 'Abort';
121
    const ALERT_SUCCESS = 'Success';
122
    const ALERT_FAILURE = 'Failure';
123
    const ALERT_ROLLBACK_STARTED = 'RollbackStarted';
124
    const ALERT_ROLLBACK_SUCCESS = 'RollbackSuccess';
125
    const ALERT_ROLLBACK_FAILURE = 'RollbackFailure';
126
127
    /**
128
     * - Status: Current status of this Pipeline. Running means 'currently executing a {@link PipelineStep}'.
129
     *           See the {@link PipelineControllerTask} class for why this is important.
130
     * - SHA:    This is the Git SHA that the pipeline is acting on. This is passed into the {@link PipelineStep}
131
     *           objects so that the steps know what to smoketest, deploy, etc.
132
     *
133
     * @var array
134
     */
135
    private static $db = array(
136
        'Status' => 'Enum("Running,Complete,Failed,Aborted,Rollback,Queued", "Queued")',
137
        'Config' => 'Text', // serialized array of configuration for this pipeline
138
        'SHA' => 'Varchar(255)',
139
        'DryRun' => 'Boolean', // Try if this deployment is a test dryrun
140
        'LastMessageSent' => 'Varchar(255)' // ID of last message sent
141
    );
142
143
    /**
144
     * - Author:      The {@link Member} object that started this pipeline running.
145
     * - Environment: The {@link DNEnvironment} that this Pipeline is associated to.
146
     * - CurrentStep: The current {@link PipelineStep} object that is keeping this pipeline alive. This should be
147
     *                cleared when the last step is complete.
148
     *
149
     * @var array
150
     */
151
    private static $has_one = array(
152
        'Author' => 'Member',
153
        'Environment' => 'DNEnvironment',
154
        'CurrentStep' => 'PipelineStep',
155
        // to be used for rollbacks
156
        "PreviousSnapshot" => "DNDataTransfer",
157
        "PreviousDeployment" => 'DNDeployment',
158
        "CurrentDeployment" => "DNDeployment",
159
        "RollbackStep1" => "PipelineStep",
160
        "RollbackStep2" => "PipelineStep"
161
    );
162
163
    /**
164
     * - Steps: These are ordered by the `PipelineStep`.`Order` attribute.
165
     *
166
     * @var array
167
     */
168
    private static $has_many = array(
169
        'Steps' => 'PipelineStep'
170
    );
171
172
    /**
173
     * @var array
174
     */
175
    private static $summary_fields = array(
176
        'ID' => 'ID',
177
        'Status' => 'Status',
178
        'SHA' => 'SHA',
179
        'Author.Title' => 'Author',
180
        'CurrentStep.Name' => 'Current Step',
181
        'Created' => 'Created',
182
        'LastEdited' => 'Last Updated'
183
    );
184
185
    /**
186
     * @var string
187
     */
188
    private static $default_sort = '"Created" DESC';
189
190
    /**
191
     * @var array
192
     */
193
    private static $cast = array(
194
        'RunningDescription' => 'HTMLText'
195
    );
196
197
    /**
198
     * @config
199
     * @var array
200
     */
201
    private static $dependencies = array(
202
        'MessagingService' => '%$ConfirmationMessagingService'
203
    );
204
205
    /**
206
     * Currently assigned messaging service
207
     *
208
     * @var ConfirmationMessagingService
209
     */
210
    private $messagingService = null;
211
212
    /**
213
     * @param ConfirmationMessagingService $service
214
     */
215
    public function setMessagingService(ConfirmationMessagingService $service)
216
    {
217
        $this->messagingService = $service;
218
    }
219
220
    /**
221
     * @return ConfirmationMessagingService
222
     */
223
    public function getMessagingService()
224
    {
225
        return $this->messagingService;
226
    }
227
228
    public function __isset($property)
229
    {
230
        // Workaround fixed in https://github.com/silverstripe/silverstripe-framework/pull/3201
231
        // Remove this once we update to a version of framework which supports this
232
        if ($property === 'MessagingService') {
233
            return !empty($this->messagingService);
234
        }
235
        return parent::__isset($property);
236
    }
237
238
    /**
239
     * Retrieve message template replacements
240
     *
241
     * @return array
242
     */
243
    public function getReplacements()
244
    {
245
        // Get member who began this request
246
        $author = $this->Author();
247
        $environment = $this->Environment();
248
        return array(
249
            '<abortlink>' => Director::absoluteURL($this->Environment()->Link()),
250
            '<pipelinelink>' => Director::absoluteURL($this->Link()),
251
            '<requester>' => $author->Title,
252
            '<requester-email>' => $author->Email,
253
            '<environment>' => $environment->Name,
254
            '<project>' => $environment->Project()->Name,
255
            '<commitsha>' => $this->SHA
256
        );
257
    }
258
259
    /**
260
     * Title of this step
261
     *
262
     * @return string
263
     */
264
    public function getTitle()
265
    {
266
        return "Pipeline {$this->ID} (Status: {$this->Status})";
267
    }
268
269
    /**
270
     * @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...
271
     */
272
    public function canAbort($member = null)
273
    {
274
        // Owner can abort
275
        $member = $member ?: Member::currentUser();
276
        if (!$member) {
277
            return false;
278
        }
279
        if ($member->ID == $this->AuthorID) {
280
            return true;
281
        }
282
283
        // Check environment permission
284
        return $this->Environment()->canAbort($member);
1 ignored issue
show
Documentation introduced by
$member is of type object<DataObject>, but the function expects a object<Member>|null.

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...
285
    }
286
287
    /**
288
     * Get status of currently running step
289
     *
290
     * @return string Status description (html format)
291
     */
292
    public function getRunningDescription()
293
    {
294
        if (!$this->isActive()) {
295
            return 'This pipeline is not currently running';
296
        }
297
        $result = '';
298
        if ($step = $this->CurrentStep()) {
299
            $result = $step->getRunningDescription();
300
        }
301
        return $result ?: 'This pipeline is currently running';
302
    }
303
304
    /**
305
     * Get options for the currently running pipeline, if and only if it is currently running
306
     *
307
     * @return ArrayList List of items with a Link and Title attribute
308
     */
309
    public function RunningOptions()
310
    {
311
        if (!$this->isActive()) {
312
            return null;
313
        }
314
        $actions = array();
315
316
        // Let current step update the current list of options
317
        if (($step = $this->CurrentStep()) && ($step->isRunning())) {
318
            $actions = $step->allowedActions();
319
        }
320
        return new ArrayList($actions);
321
    }
322
323
    /**
324
     * Get possible logs for the currently pipeline
325
     *
326
     * @return ArrayList List of logs with a Link and Title attribute
327
     */
328
    public function LogOptions()
329
    {
330
        if (!$this->isActive()) {
331
            return null;
332
        }
333
334
        $logs = array();
335
336
        $logs[] = array(
337
            'ButtonText' => 'Pipeline Log',
338
            'Link' => $this->Link()
339
        );
340
341
        if ($this->PreviousSnapshotID > 0) {
342
            $logs[] = array(
343
                'ButtonText' => 'Snapshot Log',
344
                'Link' => $this->PreviousSnapshot()->Link()
345
            );
346
        }
347
348
        if ($this->CurrentDeploymentID > 0) {
349
            $logs[] = array(
350
                'ButtonText' => 'Deployment Log',
351
                'Link' => $this->CurrentDeployment()->Link()
352
            );
353
        }
354
355
        // Get logs from rollback steps (only for RollbackSteps).
356
        $rollbackSteps = array($this->RollbackStep1(), $this->RollbackStep2());
357
        foreach ($rollbackSteps as $rollback) {
358
            if ($rollback->exists() && $rollback->ClassName == 'RollbackStep') {
359
                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...
360
                    $logs[] = array(
361
                        'ButtonText' => 'Rollback Log',
362
                        '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...
363
                    );
364
                }
365
366
                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...
367
                    $logs[] = array(
368
                        'ButtonText' => 'Rollback DB Log',
369
                        '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...
370
                    );
371
                }
372
            }
373
        }
374
375
        return new ArrayList($logs);
376
    }
377
378
    /**
379
     * Cached of config merged with defaults
380
     *
381
     * @var array|null
382
     */
383
    protected $mergedConfig;
384
385
    /**
386
     * Get this pipeline configuration. If the configuration has been serialized
387
     * and saved into the Config field, it'll use that. If that field is empty,
388
     * it'll read the YAML file directly and return that instead.
389
     *
390
     * @return array
391
     * @throws Exception
392
     */
393
    public function getConfigData()
394
    {
395
        // Lazy load if necessary
396
        $data = null;
397
        if (!$this->Config && ($data = $this->Environment()->loadPipelineConfig())) {
398
            $this->Config = serialize($data);
399
        }
400
401
        // Merge with defaults
402
        if ($this->Config) {
403 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...
404
                $this->mergedConfig = $data ?: unserialize($this->Config);
405
                if ($default = self::config()->default_config) {
406
                    Config::merge_array_low_into_high($this->mergedConfig, $default);
407
                }
408
            }
409
            return $this->mergedConfig;
410
        }
411
412
        // Fail if no data available
413
        $path = $this->Environment()->getPipelineFilename();
414
        throw new Exception(sprintf('YAML configuration for pipeline not found at path "%s"', $path));
415
    }
416
417
    public function setConfig($data)
418
    {
419
        $this->mergedConfig = null;
420
        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...
421
    }
422
423
    /**
424
     * Retrieve the value of a specific config setting
425
     *
426
     * @param string $setting Settings
427
     * @return mixed Value of setting, or null if not set
428
     */
429 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...
430
    {
431
        $source = $this->getConfigData();
432
433
        foreach (func_get_args() as $setting) {
434
            if (empty($source[$setting])) {
435
                return null;
436
            }
437
            $source = $source[$setting];
438
        }
439
440
        return $source;
441
    }
442
443
    /**
444
     * @return FieldList
445
     */
446
    public function getCMSFields()
447
    {
448
        $fields = new FieldList(new TabSet('Root'));
449
450
        // Main fields
451
        $fields->addFieldsToTab('Root.Main', array(
452
            TextField::create('SHA')
453
                ->setDescription('SHA of the commit this pipeline is running against')
454
                ->performReadonlyTransformation(),
455
            TextField::create('AuthorName', 'Author', ($author = $this->Author()) ? $author->Title : null)
456
                ->setDescription('Person who initiated this pipeline')
457
                ->performReadonlyTransformation(),
458
            DropdownField::create('Status', 'Status', $this->dbObject('Status')->enumValues()),
459
            DropdownField::create('CurrentStepID', 'Current Step', $this->Steps()->map('ID', 'TreeTitle')),
460
            TextField::create(
461
                'CurrentDeployment_Label',
462
                'Current Deployment',
463
                $this->CurrentDeployment()->getTitle()
464
            )    ->setDescription('Deployment generated by this pipeline')
465
                ->performReadonlyTransformation(),
466
        ));
467
468
        // Backup fields
469
        $fields->addFieldsToTab('Root.Backups', array(
470
            TextField::create(
471
                'PreviousDeployment_Label',
472
                'Previous Deployment',
473
                $this->PreviousDeployment()->getTitle()
474
            )    ->setDescription('Prior deployment to revert to if this pipeline fails')
475
                ->performReadonlyTransformation(),
476
            TextField::create(
477
                'PreviousSnapshot_Label',
478
                'Previous DB Snapshot',
479
                $this->PreviousSnapshot()->getTitle()
480
            )    ->setDescription('Database backup to revert to if this pipeline fails')
481
                ->performReadonlyTransformation()
482
        ));
483
484
        if ($log = $this->LogContent()) {
485
            $fields->addFieldToTab(
486
                'Root.Main',
487
                ToggleCompositeField::create(
488
                    'PipelineLog',
489
                    'Pipeline Log',
490
                    LiteralField::create('LogText', nl2br(Convert::raw2xml($log)))
491
                )
492
            );
493
        }
494
495
        // Steps
496
        $stepConfig = GridFieldConfig_RecordEditor::create();
497
        $steps = GridField::create('Steps', 'Pipeline Steps', $this->Steps(), $stepConfig);
498
        $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...
499
500
        return $fields;
501
    }
502
503
    /**
504
     * Return a dependent {@link DNEnvironment} based on this pipeline's dependent environment configuration.
505
     *
506
     * @return DNEnvironment
507
     */
508
    public function getDependentEnvironment()
509
    {
510
        // dependent environment not available
511
        $projectName = $this->getConfigSetting('PipelineConfig', 'DependsOnProject');
512
        $environmentName = $this->getConfigSetting('PipelineConfig', 'DependsOnEnvironment');
513
        if (empty($projectName) || empty($environmentName)) {
514
            return null;
515
        }
516
517
        $project = DNProject::get()->filter('Name', $projectName)->first();
518
        if (!($project && $project->exists())) {
519
            throw new Exception(sprintf('Could not find dependent project "%s"', $projectName));
520
        }
521
522
        $environment = DNEnvironment::get()->filter(array(
523
            'ProjectID' => $project->ID,
524
            'Name' => $environmentName
525
        ))->first();
526
527
        if (!($environment && $environment->exists())) {
528
            throw new Exception(sprintf(
529
                'Could not find dependent environment "%s" in project "%s"',
530
                $environmentName,
531
                $projectName
532
            ));
533
        }
534
535
        return $environment;
536
    }
537
538
    /**
539
     * Generate a step from a name, config, and sort order
540
     *
541
     * @throws Exception
542
     * @param string $name
543
     * @param array $stepConfig
544
     * @param int $order
545
     * @return PipelineStep
546
     */
547
    protected function generateStep($name, $stepConfig, $order = 0)
548
    {
549
        $stepClass = isset($stepConfig['Class']) ? $stepConfig['Class'] : $stepConfig;
550
551
        if (empty($stepClass)) {
552
            throw new Exception(
553
                sprintf('Missing or empty Class specifier for step "%s"', $name)
554
            );
555
        }
556
557
        if (!is_subclass_of($stepClass, 'PipelineStep')) {
558
            throw new Exception(
559
                sprintf('%s is not a valid "Class" field name for step "%s"', var_export($stepClass, true), $name)
560
            );
561
        }
562
563
        $step = $stepClass::create();
564
        $step->Name = $name;
565
        $step->PipelineID = $this->ID;
566
        $step->Order = $order;
567
        $step->Status = 'Queued';
568
        $step->Config = serialize($stepConfig);
569
        $step->write();
570
571
        return $step;
572
    }
573
574
    /**
575
     * Starts the pipeline process.
576
     *
577
     * Reads a YAML configuration from the linked {@link DNEnvironment}
578
     * and builds the {@link PipelineStep} objects and runs them.
579
     *
580
     * Note that this method doesn't actually start any {@link PipelineStep} objects, that is handled by
581
     * {@link self::checkPipelineStatus()}, and the daemon running the process.
582
     *
583
     * @throws LogicException
584
     * @return boolean
585
     */
586
    public function start()
587
    {
588
        // Ensure there are no other running {@link Pipeline} objects for this {@link DNEnvironment}
589
        // Requires that $this->EnvironmentID has been set
590
        $env = $this->Environment();
591
        if (!($env && $env->exists())) {
592
            throw new LogicException("This pipeline needs a valid environment to run on.");
593
        }
594
595
        if ($env->HasCurrentPipeline()) {
596
            throw new LogicException("You can only run one pipeline at a time on this environment.");
597
        }
598
599
        $this->write(); // ensure we've written this record first
600
601
        // Instantiate steps.
602
        foreach ($this->getConfigSetting('Steps') as $name => $stepConfig) {
603
            $this->pushPipelineStep($name, $stepConfig);
604
        }
605
606
        $this->Status = 'Running';
607
        $this->write();
608
609
        $this->log('Started logging for this pipeline!');
610
611
        return true;
612
    }
613
614
    /**
615
     * Mark this Pipeline as completed.
616
     */
617
    public function markComplete()
618
    {
619
        $this->Status = "Complete";
620
        $this->log("Pipeline completed successfully.");
621
        $this->write();
622
        // Some steps may pre-emptively send a success message before the pipeline itself has completed
623
        if ($this->LastMessageSent !== self::ALERT_SUCCESS) {
624
            $this->sendMessage(self::ALERT_SUCCESS);
625
        }
626
    }
627
628
    /**
629
     * @return bool true if this Pipeline has successfully completed all {@link PipelineStep} steps already.
630
     */
631
    public function isComplete()
632
    {
633
        return $this->Status == "Complete";
634
    }
635
636
    /**
637
     * True if the pipeline is running but NOT doing a rollback
638
     *
639
     * @return bool
640
     */
641
    public function isRunning()
642
    {
643
        return $this->Status == "Running";
644
    }
645
646
    /**
647
     * True if the pipeline is running or doing a rollback
648
     *
649
     * @return bool
650
     */
651
    public function isActive()
652
    {
653
        return $this->isRunning() || $this->isRollback();
654
    }
655
656
    /**
657
     * Push a step to the end of a pipeline
658
     *
659
     * @param string $name
660
     * @param array $stepConfig
661
     * @return PipelineStep
662
     */
663
    private function pushPipelineStep($name, $stepConfig)
664
    {
665
        $lastStep = $this->Steps()->sort("Order DESC")->first();
666
        $order = $lastStep ? $lastStep->Order + 1 : 1;
667
        return $this->generateStep($name, $stepConfig, $order);
668
    }
669
670
    /**
671
     * The rollback has finished - close the pipeline and send relevant messages.
672
     */
673
    protected function finaliseRollback()
674
    {
675
676
        // Figure out the status by inspecting specific rollback steps.
677
        $success = true;
678
        $rollback1 = $this->RollbackStep1();
679
        $rollback2 = $this->RollbackStep2();
680
        if (!empty($rollback1) && $rollback1->Status == 'Failed') {
681
            $success = false;
682
        }
683
        if (!empty($rollback2) && $rollback2->Status == 'Failed') {
684
            $success = false;
685
        }
686
687
        // Send messages.
688
        if ($success) {
689
            $this->log("Pipeline failed, but rollback completed successfully.");
690
            $this->sendMessage(self::ALERT_ROLLBACK_SUCCESS);
691
        } else {
692
            $this->log("Pipeline failed, rollback failed.");
693
            $this->sendMessage(self::ALERT_ROLLBACK_FAILURE);
694
        }
695
696
        // Finish off the pipeline - rollback will only be triggered on a failed pipeline.
697
        $this->Status = 'Failed';
698
        $this->write();
699
    }
700
701
    /**
702
     * Initiate a rollback. Moves the pipeline to the 'Rollback' status.
703
     */
704
    protected function beginRollback()
705
    {
706
        $this->log("Beginning rollback...");
707
        $this->sendMessage(self::ALERT_ROLLBACK_STARTED);
708
709
        // Add rollback step.
710
        $configRollback1 = $this->getConfigSetting('RollbackStep1');
711
        $stepRollback1 = $this->pushPipelineStep('RollbackStep1', $configRollback1);
712
        $this->RollbackStep1ID = $stepRollback1->ID;
713
        $this->CurrentStepID = $stepRollback1->ID;
714
        $this->Status = 'Rollback';
715
716
        // Add smoke test step, if available, for later processing.
717
        $configRollback2 = $this->getConfigSetting('RollbackStep2');
718
        if ($configRollback2) {
719
            $stepRollback2 = $this->pushPipelineStep('RollbackStep2', $configRollback2);
720
            $this->RollbackStep2ID = $stepRollback2->ID;
721
        }
722
723
        $this->write();
724
725
        $stepRollback1->start();
726
    }
727
728
    /**
729
     * Check if pipeline currently permits a rollback.
730
     * This could be influenced by both the current state and by the specific configuration.
731
     *
732
     * @return boolean
733
     */
734
    protected function canStartRollback()
735
    {
736
        // The rollback cannot run twice.
737
        if ($this->isRollback()) {
738
            return false;
739
        }
740
741
        // Rollbacks must be configured.
742
        if (!$this->getConfigSetting('RollbackStep1')) {
743
            return false;
744
        }
745
746
        // On dryrun let rollback run
747
        if ($this->DryRun) {
748
            return true;
749
        }
750
751
        // Pipeline must have ran a deployment to be able to rollback.
752
        $deploy = $this->CurrentDeployment();
753
        $previous = $this->PreviousDeployment();
754
        if (!$deploy->exists() || !$previous->exists()) {
755
            return false;
756
        }
757
758
        return true;
759
    }
760
761
    /**
762
     * Notify Pipeline that a step has failed and failure processing should kick in. If rollback steps are present
763
     * the pipeline will be put into 'Rollback' state. After rollback is complete, regardless of the rollback result,
764
     * the pipeline will be failed.
765
     *
766
     * @param bool $notify Set to false to disable notifications for this failure
767
     */
768
    public function markFailed($notify = true)
769
    {
770
        // Abort all running or queued steps.
771
        $steps = $this->Steps();
772
        foreach ($steps as $step) {
773
            if ($step->isQueued() || $step->isRunning()) {
774
                $step->abort();
775
            }
776
        }
777
778
        if ($this->canStartRollback()) {
779
            $this->beginRollback();
780
        } elseif ($this->isRollback()) {
781
            $this->finaliseRollback();
782
        } else {
783
            // Not able to roll back - fail immediately.
784
            $this->Status = 'Failed';
785
            $this->log("Pipeline failed, not running rollback (not configured or not applicable yet).");
786
            $this->write();
787
            if ($notify) {
788
                $this->sendMessage(self::ALERT_FAILURE);
789
            }
790
        }
791
    }
792
793
    /**
794
     * @return bool true if this Pipeline failed to execute all {@link PipelineStep} steps successfully
795
     */
796
    public function isFailed()
797
    {
798
        return $this->Status == "Failed";
799
    }
800
801
    /**
802
     * @return bool true if this Pipeline is rolling back.
803
     */
804
    public function isRollback()
805
    {
806
        return $this->Status == "Rollback";
807
    }
808
809
    /**
810
     * Mark this Pipeline as aborted
811
     */
812
    public function markAborted()
813
    {
814
        $this->Status = 'Aborted';
815
        $logMessage = sprintf(
816
            "Pipeline processing aborted. %s (%s) aborted the pipeline",
817
            Member::currentUser()->Name,
818
            Member::currentUser()->Email
819
        );
820
        $this->log($logMessage);
821
        $this->write();
822
823
        // Abort all running or queued steps.
824
        $steps = $this->Steps();
825
        foreach ($steps as $step) {
826
            if ($step->isQueued() || $step->isRunning()) {
827
                $step->abort();
828
            }
829
        }
830
831
        // Send notification to users about this event
832
        $this->sendMessage(self::ALERT_ABORT);
833
    }
834
835
    /**
836
     * Finds a message template for a given role and message
837
     *
838
     * @param string $messageID Message ID
839
     * @return array Resulting array(subject, message)
840
     */
841
    protected function generateMessageTemplate($messageID)
842
    {
843
        $subject = $this->getConfigSetting('PipelineConfig', 'Subjects', $messageID);
844
        $message = $this->getConfigSetting('PipelineConfig', 'Messages', $messageID);
845
        $substitutions = $this->getReplacements();
846
        return $this->injectMessageReplacements($message, $subject, $substitutions);
847
    }
848
849
    /**
850
     * Substitute templated variables into the given message and subject
851
     *
852
     * @param string $message
853
     * @param string $subject
854
     * @param array $substitutions
855
     * @return array Resulting array(subject, message)
856
     */
857
    public function injectMessageReplacements($message, $subject, $substitutions)
858
    {
859
        // Handle empty messages
860
        if (empty($subject) && empty($message)) {
861
            return array(null, null);
862
        }
863
864
        // Check if there's a role specific message
865
        $subjectText = str_replace(
866
            array_keys($substitutions),
867
            array_values($substitutions),
868
            $subject ?: $message
869
        );
870
        $messageText = str_replace(
871
            array_keys($substitutions),
872
            array_values($substitutions),
873
            $message ?: $subject
874
        );
875
876
877
        return array($subjectText, $messageText);
878
    }
879
880
    /**
881
     * Sends a specific message to all marked recipients, including the author of this pipeline
882
     *
883
     * @param string $messageID Message ID. One of 'Abort', 'Success', or 'Failure', or some custom message
884
     * @return boolean|null True if successful
885
     */
886
    public function sendMessage($messageID)
887
    {
888
        // Check message, subject, and additional arguments to include
889
        list($subject, $message) = $this->generateMessageTemplate($messageID);
890
        if (empty($subject) || empty($message)) {
891
            $this->log("Skipping sending message. None configured for $messageID");
892
            return true;
893
        }
894
895
        // Save last sent message
896
        $this->LastMessageSent = $messageID;
897
        $this->write();
898
899
        // Setup messaging arguments
900
        $arguments = array_merge(
901
            $this->getConfigSetting('PipelineConfig', 'ServiceArguments') ?: array(),
902
            array('subject' => $subject)
903
        );
904
905
        // Send message to author
906
        if ($author = $this->Author()) {
907
            $this->log("Pipeline sending $messageID message to {$author->Email}");
908
            $this->messagingService->sendMessage($this, $message, $author, $arguments);
909
        } else {
910
            $this->log("Skipping sending message to missing author");
911
        }
912
913
        // Get additional recipients
914
        $recipients = $this->getConfigSetting('PipelineConfig', 'Recipients', $messageID);
915
        if (empty($recipients)) {
916
            $this->log("Skipping sending message to empty recipients");
917
        } else {
918
            $recipientsStr = is_array($recipients) ? implode(',', $recipients) : $recipients;
919
            $this->log("Pipeline sending $messageID message to $recipientsStr");
920
            $this->messagingService->sendMessage($this, $message, $recipients, $arguments);
921
        }
922
    }
923
924
    /**
925
     * @return bool true if this Pipeline has been aborted
926
     */
927
    public function isAborted()
928
    {
929
        return $this->Status === "Aborted";
930
    }
931
932
    /**
933
     * This method should be called only by the {@link CheckPipelineStatus} controller. It iterates through all the
934
     * {@link PipelineStep} objects associated with this Pipeline, and finds a place where the pipeline has stalled
935
     * (where one step has completed, but the next one has yet to start). It will then start the next step if required.
936
     *
937
     * We check here whether the {@link PipelineStep} finished successfully, and will mark the Pipeline as Failed if
938
     * the step failed, but this is only a fallback, and should not be relied upon. The individual {@link PipelineStep}
939
     * should mark itself as failed and then call {@link Pipeline::markFailed()} directly.
940
     *
941
     * If the Pipeline has run out of steps, then it will mark the pipeline as completed.
942
     */
943
    public function checkPipelineStatus()
944
    {
945
        $message = "";
946
947
        if (!$this->isActive()) {
948
            $message = "Pipeline::checkPipelineStatus() should only be called on running or rolling back pipelines.";
949
        }
950
951
        if (!$this->ID || !$this->isInDB()) {
952
            $message = "Pipeline::checkPipelineStatus() can only be called on pipelines already saved.";
953
        }
954
955
        $currentStep = ($this->CurrentStep() && $this->CurrentStep()->isInDB())
956
            ? $this->CurrentStep()
957
            : null;
958
959
        if ($currentStep && $currentStep->PipelineID != $this->ID) {
960
            $message = sprintf(
961
                "The current step (#%d) has a pipeline ID (#%d) that doesn't match this pipeline's ID (#%d).",
962
                $currentStep->ID,
963
                $currentStep->PipelineID,
964
                $this->ID
965
            );
966
        }
967
968
        if ($message) {
969
            $this->log($message);
970
            throw new LogicException($message);
971
        }
972
973
        // Fallback check only: this shouldn't be called unless a {@link PipelineStep} has been implemented incorrectly
974
        if ($currentStep && $currentStep->isFailed() && !$this->isFailed() && !$this->isRollback()) {
975
            $this->log(sprintf("Marking pipeline step (#%d) as failed - this pipeline step needs to be amended to mark"
976
                . " the pipeline (as well as itself) as failed to ensure consistency.",
977
                $this->CurrentStep()->ID
978
            ));
979
980
            $this->markFailed();
981
            return;
982
        }
983
984
        // If this is the first time the Pipeline is run, then we don't have a CurrentStep, so set it,
985
        // start it running, and return
986
        if (!$currentStep) {
987
            $step = $this->Steps()->first();
988
            $this->CurrentStepID = $step->ID;
989
            $this->write();
990
991
            $this->log("Starting first pipeline step...");
992
            $step->start();
993
        } elseif ($currentStep->isFinished()) {
994
            // Sort through the list of {@link PipelineStep} objects to find the next step we need to start.
995
            $this->log("Finding next step to execute...");
996
            $nextStep = $this->findNextStep();
997
998
            if (!$nextStep) {
999
1000
                // Special handling, since the main pipeline has already failed at this stage.
1001
                if ($this->isRollback()) {
1002
                    $this->finaliseRollback();
1003
                    return false;
1004
                }
1005
1006
                // Double check for any steps that failed, but didn't notify the pipeline via markFailed.
1007
                $failedSteps = PipelineStep::get()->filter(array(
1008
                    'PipelineID' => $this->ID,
1009
                    'Status' => 'Failed'
1010
                ))->count();
1011
                if ($failedSteps) {
1012
                    $this->log('At least one of the steps has failed marking the pipeline as failed');
1013
                    $this->markFailed();
1014
                    return false;
1015
                }
1016
1017
                // We've reached the end of this pipeline successfully!
1018
                $this->markComplete();
1019
                return;
1020
            } else {
1021
                $this->CurrentStepID = $nextStep->ID;
1022
                $this->write();
1023
                // Otherwise, kick off the next step
1024
                $this->log(sprintf("Found the next step (#%s), starting it now...", $nextStep->Name));
1025
                $nextStep->start();
1026
            }
1027
        // if the current step is failing run it again
1028
        } elseif ($step = $this->CurrentStep()) {
1029
            $step->start();
1030
        }
1031
    }
1032
1033
    /**
1034
     * Finds the next {@link PipelineStep} that needs to execute. Relies on $this->CurrentStep() being a valid step.
1035
     *
1036
     * @return DataObject|null The next step in the pipeline, or null if none remain.
1037
     */
1038 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...
1039
    {
1040
        // otherwise get next step in chain
1041
        $currentStep = $this->CurrentStep();
1042
1043
        return $this
1044
            ->Steps()
1045
            ->filter("Status", "Queued")
1046
            ->filter("Order:GreaterThanOrEqual", $currentStep->Order)
1047
            ->exclude("ID", $currentStep->ID)
1048
            ->sort("Order ASC")
1049
            ->first();
1050
    }
1051
1052
    /**
1053
     * Finds the previous {@link PipelineStep} that executed. Relies on $this->CurrentStep() being a valid step.
1054
     *
1055
     * @return DataObject|null The previous step in the pipeline, or null if this is the first.
1056
     */
1057 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...
1058
    {
1059
        // otherwise get previous step in chain
1060
        $currentStep = $this->CurrentStep();
1061
1062
        return $this
1063
            ->Steps()
1064
            ->filter("Status", "Finished")
1065
            ->filter("Order:LessThanOrEqual", $currentStep->Order)
1066
            ->exclude("ID", $currentStep->ID)
1067
            ->sort("Order DESC")
1068
            ->first();
1069
    }
1070
1071
    /**
1072
     * Write to a common log file. This log file will be the same regardless of how often this pipeline is re-created
1073
     * from the database. To this end, it needs to know the database ID of this pipeline instance, so that it can
1074
     * generate the correct filename to open.
1075
     *
1076
     * This also includes the calling class and method name that called ->log() in the first place, so we can trace
1077
     * back where it was written from.
1078
     *
1079
     * @param string $message The message to log
1080
     * @throws LogicException Thrown if we can't log yet because we don't know what to log to (no db record yet).
1081
     */
1082
    public function log($message = "")
1083
    {
1084
        $log = $this->getLogger();
1085
1086
        // Taken from Debug::caller(), amended for our purposes to filter out the intermediate call to
1087
        // PipelineStep->log(), so that our log message shows where the log message was actually created from.
1088
        $bt = debug_backtrace();
1089
1090
        $index = ($bt[1]['class'] == 'PipelineStep') ? 2 : 1;
1091
1092
        $caller = $bt[$index];
1093
        $caller['line'] = $bt[($index - 1)]['line']; // Overwrite line and file to be the the line/file that actually
1094
        $caller['file'] = $bt[($index - 1)]['file']; // called the function, not where the function is defined.
1095
        // In case it wasn't called from a class
1096
        if (!isset($caller['class'])) {
1097
            $caller['class'] = '';
1098
        }
1099
        // In case it doesn't have a type (wasn't called from class)
1100
        if (!isset($caller['type'])) {
1101
            $caller['type'] = '';
1102
        }
1103
1104
        $log->write(sprintf(
1105
            "[%s::%s() (line %d)] %s",
1106
            $caller['class'],
1107
            $caller['function'],
1108
            $caller['line'],
1109
            $message
1110
        ));
1111
    }
1112
1113
    /**
1114
     * Returns the {@link DeploynautLogFile} instance that will actually write to this log file.
1115
     *
1116
     * @return DeploynautLogFile
1117
     * @throws RuntimeException
1118
     */
1119
    public function getLogger()
1120
    {
1121
        if (!$this->isInDB()) {
1122
            throw new RuntimeException("Can't write to a log file until we know the database ID.");
1123
        }
1124
1125
        if (!$this->Environment()) {
1126
            throw new RuntimeException("Can't write to a log file until we have an Environment.");
1127
        }
1128
1129
        if ($this->Environment() && !$this->Environment()->Project()) {
1130
            throw new RuntimeException("Can't write to a log file until we have the Environment's project.");
1131
        }
1132
1133
        $environment = $this->Environment();
1134
        $filename = sprintf('%s.pipeline.%d.log', $environment->getFullName('.'), $this->ID);
1135
1136
        return Injector::inst()->createWithArgs('DeploynautLogFile', array($filename));
1137
    }
1138
1139
    /**
1140
     * @return bool
1141
     */
1142
    public function getDryRun()
1143
    {
1144
        return $this->getField('DryRun');
1145
    }
1146
1147
    /**
1148
     * @param string|null $action
1149
     *
1150
     * @return string
1151
     */
1152
    public function Link($action = null)
1153
    {
1154
        return Controller::join_links($this->Environment()->Link(), 'pipeline', $this->ID, $action);
1155
    }
1156
1157
    /**
1158
     * Link to an action on the current step
1159
     *
1160
     * @param string|null $action
1161
     * @return string
1162
     */
1163
    public function StepLink($action = null)
1164
    {
1165
        return Controller::join_links($this->Link('step'), $action);
1166
    }
1167
1168
    /**
1169
     * @return string
1170
     */
1171
    public function AbortLink()
1172
    {
1173
        return $this->Link('abort');
1174
    }
1175
1176
    /**
1177
     * @return string
1178
     */
1179
    public function LogLink()
1180
    {
1181
        return $this->Link('log');
1182
    }
1183
1184
    /**
1185
     * @return string
1186
     */
1187
    public function LogContent()
1188
    {
1189
        if ($this->exists() && $this->Environment()) {
1190
            $logger = $this->getLogger();
1191
            if ($logger->exists()) {
1192
                return $logger->content();
1193
            }
1194
        }
1195
    }
1196
}
1197