Completed
Pull Request — master (#605)
by Sean
04:35
created

CapistranoDeploymentBackend::enableMaintenance()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 9

Duplication

Lines 12
Ratio 100 %

Importance

Changes 0
Metric Value
dl 12
loc 12
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 9
nc 2
nop 3
1
<?php
2
3
class CapistranoDeploymentBackend extends Object implements DeploymentBackend {
4
5
	protected $packageGenerator;
6
7
	public function getPackageGenerator() {
8
		return $this->packageGenerator;
9
	}
10
11
	public function setPackageGenerator(PackageGenerator $packageGenerator) {
12
		$this->packageGenerator = $packageGenerator;
13
	}
14
15
	/**
16
	 * Create a deployment strategy.
17
	 *
18
	 * @param DNEnvironment $environment
19
	 * @param array $options
20
	 *
21
	 * @return DeploymentStrategy
22
	 */
23
	public function planDeploy(DNEnvironment $environment, $options) {
24
		$strategy = new DeploymentStrategy($environment, $options);
25
26
		$currentBuild = $environment->CurrentBuild();
27
		$currentSha = $currentBuild ? $currentBuild->SHA : '-';
28
		if($currentSha !== $options['sha']) {
29
			$strategy->setChange('Code version', $currentSha, $options['sha']);
30
		}
31
		$strategy->setActionTitle('Confirm deployment');
32
		$strategy->setActionCode('fast');
33
		$strategy->setEstimatedTime('2');
34
35
		return $strategy;
36
	}
37
38
	/**
39
	 * Deploy the given build to the given environment.
40
	 *
41
	 * @param DNEnvironment $environment
42
	 * @param DeploynautLogFile $log
43
	 * @param DNProject $project
44
	 * @param array $options
45
	 */
46
	public function deploy(
47
		DNEnvironment $environment,
48
		DeploynautLogFile $log,
49
		DNProject $project,
50
		$options
51
	) {
52
		$name = $environment->getFullName();
53
		$repository = $project->getLocalCVSPath();
54
		$sha = $options['sha'];
55
56
		$args = array(
57
			'branch' => $sha,
58
			'repository' => $repository,
59
		);
60
61
		$this->extend('deployStart', $environment, $sha, $log, $project);
62
63
		$log->write(sprintf('Deploying "%s" to "%s"', $sha, $name));
64
65
		$this->enableMaintenance($environment, $log, $project);
66
67
		// Use a package generator if specified, otherwise run a direct deploy, which is the default behaviour
68
		// if build_filename isn't specified
69
		if($this->packageGenerator) {
70
			$log->write(sprintf('Using package generator "%s"', get_class($this->packageGenerator)));
71
72
			try {
73
				$args['build_filename'] = $this->packageGenerator->getPackageFilename($project->Name, $sha, $repository, $log);
74
			} catch (Exception $e) {
75
				$log->write($e->getMessage());
76
				throw $e;
77
			}
78
79
			if(empty($args['build_filename'])) {
80
				throw new RuntimeException('Failed to generate package.');
81
			}
82
		}
83
84
		$command = $this->getCommand('deploy', 'web', $environment, $args, $log);
85
		$command->run(function($type, $buffer) use($log) {
86
			$log->write($buffer);
87
		});
88
89
		// Deployment cleanup. We assume it is always safe to run this at the end, regardless of the outcome.
90
		$self = $this;
91
		$cleanupFn = function() use($self, $environment, $args, $log, $sha, $project) {
92
			$command = $self->getCommand('deploy:cleanup', 'web', $environment, $args, $log);
93
			$command->run(function($type, $buffer) use($log) {
94
				$log->write($buffer);
95
			});
96
97
			if(!$command->isSuccessful()) {
98
				$self->extend('cleanupFailure', $environment, $sha, $log, $project);
99
				$log->write('Warning: Cleanup failed, but fine to continue. Needs manual cleanup sometime.');
100
			}
101
		};
102
103
		// Once the deployment has run it's necessary to update the maintenance page status
104
		// as deploying removes .htaccess
105
		$this->enableMaintenance($environment, $log, $project);
106
107
		if(!$command->isSuccessful() || !$this->smokeTest($environment, $log)) {
108
			$cleanupFn();
109
			$this->extend('deployFailure', $environment, $sha, $log, $project);
110
111
			$currentBuild = $environment->CurrentBuild();
112
			if (empty($currentBuild) || !empty($options['no_rollback'])) {
113
				throw new RuntimeException($command->getErrorOutput());
114
			}
115
116
			// re-run deploy with the current build sha to rollback
117
			$log->write('Deploy failed. Rolling back');
118
			$rollbackArgs = array_merge($args, ['sha' => $currentBuild->SHA]);
119
			$command = $this->getCommand('deploy', 'web', $environment, $rollbackArgs, $log);
120
			$command->run(function($type, $buffer) use($log) {
121
				$log->write($buffer);
122
			});
123
124
			// Once the deployment has run it's necessary to update the maintenance page status
125
			// as deploying removes .htaccess
126
			$this->enableMaintenance($environment, $log, $project);
127
128
			if (!$command->isSuccessful() || !$this->smokeTest($environment, $log)) {
129
				$this->extend('deployRollbackFailure', $environment, $currentBuild->SHA, $log, $project);
130
				$log->write('Rollback failed');
131
				throw new RuntimeException($command->getErrorOutput());
132
			}
133
		}
134
135
		$this->disableMaintenance($environment, $log, $project);
136
137
		$cleanupFn();
138
139
		$log->write(sprintf('Deploy of "%s" to "%s" finished', $sha, $name));
140
141
		$this->extend('deployEnd', $environment, $sha, $log, $project);
142
	}
143
144
	/**
145
	 * @return array
146
	 */
147
	public function getDeployOptions() {
148
		return [
149
			new NoRollbackDeployOption()
150
			new PredeployBackupOption(true),
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_NEW, expecting ']'
Loading history...
151
		];
152
	}
153
154
	/**
155
	 * Enable a maintenance page for the given environment using the maintenance:enable Capistrano task.
156
	 */
157
	public function enableMaintenance(DNEnvironment $environment, DeploynautLogFile $log, DNProject $project) {
158
		$name = $environment->getFullName();
159
		$command = $this->getCommand('maintenance:enable', 'web', $environment, null, $log);
160
		$command->run(function($type, $buffer) use($log) {
161
			$log->write($buffer);
162
		});
163
		if(!$command->isSuccessful()) {
164
			$this->extend('maintenanceEnableFailure', $environment, $log);
165
			throw new RuntimeException($command->getErrorOutput());
166
		}
167
		$log->write(sprintf('Maintenance page enabled on "%s"', $name));
168
	}
169
170
	/**
171
	 * Disable the maintenance page for the given environment using the maintenance:disable Capistrano task.
172
	 */
173
	public function disableMaintenance(DNEnvironment $environment, DeploynautLogFile $log, DNProject $project) {
174
		$name = $environment->getFullName();
175
		$command = $this->getCommand('maintenance:disable', 'web', $environment, null, $log);
176
		$command->run(function($type, $buffer) use($log) {
177
			$log->write($buffer);
178
		});
179
		if(!$command->isSuccessful()) {
180
			$this->extend('maintenanceDisableFailure', $environment, $log);
181
			throw new RuntimeException($command->getErrorOutput());
182
		}
183
		$log->write(sprintf('Maintenance page disabled on "%s"', $name));
184
	}
185
186
	/**
187
	 * Check the status using the deploy:check capistrano method
188
	 */
189
	public function ping(DNEnvironment $environment, DeploynautLogFile $log, DNProject $project) {
190
		$command = $this->getCommand('deploy:check', 'web', $environment, null, $log);
191
		$command->run(function($type, $buffer) use($log) {
192
			$log->write($buffer);
193
			echo $buffer;
194
		});
195
	}
196
197
	/**
198
	 * @inheritdoc
199
	 */
200
	public function dataTransfer(DNDataTransfer $dataTransfer, DeploynautLogFile $log) {
201
		if($dataTransfer->Direction == 'get') {
202
			$this->dataTransferBackup($dataTransfer, $log);
203
		} else {
204
			$environment = $dataTransfer->Environment();
205
			$project = $environment->Project();
206
			$workingDir = TEMP_FOLDER . DIRECTORY_SEPARATOR . 'deploynaut-transfer-' . $dataTransfer->ID;
207
			$archive = $dataTransfer->DataArchive();
208
209
			// extract the sspak contents, we'll need these so capistrano can restore that content
210
			try {
211
				$archive->extractArchive($workingDir);
212
			} catch(Exception $e) {
213
				$log->write($e->getMessage());
214
				throw new RuntimeException($e->getMessage());
215
			}
216
217
			// validate the contents match the requested transfer mode
218
			$result = $archive->validateArchiveContents($dataTransfer->Mode);
219
			if(!$result->valid()) {
220
				// do some cleaning, get rid of the extracted archive lying around
221
				$process = new AbortableProcess(sprintf('rm -rf %s', escapeshellarg($workingDir)));
222
				$process->setTimeout(120);
223
				$process->run();
224
225
				// log the reason why we can't restore the snapshot and halt the process
226
				$log->write($result->message());
227
				throw new RuntimeException($result->message());
228
			}
229
230
			// Put up a maintenance page during a restore of db or assets.
231
			$this->enableMaintenance($environment, $log, $project);
232
			$this->dataTransferRestore($workingDir, $dataTransfer, $log);
233
			$this->disableMaintenance($environment, $log, $project);
234
		}
235
	}
236
237
	/**
238
	 * @param string $action Capistrano action to be executed
239
	 * @param string $roles Defining a server role is required to target only the required servers.
240
	 * @param DNEnvironment $environment
241
	 * @param array<string>|null $args Additional arguments for process
242
	 * @param DeploynautLogFile $log
243
	 * @return \Symfony\Component\Process\Process
244
	 */
245
	public function getCommand($action, $roles, DNEnvironment $environment, $args = null, DeploynautLogFile $log) {
246
		$name = $environment->getFullName();
247
		$env = $environment->Project()->getProcessEnv();
248
249
		if(!$args) {
250
			$args = array();
251
		}
252
		$args['history_path'] = realpath(DEPLOYNAUT_LOG_PATH . '/');
253
		$args['environment_id'] = $environment->ID;
254
255
		// Inject env string directly into the command.
256
		// Capistrano doesn't like the $process->setEnv($env) we'd normally do below.
257
		$envString = '';
258
		if(!empty($env)) {
259
			$envString .= 'env ';
260
			foreach($env as $key => $value) {
261
				$envString .= "$key=\"$value\" ";
262
			}
263
		}
264
265
		$data = DNData::inst();
266
		// Generate a capfile from a template
267
		$capTemplate = file_get_contents(BASE_PATH . '/deploynaut/Capfile.template');
268
		$cap = str_replace(
269
			array('<config root>', '<ssh key>', '<base path>'),
270
			array($data->getEnvironmentDir(), DEPLOYNAUT_SSH_KEY, BASE_PATH),
271
			$capTemplate
272
		);
273
274
		if(defined('DEPLOYNAUT_CAPFILE')) {
275
			$capFile = DEPLOYNAUT_CAPFILE;
276
		} else {
277
			$capFile = ASSETS_PATH . '/Capfile';
278
		}
279
		file_put_contents($capFile, $cap);
280
281
		$command = "{$envString}cap -f " . escapeshellarg($capFile) . " -vv $name $action ROLES=$roles";
282
		foreach($args as $argName => $argVal) {
283
			$command .= ' -s ' . escapeshellarg($argName) . '=' . escapeshellarg($argVal);
284
		}
285
286
		$log->write(sprintf('Running command: %s', $command));
287
288
		$process = new AbortableProcess($command);
289
		$process->setTimeout(3600);
290
		return $process;
291
	}
292
293
	/**
294
	 * Backs up database and/or assets to a designated folder,
295
	 * and packs up the files into a single sspak.
296
	 *
297
	 * @param DNDataTransfer    $dataTransfer
298
	 * @param DeploynautLogFile $log
299
	 */
300
	protected function dataTransferBackup(DNDataTransfer $dataTransfer, DeploynautLogFile $log) {
301
		$environment = $dataTransfer->Environment();
302
		$name = $environment->getFullName();
303
304
		// Associate a new archive with the transfer.
305
		// Doesn't retrieve a filepath just yet, need to generate the files first.
306
		$dataArchive = DNDataArchive::create();
307
		$dataArchive->Mode = $dataTransfer->Mode;
308
		$dataArchive->AuthorID = $dataTransfer->AuthorID;
309
		$dataArchive->OriginalEnvironmentID = $environment->ID;
310
		$dataArchive->EnvironmentID = $environment->ID;
311
		$dataArchive->IsBackup = $dataTransfer->IsBackupDataTransfer();
312
313
		// Generate directory structure with strict permissions (contains very sensitive data)
314
		$filepathBase = $dataArchive->generateFilepath($dataTransfer);
315
		mkdir($filepathBase, 0700, true);
316
317
		$databasePath = $filepathBase . DIRECTORY_SEPARATOR . 'database.sql';
318
319
		// Backup database
320
		if(in_array($dataTransfer->Mode, array('all', 'db'))) {
321
			$log->write(sprintf('Backup of database from "%s" started', $name));
322
			$command = $this->getCommand('data:getdb', 'db', $environment, array('data_path' => $databasePath), $log);
323
			$command->run(function($type, $buffer) use($log) {
324
				$log->write($buffer);
325
			});
326
			if(!$command->isSuccessful()) {
327
				$this->extend('dataTransferFailure', $environment, $log);
328
				throw new RuntimeException($command->getErrorOutput());
329
			}
330
			$log->write(sprintf('Backup of database from "%s" done', $name));
331
		}
332
333
		// Backup assets
334
		if(in_array($dataTransfer->Mode, array('all', 'assets'))) {
335
			$log->write(sprintf('Backup of assets from "%s" started', $name));
336
			$command = $this->getCommand('data:getassets', 'web', $environment, array('data_path' => $filepathBase), $log);
337
			$command->run(function($type, $buffer) use($log) {
338
				$log->write($buffer);
339
			});
340
			if(!$command->isSuccessful()) {
341
				$this->extend('dataTransferFailure', $environment, $log);
342
				throw new RuntimeException($command->getErrorOutput());
343
			}
344
			$log->write(sprintf('Backup of assets from "%s" done', $name));
345
		}
346
347
		// ensure the database connection is re-initialised, which is needed if the transfer
348
		// above took a really long time because the handle to the db may have become invalid.
349
		global $databaseConfig;
350
		DB::connect($databaseConfig);
351
352
		$log->write('Creating sspak...');
353
354
		$sspakFilename = sprintf('%s.sspak', $dataArchive->generateFilename($dataTransfer));
355
		$sspakFilepath = $filepathBase . DIRECTORY_SEPARATOR . $sspakFilename;
356
357
		try {
358
			$dataArchive->attachFile($sspakFilepath, $dataTransfer);
359
			$dataArchive->setArchiveFromFiles($filepathBase);
360
		} catch(Exception $e) {
361
			$log->write($e->getMessage());
362
			throw new RuntimeException($e->getMessage());
363
		}
364
365
		// Remove any assets and db files lying around, they're not longer needed as they're now part
366
		// of the sspak file we just generated. Use --force to avoid errors when files don't exist,
367
		// e.g. when just an assets backup has been requested and no database.sql exists.
368
		$process = new AbortableProcess(sprintf('rm -rf %s/assets && rm -f %s', escapeshellarg($filepathBase), escapeshellarg($databasePath)));
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 120 characters; contains 137 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
369
		$process->setTimeout(120);
370
		$process->run();
371
		if(!$process->isSuccessful()) {
372
			$log->write('Could not delete temporary files');
373
			throw new RuntimeException($process->getErrorOutput());
374
		}
375
376
		$log->write(sprintf('Creating sspak file done: %s', $dataArchive->ArchiveFile()->getAbsoluteURL()));
377
	}
378
379
	/**
380
	 * Utility function for triggering the db rebuild and flush.
381
	 * Also cleans up and generates new error pages.
382
	 * @param DeploynautLogFile $log
383
	 */
384
	public function rebuild(DNEnvironment $environment, $log) {
385
		$name = $environment->getFullName();
386
		$command = $this->getCommand('deploy:migrate', 'web', $environment, null, $log);
387
		$command->run(function($type, $buffer) use($log) {
388
			$log->write($buffer);
389
		});
390
		if(!$command->isSuccessful()) {
391
			$log->write(sprintf('Rebuild of "%s" failed: %s', $name, $command->getErrorOutput()));
392
			throw new RuntimeException($command->getErrorOutput());
393
		}
394
		$log->write(sprintf('Rebuild of "%s" done', $name));
395
	}
396
397
	/**
398
	 * Extracts a *.sspak file referenced through the passed in $dataTransfer
399
	 * and pushes it to the environment referenced in $dataTransfer.
400
	 *
401
	 * @param string $workingDir Directory for the unpacked files.
402
	 * @param DNDataTransfer $dataTransfer
403
	 * @param DeploynautLogFile $log
404
	 */
405
	protected function dataTransferRestore($workingDir, DNDataTransfer $dataTransfer, DeploynautLogFile $log) {
406
		$environment = $dataTransfer->Environment();
407
		$name = $environment->getFullName();
408
409
		// Rollback cleanup.
410
		$self = $this;
411
		$cleanupFn = function() use($self, $workingDir, $environment, $log) {
412
			// Rebuild makes sense even if failed - maybe we can at least partly recover.
413
			$self->rebuild($environment, $log);
414
			$process = new AbortableProcess(sprintf('rm -rf %s', escapeshellarg($workingDir)));
415
			$process->setTimeout(120);
416
			$process->run();
417
		};
418
419
		// Restore database into target environment
420
		if(in_array($dataTransfer->Mode, array('all', 'db'))) {
421
			$log->write(sprintf('Restore of database to "%s" started', $name));
422
			$args = array('data_path' => $workingDir . DIRECTORY_SEPARATOR . 'database.sql');
423
			$command = $this->getCommand('data:pushdb', 'db', $environment, $args, $log);
424
			$command->run(function($type, $buffer) use($log) {
425
				$log->write($buffer);
426
			});
427
			if(!$command->isSuccessful()) {
428
				$cleanupFn();
429
				$log->write(sprintf('Restore of database to "%s" failed: %s', $name, $command->getErrorOutput()));
430
				$this->extend('dataTransferFailure', $environment, $log);
431
				throw new RuntimeException($command->getErrorOutput());
432
			}
433
			$log->write(sprintf('Restore of database to "%s" done', $name));
434
		}
435
436
		// Restore assets into target environment
437
		if(in_array($dataTransfer->Mode, array('all', 'assets'))) {
438
			$log->write(sprintf('Restore of assets to "%s" started', $name));
439
			$args = array('data_path' => $workingDir . DIRECTORY_SEPARATOR . 'assets');
440
			$command = $this->getCommand('data:pushassets', 'web', $environment, $args, $log);
441
			$command->run(function($type, $buffer) use($log) {
442
				$log->write($buffer);
443
			});
444
			if(!$command->isSuccessful()) {
445
				$cleanupFn();
446
				$log->write(sprintf('Restore of assets to "%s" failed: %s', $name, $command->getErrorOutput()));
447
				$this->extend('dataTransferFailure', $environment, $log);
448
				throw new RuntimeException($command->getErrorOutput());
449
			}
450
			$log->write(sprintf('Restore of assets to "%s" done', $name));
451
		}
452
453
		$log->write('Rebuilding and cleaning up');
454
		$cleanupFn();
455
	}
456
457
	/**
458
	 * This is mostly copy-pasted from Anthill/Smoketest.
459
	 *
460
	 * @param DNEnvironment $environment
461
	 * @param DeploynautLogFile $log
462
	 * @return bool
463
	 */
464
	protected function smokeTest(DNEnvironment $environment, DeploynautLogFile $log) {
465
		$url = $environment->getBareURL();
466
		$timeout = 600;
467
		$tick = 60;
468
469
		if(!$url) {
470
			$log->write('Skipping site accessible check: no URL found.');
471
			return true;
472
		}
473
474
		$start = time();
475
		$infoTick = time() + $tick;
476
477
		$log->write(sprintf(
478
			'Waiting for "%s" to become accessible... (timeout: %smin)',
479
			$url,
480
			$timeout / 60
481
		));
482
483
		// configure curl so that curl_exec doesn't wait a long time for a response
484
		$ch = curl_init();
485
		curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
486
		curl_setopt($ch, CURLOPT_TIMEOUT, 5);
487
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
488
		curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
489
		curl_setopt($ch, CURLOPT_MAXREDIRS, 10); // set a high number of max redirects (but not infinite amount) to avoid a potential infinite loop
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 120 characters; contains 141 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
490
		curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
491
		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
492
		curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
493
		curl_setopt($ch, CURLOPT_URL, $url);
494
		curl_setopt($ch, CURLOPT_USERAGENT, 'Rainforest');
495
		$success = false;
496
497
		// query the site every second. Note that if the URL doesn't respond,
498
		// curl_exec will take 5 seconds to timeout (see CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT above)
499
		do {
500
			if(time() > $start + $timeout) {
501
				$log->write(sprintf(' * Failed: check for %s timed out after %smin', $url, $timeout / 60));
502
				return false;
503
			}
504
505
			$response = curl_exec($ch);
506
507
			// check the HTTP response code for HTTP protocols
508
			$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
509
			if($status && !in_array($status, [500, 501, 502, 503, 504])) {
510
				$success = true;
511
			}
512
513
			// check for any curl errors, mostly for checking the response state of non-HTTP protocols,
514
			// but applies to checks of any protocol
515
			if($response && !curl_errno($ch)) {
516
				$success = true;
517
			}
518
519
			// Produce an informational ticker roughly every $tick
520
			if (time() > $infoTick) {
521
				$message = [];
522
523
				// Collect status information from different sources.
524
				if ($status) {
525
					$message[] = sprintf('HTTP status code is %s', $status);
526
				}
527
				if (!$response) {
528
					$message[] = 'response is empty';
529
				}
530
				if ($error = curl_error($ch)) {
531
					$message[] = sprintf('request error: %s', $error);
532
				}
533
534
				$log->write(sprintf(
535
					' * Still waiting: %s...',
536
					implode(', ', $message)
537
				));
538
539
				$infoTick = time() + $tick;
540
			}
541
542
			sleep(1);
543
		} while(!$success);
544
545
		curl_close($ch);
546
		$log->write(' * Success: site is accessible!');
547
		return true;
548
	}
549
550
}
551