├── test
├── shared
│ ├── config
│ │ └── env
│ └── storage
│ │ └── tmp
├── .gitignore
└── deploy-cache
│ └── test-deploy-file.txt
├── .gitignore
├── bin
└── deploy
├── composer.lock
├── composer.json
├── templates
├── htaccess-auth.txt
└── htaccess.txt
├── README.md
└── atomic-deploy.php
/test/shared/config/env:
--------------------------------------------------------------------------------
1 | key=value
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | .DS_Store
3 | /.idea
--------------------------------------------------------------------------------
/test/shared/storage/tmp:
--------------------------------------------------------------------------------
1 | this is a test file
2 |
--------------------------------------------------------------------------------
/test/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 | !.gitignore
3 |
4 | !deploy-cache
5 | !shared
6 |
--------------------------------------------------------------------------------
/bin/deploy:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 |
5 | RewriteEngine On
6 |
7 | # This sets the environment variable HTTPS to "on"
8 | # if the request is behind a load balancer which terminates SSL.
9 | # In PHP, you can access this via $_SERVER['HTTPS']
10 | SetEnvIf X-Forwarded-Proto https HTTPS=on
11 |
12 | # Force redirect to HTTPS
13 | # Uncomment the rules below to enable. If hosting on Cloudways, these rules do not need to be enabled: https://support.cloudways.com/redirect-http-to-https/
14 | # RewriteCond %{HTTPS} off
15 | # RewriteCond %{HTTP:X-Forwarded-Proto} !https [NC]
16 | # RewriteCond %{HTTP_HOST} ^www.example.com [NC,OR]
17 | # RewriteCond %{HTTP_HOST} ^.+\.oneis.us [NC]
18 | # RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
19 |
20 | # Redirect non-www to www
21 | # Uncomment the rules below to enable
22 | # RewriteCond %{HTTP_HOST} ^example.com [NC]
23 | # RewriteRule ^(.*)$ https://www.example.com/$1 [L,R=301,NC]
24 |
25 | # Strip trailing slashes from the end of URLs. Redirect them to non-slash versions.
26 | # This ignores the trailing slash on the base URL (e.g. https://www.example.com/)
27 | RewriteCond %{REQUEST_URI} ^.+\/$
28 | RewriteRule ^(.+)\/$ /$1 [L,R=301,NC]
29 |
30 | # Blitz cache rewrite
31 | # https://putyourlightson.com/craft-plugins/blitz/docs#/?id=server-rewrites
32 | RewriteCond %{DOCUMENT_ROOT}/cache/blitz/%{HTTP_HOST}/%{REQUEST_URI}/%{QUERY_STRING}/index.html -s
33 | RewriteCond %{REQUEST_METHOD} GET
34 | # Required as of version 2.1.0
35 | RewriteCond %{QUERY_STRING} !token= [NC]
36 | RewriteRule .* /cache/blitz/%{HTTP_HOST}/%{REQUEST_URI}/%{QUERY_STRING}/index.html [L]
37 |
38 | # Send would-be 404 requests to Craft
39 | RewriteCond %{REQUEST_FILENAME} !-f
40 | RewriteCond %{REQUEST_FILENAME} !-d
41 | RewriteCond %{REQUEST_URI} !^/(favicon\.ico|apple-touch-icon.*\.png)$ [NC]
42 | RewriteRule (.+) index.php?p=$1 [QSA,L]
43 |
44 |
45 | # ----------------------------------------------------------------------
46 | # Security headers
47 | # ----------------------------------------------------------------------
48 |
49 |
50 | # Stop pages from loading when they detect XSS attacks
51 | Header set X-XSS-Protection "1; mode=block"
52 |
53 | # Disable the ablity to render a page on this website in a '', '
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Atomic Deployments
2 |
3 | This project is based on the concepts presented in [Buddy Atomic Deployments](https://buddy.works/blog/introducing-atomic-deployments). It provides functionality to handle shared files and directories across deployments. While this was built for use with Buddy, it will work in any standard *nix environment.
4 |
5 | ## Dependencies
6 |
7 | - PHP 5.5+
8 | - curl
9 |
10 | ## File Structure
11 |
12 | - `deploy-cache/` - the location where all files are uploaded to the server
13 | - `revisions/` - a directory containing all revisions
14 | - `current` - a symbolic link to the current revision
15 | - `shared/` - a directory containing files that should be shared across all deploys
16 |
17 | ## Usage
18 |
19 | ```bash
20 | curl -sS https://raw.githubusercontent.com/onedesign/atomic-deployments/master/atomic-deploy.php | php -- --revision=$(date "+%F-%H-%M-%S")
21 | ```
22 |
23 | ### Buddy + Craft 2 Example
24 |
25 | Add the following in the "SSH Commands" section after your file upload action in your pipeline:
26 |
27 | ```
28 | curl -sS https://raw.githubusercontent.com/onedesign/atomic-deployments/master/atomic-deploy.php | php -- --revision=${execution.to_revision.revision} --symlinks='{"shared/config/.env.php":".env.php","shared/storage":"craft/storage"}'
29 | ```
30 |
31 | ### Options
32 |
33 | - `--revision` (**required**) accepts a string ID for this revision
34 | - `--deploy-dir` accepts a base directory for deployments (default: current working directory)
35 | - `--deploy-cache-dir` accepts a target cache directory (default: `deploy-cache` within deploy-dir)
36 | - `--revisions-to-keep` number of old revisions to keep in addition to the current revision (default: `20`)
37 | - `--symlinks` a JSON hash of symbolic links to be created in the revision directory (default: `{}`)
38 | - `--help` prints help and usage instructions
39 | - `--ansi` forces ANSI color output
40 | - `--no-ansi` disables ANSI color output
41 | - `--quiet` supresses unimportant messages
42 | - `--protect` Password protect sites at .oneis.us (default: `true`)
43 |
44 | #### Symlinks
45 |
46 | Symlinks are specified as `{"target":"linkname"}` and use the native `ln` utility to create links.
47 |
48 | - `target` is relative to the `--deploy-dir` path
49 | - `linkname` is relative to the revision path
50 |
51 | For example, specifying this option:
52 |
53 | ```
54 | --symlinks='{"shared/config/.env.php":".env.php","shared/logs":"logs"}'
55 | ```
56 |
57 | will create symlinks the same way as:
58 |
59 | ```
60 | ln -s /shared/config/.env.php revisions//.env.php
61 | ln -s /shared/logs revisions//logs
62 | ```
63 |
64 | **Note:** Files and directories that exist where the symlinks are being created will be overwritten. For example, using the above example, this is actually what is happening:
65 |
66 | ```
67 | rm -rf revisions//.env.php \
68 | && ln -sfn /shared/config/.env.php revisions//.env.php
69 | rm -rf revisions//logs \
70 | && ln -sfn /shared/logs revisions//logs
71 | ```
72 |
73 | ## Password Protection
74 | By default, the deployment will password protect any site that is served from a *.oneis.us domain name. This works by prepending the contents of the `templates/htaccess-auth.txt` file to any existing `.htaccess` file found in the `current/web` directory. If an `.htaccess` file does not exist within that directory, one will be generated using the `templates/htaccess.txt` file.
75 |
76 | ## Testing
77 |
78 | ```bash
79 | cd ./test
80 | php ../bin/deploy \
81 | --deploy-cache-dir="./deploy-cache" \
82 | --revision="123456" \
83 | --symlinks='{"shared/config/env":".env","shared/storage":"storage"}'
84 | ```
85 |
--------------------------------------------------------------------------------
/atomic-deploy.php:
--------------------------------------------------------------------------------
1 | run($deployDir, $deployCacheDir, $revision, $revisionsToKeep, json_decode($symLinks))) {
40 | $deployer->postDeploy($protect);
41 | exit(0);
42 | }
43 |
44 | exit(1);
45 | }
46 |
47 | /**
48 | * displays the help
49 | */
50 | function displayHelp()
51 | {
52 | echo << $value) {
108 | $next = $key + 1;
109 | if (0 === strpos($value, $opt)) {
110 | if ($optLength === strlen($value) && isset($argv[$next])) {
111 | return trim($argv[$next]);
112 | } else {
113 | return trim(substr($value, $optLength + 1));
114 | }
115 | }
116 | }
117 |
118 | return $default;
119 | }
120 |
121 | /**
122 | * Checks that user-supplied params are valid
123 | *
124 | * @param mixed $deployDir The required deployment directory
125 | * @param mixed $deployCacheDir The required deployment cache directory
126 | * @param mixed $revision A unique ID for this revision
127 | * @param mixed $revisionsToKeep The number of revisions to keep after deploying
128 | *
129 | * @return bool True if the supplied params are okay
130 | */
131 | function checkParams($deployDir, $deployCacheDir, $revision, $revisionsToKeep, $symLinks)
132 | {
133 | $result = true;
134 |
135 | if (false !== $deployDir && !is_dir($deployDir)) {
136 | out("The defined deploy dir ({$deployDir}) does not exist.", 'info');
137 | $result = false;
138 | }
139 |
140 | if (false !== $deployCacheDir && !is_dir($deployCacheDir)) {
141 | out("The defined deploy cache dir ({$deployCacheDir}) does not exist.", 'info');
142 | $result = false;
143 | }
144 |
145 | if (false === $revision || empty($revision)) {
146 | out("A revision must be specified.", 'info');
147 | $result = false;
148 | }
149 |
150 | if (false !== $revisionsToKeep && (!is_int((integer)$revisionsToKeep) || $revisionsToKeep <= 0)) {
151 | out("Number of revisions to keep must be a number greater than zero.", 'info');
152 | $result = false;
153 | }
154 |
155 | if (false !== $symLinks && null === json_decode($symLinks)) {
156 | out("Symlinks parameter is not valid JSON.", 'info');
157 | $result = false;
158 | }
159 |
160 | return $result;
161 | }
162 |
163 | /**
164 | * colorize output
165 | */
166 | function out($text, $color = null, $newLine = true)
167 | {
168 | $styles = [
169 | 'success' => "\033[0;32m%s\033[0m",
170 | 'error' => "\033[31;31m%s\033[0m",
171 | 'info' => "\033[33;33m%s\033[0m"
172 | ];
173 |
174 | $format = '%s';
175 |
176 | if (isset($styles[$color]) && USE_ANSI) {
177 | $format = $styles[$color];
178 | }
179 |
180 | if ($newLine) {
181 | $format .= PHP_EOL;
182 | }
183 |
184 | printf($format, $text);
185 | }
186 |
187 | class Deployer
188 | {
189 |
190 | private $quiet;
191 | private $deployPath;
192 | private $revisionPath;
193 | private $errHandler;
194 |
195 | private $directories = [
196 | 'revisions' => 'revisions',
197 | 'shared' => 'shared',
198 | 'config' => 'shared/config',
199 | ];
200 |
201 | /**
202 | * Constructor - must not do anything that throws an exception
203 | *
204 | * @param bool $quiet Quiet mode
205 | */
206 | public function __construct($quiet)
207 | {
208 | if (($this->quiet = $quiet)) {
209 | ob_start();
210 | }
211 | $this->errHandler = new ErrorHandler();
212 | }
213 |
214 | /**
215 | * Run commands post deploy
216 | */
217 | public function postDeploy($protect = true)
218 | {
219 | if ($protect) {
220 | PasswordProtect::generateHtaccessFile($this->deployPath);
221 | } else {
222 | out('WARNING: Password protection disabled');
223 | }
224 | }
225 |
226 | /**
227 | * Run the script
228 | */
229 | public function run($deployDir, $deployCacheDir, $revision, $revisionsToKeep, $symLinks)
230 | {
231 | try {
232 | out('Creating atomic deployment directories...');
233 | $this->initDirectories($deployDir);
234 |
235 | out('Creating new revision directory...');
236 | $this->createRevisionDir($revision);
237 |
238 | out('Copying deploy-cache to new revision directory...');
239 | $this->copyCacheToRevision($deployCacheDir);
240 |
241 | out('Creating symlinks within new revision directory...');
242 | $this->createSymLinks($symLinks);
243 |
244 | out('Switching over to latest revision...');
245 | $this->linkCurrentRevision();
246 |
247 | out('Pruning old revisions...');
248 | $this->pruneOldRevisions($revisionsToKeep);
249 |
250 | $result = true;
251 | } catch (Exception $e) {
252 | $result = false;
253 | }
254 |
255 | // Always clean up
256 | $this->cleanUp($result);
257 |
258 | if (isset($e)) {
259 | // Rethrow anything that is not a RuntimeException
260 | if (!$e instanceof RuntimeException) {
261 | throw $e;
262 | }
263 | out($e->getMessage(), 'error');
264 | }
265 | return $result;
266 | }
267 |
268 | /**
269 | * [initDirectories description]
270 | *
271 | * @param string $deployDir Base deployment directory
272 | * @return void
273 | * @throws RuntimeException If the deploy directory is not writable or dirs can't be created
274 | */
275 | public function initDirectories($deployDir)
276 | {
277 | $this->deployPath = (is_dir($deployDir) ? rtrim($deployDir, '/') : '');
278 |
279 | if (!is_writeable($deployDir)) {
280 | throw new RuntimeException('The deploy directory "' . $deployDir . '" is not writable');
281 | }
282 |
283 | if (!is_dir($this->directories['revisions']) && !mkdir($this->directories['revisions'])) {
284 | throw new RuntimeException('Could not create revisions directory.');
285 | }
286 |
287 | if (!is_dir($this->directories['shared']) && !mkdir($this->directories['shared'])) {
288 | throw new RuntimeException('Could not create shared directory.');
289 | }
290 |
291 | if (!is_dir($this->directories['config']) && !mkdir($this->directories['config'])) {
292 | throw new RuntimeException('Could not create config directory.');
293 | }
294 | }
295 |
296 | /**
297 | * Creates a revision directory under the revisions/ directory
298 | *
299 | * @throws RuntimeException If directories can't be created
300 | */
301 | public function createRevisionDir($revision)
302 | {
303 | $this->revisionPath = $this->deployPath . DIRECTORY_SEPARATOR . $this->directories['revisions'] . DIRECTORY_SEPARATOR . $revision;
304 | $this->revisionPath = rtrim($this->revisionPath, DIRECTORY_SEPARATOR);
305 |
306 | // Check to see if this revision was already deployed
307 | if (is_dir(realpath($this->revisionPath))) {
308 | $this->revisionPath = $this->revisionPath . '-' . time();
309 | }
310 |
311 | if (!is_dir($this->revisionPath) && !mkdir($this->revisionPath)) {
312 | throw new RuntimeException('Could not create revision directory: ' . $this->revisionPath);
313 | }
314 |
315 | if (!is_writeable($this->revisionPath)) {
316 | throw new RuntimeException('The revision directory "' . $this->revisionPath . '" is not writable');
317 | }
318 | }
319 |
320 | /**
321 | * Copies the deploy-cache to the revision directory
322 | */
323 | public function copyCacheToRevision($deployCacheDir)
324 | {
325 | $this->errHandler->start();
326 |
327 | exec("cp -a $deployCacheDir/. $this->revisionPath", $output, $returnVar);
328 |
329 | if ($returnVar > 0) {
330 | throw new RuntimeException('Could not copy deploy cache to revision directory: ' . $output);
331 | }
332 |
333 | $this->errHandler->stop();
334 | }
335 |
336 | /**
337 | * Creates defined symbolic links
338 | */
339 | public function createSymLinks($symLinks)
340 | {
341 | $this->errHandler->start();
342 |
343 | foreach ($symLinks as $target => $linkName) {
344 | $t = $this->deployPath . DIRECTORY_SEPARATOR . $target;
345 | $l = $this->revisionPath . DIRECTORY_SEPARATOR . $linkName;
346 |
347 | try {
348 | $this->createSymLink($t, $l);
349 | } catch (Exception $e) {
350 | throw new RuntimeException("Could not create symlink $t -> $l: " . $e->getMessage());
351 | }
352 | }
353 |
354 | $this->errHandler->stop();
355 | }
356 |
357 | /**
358 | * Sets the deployed revision as `current`
359 | */
360 | public function linkCurrentRevision()
361 | {
362 | $this->errHandler->start();
363 |
364 | $revisionTarget = $this->revisionPath;
365 | $currentLink = $this->deployPath . DIRECTORY_SEPARATOR . 'current';
366 |
367 | try {
368 | $this->createSymLink($revisionTarget, $currentLink);
369 | } catch (Exception $e) {
370 | throw new RuntimeException("Could not create current symlink: " . $e->getMessage());
371 | }
372 |
373 | $this->errHandler->stop();
374 | }
375 |
376 | /**
377 | * Removes old revision directories
378 | */
379 | public function pruneOldRevisions($revisionsToKeep)
380 | {
381 | if ($revisionsToKeep > 0) {
382 | $revisionsDir = $this->deployPath . DIRECTORY_SEPARATOR . $this->directories['revisions'];
383 |
384 | // Never delete the most recent revision and start index after listing of the last revision we want to keep
385 | // e.g.
386 | // revision-1/
387 | // revision-2/
388 | // revision-3/ <- --revisions-to-keep=1 will remove starting with this line
389 | // revision-4/ <- --revisions-to-keep=2 will remove starting with this line
390 | $rmIndex = $revisionsToKeep + 2;
391 |
392 | // ls 1 directory by time modified | collect all dirs from ${revisionsToKeep} line of output | translate newlines and nulls | remove all those dirs
393 | exec("ls -1dtp ${revisionsDir}/** | tail -n +${rmIndex} | tr " . '\'\n\' \'\0\'' . " | xargs -0 rm -rf --",
394 | $output, $returnVar);
395 |
396 | if ($returnVar > 0) {
397 | throw new RuntimeException('Could not prune old revisions' . $output);
398 | }
399 | }
400 | }
401 |
402 | /**
403 | * Uses the system method `ln` to create a symlink
404 | */
405 | protected function createSymLink($target, $linkName)
406 | {
407 | exec("rm -rf $linkName && ln -sfn $target $linkName", $output, $returnVar);
408 |
409 | if ($returnVar > 0) {
410 | throw new RuntimeException($output);
411 | }
412 | }
413 |
414 | /**
415 | * Cleans up resources at the end of the installation
416 | *
417 | * @param bool $result If the installation succeeded
418 | */
419 | protected function cleanUp($result)
420 | {
421 | if (!$result) {
422 | // Output buffered errors
423 | if ($this->quiet) {
424 | $this->outputErrors();
425 | }
426 | // Clean up stuff we created
427 | $this->uninstall();
428 | }
429 | }
430 |
431 | /**
432 | * Outputs unique errors when in quiet mode
433 | *
434 | */
435 | protected function outputErrors()
436 | {
437 | $errors = explode(PHP_EOL, ob_get_clean());
438 | $shown = [];
439 |
440 | foreach ($errors as $error) {
441 | if ($error && !in_array($error, $shown)) {
442 | out($error, 'error');
443 | $shown[] = $error;
444 | }
445 | }
446 | }
447 |
448 | /**
449 | * Uninstalls newly-created files and directories on failure
450 | *
451 | */
452 | protected function uninstall()
453 | {
454 | if ($this->revisionPath && is_dir($this->revisionPath)) {
455 | unlink($this->revisionPath);
456 | }
457 | }
458 | }
459 |
460 | class ErrorHandler
461 | {
462 | public $message;
463 | protected $active;
464 |
465 | /**
466 | * Handle php errors
467 | *
468 | * @param mixed $code The error code
469 | * @param mixed $msg The error message
470 | */
471 | public function handleError($code, $msg)
472 | {
473 | if ($this->message) {
474 | $this->message .= PHP_EOL;
475 | }
476 | $this->message .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg);
477 | }
478 |
479 | /**
480 | * Starts error-handling if not already active
481 | *
482 | * Any message is cleared
483 | */
484 | public function start()
485 | {
486 | if (!$this->active) {
487 | set_error_handler([$this, 'handleError']);
488 | $this->active = true;
489 | }
490 | $this->message = '';
491 | }
492 |
493 | /**
494 | * Stops error-handling if active
495 | *
496 | * Any message is preserved until the next call to start()
497 | */
498 | public function stop()
499 | {
500 | if ($this->active) {
501 | restore_error_handler();
502 | $this->active = false;
503 | }
504 | }
505 | }
506 |
507 | /**
508 | * @author One Design Company
509 | * @package atomic-deployments
510 | * @since 1.1.0
511 | */
512 | class PasswordProtect
513 | {
514 | /**
515 | * Generate the .htaccess file
516 | *
517 | * @param string $deployDir Path to the deploy directory
518 | * @return bool
519 | */
520 | public static function generateHtaccessFile(string $deployDir): bool
521 | {
522 | out('Enabling password protection ...');
523 | $htpasswdPath = self::writeHtpasswdFile($deployDir);
524 | $outputPath = $deployDir . '/current/web/.htaccess';
525 |
526 | $authContents = @file_get_contents(TEMPLATE_DIR . '/htaccess-auth.txt');
527 | $authContents = str_replace('%{AUTH_FILE_PATH}', $htpasswdPath, $authContents);
528 |
529 |
530 | // If the project doesn't have an .htaccess file, create one from the template
531 | if (!file_exists($outputPath)) {
532 | // Need to make sure the `current/web` directory is here, otherwise PHP will fail to create the .htaccess file
533 | if (!is_dir($deployDir . '/current/web') && !mkdir($deployDir . '/current/web')) {
534 | throw new RuntimeException('No current/web directory exists, and could not create it.');
535 | }
536 |
537 | $htaccessFileContents = @file_get_contents(TEMPLATE_DIR . '/htaccess.txt');
538 | } else {
539 | $htaccessFileContents = @file_get_contents($outputPath);
540 | }
541 |
542 | $resource = fopen($outputPath, 'w');
543 | fwrite($resource, $authContents . PHP_EOL . $htaccessFileContents);
544 | return fclose($resource);
545 | }
546 |
547 | /**
548 | * Generate the auth string to output in the .htpasswd file
549 | *
550 | * @return string Auth string
551 | */
552 | public static function generateAuthString(): string
553 | {
554 | $username = 'onedesign';
555 | $password = 'oneisus';
556 |
557 | $encrypted_password = crypt($password, base64_encode($password));
558 | return $username . ':' . $encrypted_password;
559 | }
560 |
561 | /**
562 | * Write the .htpasswd file to the DEPLOY_DIR
563 | *
564 | * @param string $deployDir Deploy directory
565 | * @return string Path to the .htpasswd file
566 | */
567 | public static function writeHtpasswdFile(string $deployDir): string
568 | {
569 | $authString = self::generateAuthString();
570 | $htpasswdPath = $deployDir . '/.htpasswd';
571 |
572 | $htpasswdFile = fopen($htpasswdPath, 'w');
573 | fwrite($htpasswdFile, $authString);
574 | fclose($htpasswdFile);
575 |
576 | return $htpasswdPath;
577 | }
578 | }
579 |
--------------------------------------------------------------------------------