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

DNProject::getRunningEnvironmentCreations()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 4
rs 10
cc 1
eloc 3
nc 1
nop 0
1
<?php
2
3
/**
4
 * DNProject represents a project that relates to a group of target
5
 * environments.
6
 *
7
 * @property string Name
8
 * @property string CVSPath
9
 * @property int DiskQuotaMB
10
 *
11
 * @method HasManyList Environments()
12
 * @method ManyManyList Viewers()
13
 * @method ManyManyList StarredBy()
14
 */
15
class DNProject extends DataObject {
16
17
	/**
18
	 * @var array
19
	 */
20
	public static $db = array(
21
		"Name" => "Varchar",
22
		"CVSPath" => "Varchar(255)",
23
		"DiskQuotaMB" => "Int",
24
		"AllowedEnvironmentType" => "Varchar(255)",
25
	);
26
27
	/**
28
	 * @var array
29
	 */
30
	public static $has_many = array(
31
		"Environments" => "DNEnvironment",
32
		"CreateEnvironments" => "DNCreateEnvironment"
33
	);
34
35
	/**
36
	 * @var array
37
	 */
38
	public static $many_many = array(
39
		"Viewers" => "Group",
40
		'StarredBy' => "Member"
41
	);
42
43
	/**
44
	 * @var array
45
	 */
46
	public static $summary_fields = array(
47
		"Name",
48
		"ViewersList",
49
	);
50
51
	/**
52
	 * @var array
53
	 */
54
	public static $searchable_fields = array(
55
		"Name",
56
	);
57
58
	/**
59
	 * @var string
60
	 */
61
	private static $singular_name = 'Project';
62
63
	/**
64
	 * @var string
65
	 */
66
	private static $plural_name = 'Projects';
67
68
	/**
69
	 * @var string
70
	 */
71
	private static $default_sort = 'Name';
72
73
	/**
74
	 * Display the repository URL on the project page.
75
	 *
76
	 * @var bool
77
	 */
78
	private static $show_repository_url = false;
79
80
	/**
81
	 * In-memory cache for currentBuilds per environment since fetching them from
82
	 * disk is pretty resource hungry.
83
	 *
84
	 * @var array
85
	 */
86
	protected static $relation_cache = array();
87
88
	/**
89
	 * In-memory cache to determine whether clone repo was called.
90
	 * @var array
91
	 */
92
	private static $has_cloned_cache = array();
93
94
	/**
95
	 * @var bool|Member
96
	 */
97
	protected static $_current_member_cache = null;
98
99
	/**
100
	 * Used by the sync task
101
	 *
102
	 * @param string $path
103
	 * @return \DNProject
104
	 */
105
	public static function create_from_path($path) {
106
		$project = DNProject::create();
107
		$project->Name = $path;
108
		$project->write();
109
110
		// add the administrators group as the viewers of the new project
111
		$adminGroup = Group::get()->filter('Code', 'administrators')->first();
112
		if($adminGroup && $adminGroup->exists()) {
113
			$project->Viewers()->add($adminGroup);
114
		}
115
		return $project;
116
	}
117
118
	/**
119
	 * Return the used quota in MB.
120
	 *
121
	 * @param int $round Number of decimal places to round to
122
	 * @return double The used quota size in MB
123
	 */
124
	public function getUsedQuotaMB($round = 2) {
125
		$size = 0;
126
127
		foreach($this->Environments() as $environment) {
128
			foreach($environment->DataArchives()->filter('IsBackup', 0) as $archive) {
129
				$size += $archive->ArchiveFile()->getAbsoluteSize();
130
			}
131
		}
132
133
		// convert bytes to megabytes and round
134
		return round(($size / 1024) / 1024, $round);
135
	}
136
137
	/**
138
	 * Getter for DiskQuotaMB field to provide a default for existing
139
	 * records that have no quota field set, as it will need to default
140
	 * to a globally set size.
141
	 *
142
	 * @return string|int The quota size in MB
143
	 */
144
	public function getDiskQuotaMB() {
145
		$size = $this->getField('DiskQuotaMB');
146
147
		if(empty($size)) {
148
			$defaults = $this->config()->get('defaults');
149
			$size = (isset($defaults['DiskQuotaMB'])) ? $defaults['DiskQuotaMB'] : 0;
150
		}
151
152
		return $size;
153
	}
154
155
	/**
156
	 * Has the disk quota been exceeded?
157
	 *
158
	 * @return boolean
159
	 */
160
	public function HasExceededDiskQuota() {
161
		return $this->getUsedQuotaMB(0) >= $this->getDiskQuotaMB();
162
	}
163
164
	/**
165
	 * Is there a disk quota set for this project?
166
	 *
167
	 * @return boolean
168
	 */
169
	public function HasDiskQuota() {
170
		return $this->getDiskQuotaMB() > 0;
171
	}
172
173
	/**
174
	 * Returns the current disk quota usage as a percentage
175
	 *
176
	 * @return int
177
	 */
178
	public function DiskQuotaUsagePercent() {
179
		$quota = $this->getDiskQuotaMB();
180
		if($quota > 0) {
181
			return $this->getUsedQuotaMB() * 100 / $quota;
182
		}
183
		return 100;
184
	}
185
186
	/**
187
	 * Get the menu to be shown on projects
188
	 *
189
	 * @return ArrayList
190
	 */
191
	public function Menu() {
192
		$list = new ArrayList();
193
194
		$controller = Controller::curr();
195
		$actionType = $controller->getField('CurrentActionType');
196
197
		if(DNRoot::FlagSnapshotsEnabled() && $this->isProjectReady()) {
198
			$list->push(new ArrayData(array(
199
				'Link' => sprintf('naut/project/%s/snapshots', $this->Name),
200
				'Title' => 'Snapshots',
201
				'IsCurrent' => $this->isSection() && $controller->getAction() == 'snapshots',
202
				'IsSection' => $this->isSection() && $actionType == DNRoot::ACTION_SNAPSHOT
203
			)));
204
		}
205
206
		$this->extend('updateMenu', $list);
207
208
		return $list;
209
	}
210
211
	/**
212
	 * Is this project currently at the root level of the controller that handles it?
213
	 *
214
	 * @return bool
215
	 */
216
	public function isCurrent() {
217
		return $this->isSection() && Controller::curr()->getAction() == 'project';
218
	}
219
220
	/**
221
	 * Return the current object from $this->Menu()
222
	 * Good for making titles and things
223
	 *
224
	 * @return DataObject
225
	 */
226
	public function CurrentMenu() {
227
		return $this->Menu()->filter('IsSection', true)->First();
228
	}
229
230
	/**
231
	 * Is this project currently in a controller that is handling it or performing a sub-task?
232
	 *
233
	 * @return bool
234
	 */
235
	public function isSection() {
236
		$controller = Controller::curr();
237
		$project = $controller->getField('CurrentProject');
238
		return $project && $this->ID == $project->ID;
239
	}
240
241
	/**
242
	 * Restrict access to viewing this project
243
	 *
244
	 * @param Member|null $member
245
	 * @return boolean
246
	 */
247
	public function canView($member = null) {
248
		if(!$member) {
249
			$member = Member::currentUser();
250
		}
251
252
		if(Permission::checkMember($member, 'ADMIN')) {
253
			return true;
254
		}
255
256
		return $member->inGroups($this->Viewers());
257
	}
258
259
	/**
260
	 * @param Member|null $member
261
	 *
262
	 * @return bool
263
	 */
264 View Code Duplication
	public function canRestore($member = null) {
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...
265
		if ($this->allowedAny(
266
			array(
267
				DNRoot::ALLOW_PROD_SNAPSHOT,
268
				DNRoot::ALLOW_NON_PROD_SNAPSHOT
269
			),
270
			$member
271
		)) {
272
			return true;
273
		}
274
275
		return (bool)$this->Environments()->filterByCallback(function($env) use($member) {
276
			return $env->canRestore($member);
277
		})->Count();
278
	}
279
280
	/**
281
	 * @param Member|null $member
282
	 * @return bool
283
	 */
284 View Code Duplication
	public function canBackup($member = null) {
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...
285
		if ($this->allowedAny(
286
			array(
287
				DNRoot::ALLOW_PROD_SNAPSHOT,
288
				DNRoot::ALLOW_NON_PROD_SNAPSHOT
289
			),
290
			$member
291
		)) {
292
			return true;
293
		}
294
295
		return (bool)$this->Environments()->filterByCallback(function($env) use($member) {
296
			return $env->canBackup($member);
297
		})->Count();
298
	}
299
300
	/**
301
	 * @param Member|null $member
302
	 * @return bool
303
	 */
304 View Code Duplication
	public function canUploadArchive($member = null) {
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...
305
		if ($this->allowedAny(
306
			array(
307
				DNRoot::ALLOW_PROD_SNAPSHOT,
308
				DNRoot::ALLOW_NON_PROD_SNAPSHOT
309
			),
310
			$member
311
		)) {
312
			return true;
313
		}
314
315
		return (bool)$this->Environments()->filterByCallback(function($env) use($member) {
316
			return $env->canUploadArchive($member);
317
		})->Count();
318
	}
319
320
	/**
321
	 * @param Member|null $member
322
	 * @return bool
323
	 */
324 View Code Duplication
	public function canDownloadArchive($member = null) {
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...
325
		if ($this->allowedAny(
326
			array(
327
				DNRoot::ALLOW_PROD_SNAPSHOT,
328
				DNRoot::ALLOW_NON_PROD_SNAPSHOT
329
			),
330
			$member
331
		)) {
332
			return true;
333
		}
334
335
		return (bool)$this->Environments()->filterByCallback(function($env) use($member) {
336
			return $env->canDownloadArchive($member);
337
		})->Count();
338
	}
339
340
	/**
341
	 * This is a permission check for the front-end only.
342
	 *
343
	 * Only admins can create environments for now. Also, we need to check the value
344
	 * of AllowedEnvironmentType which dictates which backend to use to render the form.
345
	 *
346
	 * @param Member|null $member
347
	 *
348
	 * @return bool
349
	 */
350
	public function canCreateEnvironments($member = null) {
351
		$envType = $this->AllowedEnvironmentType;
0 ignored issues
show
Documentation introduced by
The property AllowedEnvironmentType does not exist on object<DNProject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write 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.");
        }
    }

}

Since the property has write access only, you can use the @property-write 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...
352
		if($envType) {
353
			$env = Injector::inst()->get($envType);
354
			if($env instanceof EnvironmentCreateBackend) {
355
				return $this->allowed(DNRoot::ALLOW_CREATE_ENVIRONMENT, $member);
356
			}
357
		}
358
		return false;
359
	}
360
361
	/**
362
	 * @return DataList
363
	 */
364
	public function DataArchives() {
365
		$envIds = $this->Environments()->column('ID');
366
		return DNDataArchive::get()->filter('EnvironmentID', $envIds);
367
	}
368
369
	/**
370
	 * Return all archives which are "manual upload requests",
371
	 * meaning they don't have a file attached to them (yet).
372
	 *
373
	 * @return DataList
374
	 */
375
	public function PendingManualUploadDataArchives() {
376
		return $this->DataArchives()->filter('ArchiveFileID', null);
377
	}
378
379
	/**
380
	 * Build an environment variable array to be used with this project.
381
	 *
382
	 * This is relevant if every project needs to use an individual SSH pubkey.
383
	 *
384
	 * Include this with all Gitonomy\Git\Repository, and
385
	 * \Symfony\Component\Process\Processes.
386
	 *
387
	 * @return array
388
	 */
389
	public function getProcessEnv() {
390
		if(file_exists($this->getPrivateKeyPath())) {
391
			// Key-pair is available, use it.
392
			$processEnv = array(
393
				'IDENT_KEY' => $this->getPrivateKeyPath(),
394
				'GIT_SSH' => BASE_PATH . "/deploynaut/git-deploy.sh"
395
			);
396
		} else {
397
			$processEnv = array();
398
		}
399
		$this->extend('updateProcessEnv', $processEnv);
400
401
		return $processEnv;
402
	}
403
404
	/**
405
	 * Get a string of people allowed to view this project
406
	 *
407
	 * @return string
408
	 */
409
	public function getViewersList() {
410
		return implode(", ", $this->Viewers()->column("Title"));
411
	}
412
413
	/**
414
	 * @return DNData
415
	 */
416
	public function DNData() {
417
		return DNData::inst();
418
	}
419
420
	/**
421
	 * Provides a DNBuildList of builds found in this project.
422
	 *
423
	 * @return DNReferenceList
424
	 */
425
	public function DNBuildList() {
426
		return DNReferenceList::create($this, $this->DNData());
427
	}
428
429
	/**
430
	 * Provides a list of the branches in this project.
431
	 *
432
	 * @return DNBranchList
433
	 */
434
	public function DNBranchList() {
435
		if($this->CVSPath && !$this->repoExists()) {
436
			$this->cloneRepo();
437
		}
438
		return DNBranchList::create($this, $this->DNData());
439
	}
440
441
	/**
442
	 * Provides a list of the tags in this project.
443
	 *
444
	 * @return DNReferenceList
445
	 */
446
	public function DNTagList() {
447
		if($this->CVSPath && !$this->repoExists()) {
448
			$this->cloneRepo();
449
		}
450
		return DNReferenceList::create($this, $this->DNData(), null, null, true);
451
	}
452
453
	/**
454
	 * @return false|Gitonomy\Git\Repository
455
	 */
456
	public function getRepository() {
457
		if(!$this->repoExists()) {
458
			return false;
459
		}
460
461
		return new Gitonomy\Git\Repository($this->getLocalCVSPath());
462
	}
463
464
	/**
465
	 * Provides a list of environments found in this project.
466
	 * CAUTION: filterByCallback will change this into an ArrayList!
467
	 *
468
	 * @return ArrayList
469
	 */
470
	public function DNEnvironmentList() {
471
472
		if(!self::$_current_member_cache) {
473
			self::$_current_member_cache = Member::currentUser();
0 ignored issues
show
Documentation Bug introduced by
It seems like \Member::currentUser() can also be of type object<DataObject>. However, the property $_current_member_cache is declared as type boolean|object<Member>. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
474
		}
475
476
		if(self::$_current_member_cache === false) {
477
			return new ArrayList();
478
		}
479
480
		$currentMember = self::$_current_member_cache;
481
		return $this->Environments()
482
			->filterByCallBack(function($item) use ($currentMember) {
483
				return $item->canView($currentMember);
484
			});
485
	}
486
487
	/**
488
	 * @param string $usage
489
	 * @return ArrayList
490
	 */
491
	public function EnvironmentsByUsage($usage) {
492
		return $this->DNEnvironmentList()->filter('Usage', $usage);
493
	}
494
495
	/**
496
	 * Returns a map of envrionment name to build name
497
	 *
498
	 * @return false|DNDeployment
499
	 */
500
	public function currentBuilds() {
501
		if(!isset(self::$relation_cache['currentBuilds.'.$this->ID])) {
502
			$currentBuilds = array();
503
			foreach($this->Environments() as $env) {
504
				$currentBuilds[$env->Name] = $env->CurrentBuild();
505
			}
506
			self::$relation_cache['currentBuilds.'.$this->ID] = $currentBuilds;
507
		}
508
		return self::$relation_cache['currentBuilds.'.$this->ID];
509
	}
510
511
	/**
512
	 * @param string
513
	 * @return string
514
	 */
515
	public function Link($action = '') {
516
		return Controller::join_links("naut", "project", $this->Name, $action);
517
	}
518
519
	/**
520
	 * @return string|null
521
	 */
522
	public function CreateEnvironmentLink() {
523
		if($this->canCreateEnvironments()) {
524
			return $this->Link('createenv');
525
		}
526
		return null;
527
	}
528
529
	/**
530
	 * @return string
531
	 */
532
	public function ToggleStarLink() {
533
		return $this->Link('/star');
534
	}
535
536
	/**
537
	 * @return bool
538
	 */
539
	public function IsStarred() {
540
		$member = Member::currentUser();
541
		if($member === null) {
542
			return false;
543
		}
544
		$favourited = $this->StarredBy()->filter('MemberID', $member->ID);
545
		if($favourited->count() == 0) {
546
			return false;
547
		}
548
		return true;
549
	}
550
551
	/**
552
	 * @param string $action
553
	 * @return string
554
	 */
555
	public function APILink($action) {
556
		return Controller::join_links("naut", "api", $this->Name, $action);
557
	}
558
559
	/**
560
	 * @return FieldList
561
	 */
562
	public function getCMSFields() {
563
		$fields = parent::getCMSFields();
564
565
		/** @var GridField $environments */
566
		$environments = $fields->dataFieldByName("Environments");
567
568
		$fields->fieldByName("Root")->removeByName("Viewers");
569
		$fields->fieldByName("Root")->removeByName("Environments");
570
		$fields->fieldByName("Root")->removeByName("LocalCVSPath");
571
572
		$diskQuotaDesc = 'This is the maximum amount of disk space (in megabytes) that all environments within this '
573
			. 'project can use for stored snapshots';
574
		$fields->dataFieldByName('DiskQuotaMB')->setDescription($diskQuotaDesc);
575
576
		$projectNameDesc = 'Changing the name will <strong>reset</strong> the deploy configuration and avoid using non'
577
			. 'alphanumeric characters';
578
		$fields->fieldByName('Root.Main.Name')
579
			->setTitle('Project name')
580
			->setDescription($projectNameDesc);
581
582
		$fields->fieldByName('Root.Main.CVSPath')
583
			->setTitle('Git repository')
584
			->setDescription('E.g. [email protected]:silverstripe/silverstripe-installer.git');
585
586
		$workspaceField = new ReadonlyField('LocalWorkspace', 'Git workspace', $this->getLocalCVSPath());
587
		$workspaceField->setDescription('This is where the GIT repository are located on this server');
588
		$fields->insertAfter($workspaceField, 'CVSPath');
0 ignored issues
show
Documentation introduced by
'CVSPath' is of type string, but the function expects a object<FormField>.

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...
589
590
		$readAccessGroups = ListboxField::create('Viewers', 'Project viewers', Group::get()->map()->toArray())
591
			->setMultiple(true)
592
			->setDescription('These groups can view the project in the front-end.');
593
		$fields->addFieldToTab("Root.Main", $readAccessGroups);
594
595
		$this->setCreateProjectFolderField($fields);
596
		$this->setEnvironmentFields($fields, $environments);
597
598
		$environmentTypes = ClassInfo::implementorsOf('EnvironmentCreateBackend');
599
		$types = array();
600
		foreach($environmentTypes as $type) {
601
			$types[$type] = $type;
602
		}
603
604
		$fields->addFieldsToTab('Root.Main', array(
605
			DropdownField::create(
606
				'AllowedEnvironmentType',
607
				'Allowed Environment Type',
608
				$types
609
			)->setDescription('This defined which form to show on the front end for '
610
				. 'environment creation. This will not affect backend functionality.')
611
			->setEmptyString(' - None - '),
612
		));
613
614
		return $fields;
615
	}
616
617
	/**
618
	 * If there isn't a capistrano env project folder, show options to create one
619
	 *
620
	 * @param FieldList $fields
621
	 */
622
	public function setCreateProjectFolderField(&$fields) {
623
		// Check if the capistrano project folder exists
624
		if(!$this->Name) {
625
			return;
626
		}
627
628
		if($this->projectFolderExists()) {
629
			return;
630
		}
631
632
		$createFolderNotice = new LabelField('CreateEnvFolderNotice', 'Warning: No Capistrano project folder exists');
633
		$createFolderNotice->addExtraClass('message warning');
634
		$fields->insertBefore($createFolderNotice, 'Name');
0 ignored issues
show
Documentation introduced by
'Name' is of type string, but the function expects a object<FormField>.

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...
635
		$createFolderField = new CheckboxField('CreateEnvFolder', 'Create folder');
636
		$createFolderField->setDescription('Would you like to create the capistrano project folder?');
637
		$fields->insertAfter($createFolderField, 'CreateEnvFolderNotice');
638
	}
639
640
	/**
641
	 * @return boolean
642
	 */
643
	public function projectFolderExists() {
644
		return file_exists($this->getProjectFolderPath());
645
	}
646
647
	/**
648
	 * @return bool
649
	 */
650
	public function repoExists() {
651
		return file_exists(sprintf('%s/HEAD', $this->getLocalCVSPath()));
652
	}
653
654
	/**
655
	 * Setup a job to clone a git repository.
656
	 * @return string resque token
657
	 */
658
	public function cloneRepo() {
659
		// Avoid this being called multiple times in the same request
660
		if(!isset(self::$has_cloned_cache[$this->ID])) {
661
			$fetch = DNGitFetch::create();
662
			$fetch->ProjectID = $this->ID;
663
			$fetch->write();
664
665
			// passing true here tells DNGitFetch to force a git clone, otherwise
666
			// it will just update the repo if it already exists. We want to ensure
667
			// we're always cloning a new repo in this case, as the git URL may have changed.
668
			$fetch->start(true);
669
670
			self::$has_cloned_cache[$this->ID] = true;
671
		}
672
	}
673
674
	/**
675
	 * @return string
676
	 */
677
	public function getLocalCVSPath() {
678
		return sprintf('%s/%s', DEPLOYNAUT_LOCAL_VCS_PATH, $this->Name);
679
	}
680
681
	public function onBeforeWrite() {
682
		parent::onBeforeWrite();
683
684
		if($this->CreateEnvFolder && !file_exists($this->getProjectFolderPath())) {
0 ignored issues
show
Documentation introduced by
The property CreateEnvFolder does not exist on object<DNProject>. 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...
685
			mkdir($this->getProjectFolderPath());
686
		}
687
	}
688
689
	public function onAfterWrite() {
690
		parent::onAfterWrite();
691
692
		if(!$this->CVSPath) {
693
			return;
694
		}
695
696
		$changedFields = $this->getChangedFields(true, 2);
697
		if(isset($changedFields['CVSPath']) || isset($changedFields['Name'])) {
698
			$this->cloneRepo();
699
		}
700
	}
701
702
	/**
703
	 * Delete related environments and folders
704
	 */
705
	public function onAfterDelete() {
706
		parent::onAfterDelete();
707
708
		// Delete related environments
709
		foreach($this->Environments() as $env) {
710
			$env->delete();
711
		}
712
713
		// Delete local repository
714
		if(file_exists($this->getLocalCVSPath())) {
715
			Filesystem::removeFolder($this->getLocalCVSPath());
716
		}
717
718
		// Delete project template
719
		if(file_exists($this->getProjectFolderPath()) && Config::inst()->get('DNEnvironment', 'allow_web_editing')) {
720
			Filesystem::removeFolder($this->getProjectFolderPath());
721
		}
722
723
		// Delete the deploy key
724
		if(file_exists($this->getKeyDir())) {
725
			Filesystem::removeFolder($this->getKeyDir());
726
		}
727
	}
728
729
	/**
730
	 * Fetch the public key for this project.
731
	 *
732
	 * @return string|void
733
	 */
734
	public function getPublicKey() {
735
		$key = $this->getPublicKeyPath();
736
737
		if(file_exists($key)) {
738
			return trim(file_get_contents($key));
739
		}
740
	}
741
742
	/**
743
	 * This returns that path of the public key if a key directory is set. It doesn't check whether the file exists.
744
	 *
745
	 * @return string|null
746
	 */
747
	public function getPublicKeyPath() {
748
		if($privateKey = $this->getPrivateKeyPath()) {
749
			return $privateKey . '.pub';
750
		}
751
		return null;
752
	}
753
754
	/**
755
	 * This returns that path of the private key if a key directory is set. It doesn't check whether the file exists.
756
	 *
757
	 * @return string|null
758
	 */
759
	public function getPrivateKeyPath() {
760
		$keyDir = $this->getKeyDir();
761
		if(!empty($keyDir)) {
762
			$filter = FileNameFilter::create();
763
			$name = $filter->filter($this->Name);
764
			return $keyDir . '/' . $name;
765
		}
766
		return null;
767
	}
768
769
	/**
770
	 * Returns the location of the projects key dir if one exists.
771
	 *
772
	 * @return string|null
773
	 */
774
	public function getKeyDir() {
775
		$keyDir = $this->DNData()->getKeyDir();
776
		if(!$keyDir) {
777
			return null;
778
		}
779
780
		$filter = FileNameFilter::create();
781
		$name = $filter->filter($this->Name);
782
783
		return $this->DNData()->getKeyDir() . '/' . $name;
784
	}
785
786
	/**
787
	 * Setup a gridfield for the environment configs
788
	 *
789
	 * @param FieldList $fields
790
	 * @param GridField $environments
791
	 */
792
	protected function setEnvironmentFields(&$fields, $environments) {
793
		if(!$environments) {
794
			return;
795
		}
796
797
		$environments->getConfig()->addComponent(new GridFieldAddNewMultiClass());
798
		$environments->getConfig()->removeComponentsByType('GridFieldAddNewButton');
799
		$environments->getConfig()->removeComponentsByType('GridFieldAddExistingAutocompleter');
800
		$environments->getConfig()->removeComponentsByType('GridFieldDeleteAction');
801
		$environments->getConfig()->removeComponentsByType('GridFieldPageCount');
802
		if(Config::inst()->get('DNEnvironment', 'allow_web_editing')) {
803
			$addNewRelease = new GridFieldAddNewButton('toolbar-header-right');
804
			$addNewRelease->setButtonName('Add');
805
			$environments->getConfig()->addComponent($addNewRelease);
806
		}
807
808
		$fields->addFieldToTab("Root.Main", $environments);
809
	}
810
811
	/**
812
	 * Provide current repository URL to the users.
813
	 *
814
	 * @return void|string
815
	 */
816
	public function getRepositoryURL() {
817
		$showUrl = Config::inst()->get($this->class, 'show_repository_url');
818
		if($showUrl) {
819
			return $this->CVSPath;
820
		}
821
	}
822
823
	/**
824
	 * Whitelist configuration that describes how to convert a repository URL into a link
825
	 * to a web user interface for that URL
826
	 *
827
	 * Consists of a hash of "full.lower.case.domain" => {configuration} key/value pairs
828
	 *
829
	 * {configuration} can either be boolean true to auto-detect both the host and the
830
	 * name of the UI provider, or a nested array that overrides either one or both
831
	 * of the auto-detected valyes
832
	 *
833
	 * @var array
834
	 */
835
	static private $repository_interfaces = array(
836
		'github.com' => array(
837
			'icon' => 'deploynaut/img/github.png',
838
			'name' => 'Github.com',
839
		),
840
		'bitbucket.org' => array(
841
			'commit' => 'commits',
842
			'name' => 'Bitbucket.org',
843
		),
844
		'repo.or.cz' => array(
845
			'scheme' => 'http',
846
			'name' => 'repo.or.cz',
847
			'regex' => array('^(.*)$' => '/w$1'),
848
		),
849
850
		/* Example for adding your own gitlab repository and override all auto-detected values (with their defaults)
0 ignored issues
show
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
851
		'gitlab.mysite.com' => array(
852
			'icon' => 'deploynaut/img/git.png',
853
			'host' => 'gitlab.mysite.com',
854
			'name' => 'Gitlab',
855
			'regex' => array('.git$' => ''),
856
			'commit' => "commit"
857
		),
858
		*/
859
	);
860
861
	/**
862
	 * Get a ViewableData structure describing the UI tool that lets the user view the repository code
863
	 *
864
	 * @return ArrayData
865
	 */
866
	public function getRepositoryInterface() {
867
		$interfaces = $this->config()->repository_interfaces;
868
869
		/* Look for each whitelisted hostname */
870
		foreach($interfaces as $host => $interface) {
871
			/* See if the CVS Path is for this hostname, followed by some junk (maybe a port), then the path */
872
			if(preg_match('{^[^.]*' . $host . '(.*?)([/a-zA-Z].+)}', $this->CVSPath, $match)) {
873
874
				$path = $match[2];
875
876
				$scheme = isset($interface['scheme']) ? $interface['scheme'] : 'https';
877
				$host = isset($interface['host']) ? $interface['host'] : $host;
878
				$regex = isset($interface['regex']) ? $interface['regex'] : array('\.git$' => '');
879
880
				$components = explode('.', $host);
881
882
				foreach($regex as $pattern => $replacement) {
883
					$path = preg_replace('/' . $pattern . '/', $replacement, $path);
884
				}
885
886
				$uxurl = Controller::join_links($scheme . '://', $host, $path);
887
888
				if(array_key_exists('commit', $interface) && $interface['commit'] == false) {
889
					$commiturl = false;
890
				} else {
891
					$commiturl = Controller::join_links(
892
						$uxurl,
893
						isset($interface['commit']) ? $interface['commit'] : 'commit'
894
					);
895
				}
896
897
				return new ArrayData(array(
898
					'Name'      => isset($interface['name']) ? $interface['name'] : ucfirst($components[0]),
899
					'Icon'      => isset($interface['icon']) ? $interface['icon'] : 'deploynaut/img/git.png',
900
					'URL'       => $uxurl,
901
					'CommitURL' => $commiturl
902
				));
903
			}
904
		}
905
	}
906
907
	/**
908
	 * @return string
909
	 */
910
	protected function getProjectFolderPath() {
911
		return sprintf('%s/%s', $this->DNData()->getEnvironmentDir(), $this->Name);
912
	}
913
914
	/**
915
	 * Convenience wrapper for a single permission code.
916
	 *
917
	 * @param string $code
918
	 * @return SS_List
919
	 */
920
	public function whoIsAllowed($code) {
921
		return $this->whoIsAllowedAny(array($code));
922
	}
923
924
	/**
925
	 * List members who have $codes on this project.
926
	 * Does not support Permission::DENY_PERMISSION malarky, same as Permission::get_groups_by_permission anyway...
927
	 *
928
	 * @param array|string $codes
929
	 * @return SS_List
930
	 */
931
	public function whoIsAllowedAny($codes) {
932
		if(!is_array($codes)) $codes = array($codes);
933
934
		$SQLa_codes = Convert::raw2sql($codes);
935
		$SQL_codes = join("','", $SQLa_codes);
936
937
		return DataObject::get('Member')
938
			->where("\"PermissionRoleCode\".\"Code\" IN ('$SQL_codes') OR \"Permission\".\"Code\" IN ('$SQL_codes')")
939
			->filter("DNProject_Viewers.DNProjectID", $this->ID)
940
			->leftJoin('Group_Members', "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\"")
941
			->leftJoin('Group', "\"Group_Members\".\"GroupID\" = \"Group\".\"ID\"")
942
			->leftJoin('DNProject_Viewers', "\"DNProject_Viewers\".\"GroupID\" = \"Group\".\"ID\"")
943
			->leftJoin('Permission', "\"Permission\".\"GroupID\" = \"Group\".\"ID\"")
944
			->leftJoin('Group_Roles', "\"Group_Roles\".\"GroupID\" = \"Group\".\"ID\"")
945
			->leftJoin('PermissionRole', "\"Group_Roles\".\"PermissionRoleID\" = \"PermissionRole\".\"ID\"")
946
			->leftJoin('PermissionRoleCode', "\"PermissionRoleCode\".\"RoleID\" = \"PermissionRole\".\"ID\"");
947
	}
948
949
	/**
950
	 * Convenience wrapper for a single permission code.
951
	 *
952
	 * @param string $code
953
	 * @param Member|null $member
954
	 *
955
	 * @return bool
956
	 */
957
	public function allowed($code, $member = null) {
958
		return $this->allowedAny(array($code), $member);
959
	}
960
961
	/**
962
	 * Checks if a group is allowed to the project and the permission code
963
	 *
964
	 * @param string $permissionCode
965
	 * @param Group $group
966
	 *
967
	 * @return bool
968
	 */
969
	public function groupAllowed($permissionCode, Group $group) {
970
		$viewers = $this->Viewers();
971
		if(!$viewers->find('ID', $group->ID)) {
972
			return false;
973
		}
974
		$groups = Permission::get_groups_by_permission($permissionCode);
975
		if(!$groups->find('ID', $group->ID)) {
976
			return false;
977
		}
978
		return true;
979
	}
980
981
	/**
982
	 * Check if member has a permission code in this project.
983
	 *
984
	 * @param array|string $codes
985
	 * @param Member|null $member
986
	 *
987
	 * @return bool
988
	 */
989
	public function allowedAny($codes, $member = null) {
990
		if (!$member) {
991
			$member = Member::currentUser();
992
		}
993
994
		if(Permission::checkMember($member, 'ADMIN')) return true;
995
996
		$hits = $this->whoIsAllowedAny($codes)->filter('Member.ID', $member->ID)->count();
997
		return ($hits>0 ? true : false);
998
	}
999
1000
	/**
1001
	 * Checks if the environment has been fully built.
1002
	 *
1003
	 * @return bool
1004
	 */
1005
	public function isProjectReady() {
1006
		if($this->getRunningInitialEnvironmentCreations()->count() > 0) {
1007
			// We're still creating the initial environments for this project so we're
1008
			// not quite done
1009
			return false;
1010
		}
1011
1012
		// Provide a hook for further checks. Logic stolen from
1013
		// {@see DataObject::extendedCan()}
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
1014
		$isDone = $this->extend('isProjectReady');
1015
		if($isDone && is_array($isDone)) {
1016
			$isDone = array_filter($isDone, function($val) {
1017
				return !is_null($val);
1018
			});
1019
1020
			// If anything returns false then we're not ready.
1021
			if($isDone) return min($isDone);
1022
		}
1023
1024
		return true;
1025
	}
1026
1027
	/**
1028
	 * Returns a list of environments still being created.
1029
	 *
1030
	 * @return SS_List
1031
	 */
1032
	public function getRunningEnvironmentCreations() {
1033
		return $this->CreateEnvironments()
0 ignored issues
show
Bug introduced by
The method CreateEnvironments() does not exist on DNProject. Did you maybe mean canCreateEnvironments()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
1034
			->filter('Status', ['Queued', 'Started']);
1035
	}
1036
1037
	/**
1038
	 * Returns a list of initial environments created for this project.
1039
	 *
1040
	 * @return DataList
1041
	 */
1042
	public function getInitialEnvironmentCreations() {
1043
		return $this->CreateEnvironments()->filter('IsInitialEnvironment', true);
0 ignored issues
show
Bug introduced by
The method CreateEnvironments() does not exist on DNProject. Did you maybe mean canCreateEnvironments()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
1044
	}
1045
1046
	/**
1047
	 * Only returns initial environments that are being created.
1048
	 *
1049
	 * @return DataList
1050
	 */
1051
	public function getRunningInitialEnvironmentCreations() {
1052
		return $this->getInitialEnvironmentCreations()
1053
			->filter('Status', ['Queued', 'Started']);
1054
	}
1055
1056
	/**
1057
	 * Returns a list of completed initial environment creations. This includes failed tasks.
1058
	 *
1059
	 * @return DataList
1060
	 */
1061
	public function getCompleteInitialEnvironmentCreations() {
1062
		return $this->getInitialEnvironmentCreations()
1063
			->exclude('Status', ['Queued', 'Started']);
1064
	}
1065
1066
	/**
1067
	 * @return ValidationResult
1068
	 */
1069
	protected function validate() {
1070
		$validation = parent::validate();
1071
		if($validation->valid()) {
1072
			if(empty($this->Name)) {
1073
				return $validation->error('The stack must have a name.');
1074
			}
1075
1076
			// The name is used to build filepaths so should be restricted
1077
			if(!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9\-\_]+$/', $this->Name)) {
1078
				return $validation->error('Project name can only contain alphanumeric, hyphens and underscores.');
1079
			}
1080
1081
			if(empty($this->CVSPath)) {
1082
				return $validation->error('You must provide a repository URL.');
1083
			}
1084
1085
			$existing = DNProject::get()->filter('Name', $this->Name);
1086
			if($this->ID) {
1087
				$existing = $existing->exclude('ID', $this->ID);
1088
			}
1089
			if($existing->count() > 0) {
1090
				return $validation->error('A stack already exists with that name.');
1091
			}
1092
		}
1093
		return $validation;
1094
	}
1095
1096
	/**
1097
	 * @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...
1098
	 *
1099
	 * @return bool
1100
	 */
1101
	public function canCreate($member = null) {
1102
		if(!$member) $member = Member::currentUser();
1103
		if(!$member) return false;
1104
1105
		if(Permission::checkMember($member, 'ADMIN')) {
1106
			return true;
1107
		}
1108
1109
		// This calls canCreate on extensions.
1110
		return parent::canCreate($member);
1111
	}
1112
1113
}
1114
1115