Last active
December 4, 2017 19:22
-
-
Save mkornatz/cf64eb40de4cb7c21a1edd9a2394d455 to your computer and use it in GitHub Desktop.
A simple script for atomic deployments within Buddy CI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/* | |
*/ | |
process(is_array($argv) ? $argv : array()); | |
/** | |
* processes the installer | |
*/ | |
function process($argv) | |
{ | |
// Determine ANSI output from --ansi and --no-ansi flags | |
setUseAnsi($argv); | |
if (in_array('--help', $argv)) { | |
displayHelp(); | |
exit(0); | |
} | |
$help = in_array('--help', $argv); | |
$quiet = in_array('--quiet', $argv); | |
$deployDir = getOptValue('--deploy-dir', $argv, getcwd()); | |
$deployCacheDir = getOptValue('--deploy-cache-dir', $argv, 'deploy-cache'); | |
$release = getOptValue('--release', $argv, false); | |
$releasesToKeep = getOptValue('--releases-to-keep', $argv, 20); | |
$symLinks = getOptValue('--symlinks', $argv, '{}'); | |
if (!checkParams($deployDir, $deployCacheDir, $release, $releasesToKeep, $symLinks)) { | |
exit(1); | |
} | |
$deployer = new Deployer($quiet); | |
if ($deployer->run($deployDir, $deployCacheDir, $release, $releasesToKeep, json_decode($symLinks))) { | |
exit(0); | |
} | |
exit(1); | |
} | |
/** | |
* displays the help | |
*/ | |
function displayHelp() | |
{ | |
echo <<<EOF | |
Craft CMS Buddy Atomic Deploy | |
------------------ | |
Options | |
--help this help | |
--ansi force ANSI color output | |
--no-ansi disable ANSI color output | |
--quiet do not output unimportant messages | |
--deploy-dir="..." accepts a base directory for deployments | |
--deploy-cache-dir="..." accepts a target cache directory | |
--release a unique id for this release | |
--releases-to-keep number of old releases to keep (default 20) | |
--symlinks a JSON hash of symlinks to be created in the release (format: {"target/":"linkname"}) | |
e.g. --symlinks='{"shared/config/.env.php":".env.php","shared/storage":"craft/storage"}' | |
EOF; | |
} | |
/** | |
* Sets the USE_ANSI define for colorizing output | |
* | |
* @param array $argv Command-line arguments | |
*/ | |
function setUseAnsi($argv) | |
{ | |
// --no-ansi wins over --ansi | |
if (in_array('--no-ansi', $argv)) { | |
define('USE_ANSI', false); | |
} elseif (in_array('--ansi', $argv)) { | |
define('USE_ANSI', true); | |
} else { | |
// On Windows, default to no ANSI, except in ANSICON and ConEmu. | |
// Everywhere else, default to ANSI if stdout is a terminal. | |
define( | |
'USE_ANSI', | |
(DIRECTORY_SEPARATOR == '\\') | |
? (false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI')) | |
: (function_exists('posix_isatty') && posix_isatty(1)) | |
); | |
} | |
} | |
/** | |
* Returns the value of a command-line option | |
* | |
* @param string $opt The command-line option to check | |
* @param array $argv Command-line arguments | |
* @param mixed $default Default value to be returned | |
* | |
* @return mixed The command-line value or the default | |
*/ | |
function getOptValue($opt, $argv, $default) | |
{ | |
$optLength = strlen($opt); | |
foreach ($argv as $key => $value) { | |
$next = $key + 1; | |
if (0 === strpos($value, $opt)) { | |
if ($optLength === strlen($value) && isset($argv[$next])) { | |
return trim($argv[$next]); | |
} else { | |
return trim(substr($value, $optLength + 1)); | |
} | |
} | |
} | |
return $default; | |
} | |
/** | |
* Checks that user-supplied params are valid | |
* | |
* @param mixed $deployDir The required deployment directory | |
* @param mixed $deployCacheDir The required deployment cache directory | |
* @param mixed $release A unique ID for this release | |
* @param mixed $releasesToKeep The number of releases to keep after deploying | |
* | |
* @return bool True if the supplied params are okay | |
*/ | |
function checkParams($deployDir, $deployCacheDir, $release, $releasesToKeep, $symLinks) | |
{ | |
$result = true; | |
if (false !== $deployDir && !is_dir($deployDir)) { | |
out("The defined deploy dir ({$deployDir}) does not exist.", 'info'); | |
$result = false; | |
} | |
if (false !== $deployCacheDir && !is_dir($deployCacheDir)) { | |
out("The defined deploy cache dir ({$deployCacheDir}) does not exist.", 'info'); | |
$result = false; | |
} | |
if (false === $release || empty($release)) { | |
out("A release must be specified.", 'info'); | |
$result = false; | |
} | |
if (false !== $releasesToKeep && (!is_int((integer)$releasesToKeep) || $releasesToKeep <= 0)) { | |
out("Number of releases to keep must be a number greater than zero.", 'info'); | |
$result = false; | |
} | |
if (false !== $symLinks && null === json_decode($symLinks)) { | |
out("Symlinks parameter is not valid JSON.", 'info'); | |
$result = false; | |
} | |
return $result; | |
} | |
/** | |
* colorize output | |
*/ | |
function out($text, $color = null, $newLine = true) | |
{ | |
$styles = array( | |
'success' => "\033[0;32m%s\033[0m", | |
'error' => "\033[31;31m%s\033[0m", | |
'info' => "\033[33;33m%s\033[0m" | |
); | |
$format = '%s'; | |
if (isset($styles[$color]) && USE_ANSI) { | |
$format = $styles[$color]; | |
} | |
if ($newLine) { | |
$format .= PHP_EOL; | |
} | |
printf($format, $text); | |
} | |
class Deployer { | |
private $quiet; | |
private $deployPath; | |
private $releasePath; | |
private $errHandler; | |
private $directories = array( | |
'releases' => 'releases', | |
'shared' => 'shared', | |
'config' => 'shared/config', | |
); | |
/** | |
* Constructor - must not do anything that throws an exception | |
* | |
* @param bool $quiet Quiet mode | |
*/ | |
public function __construct($quiet) | |
{ | |
if (($this->quiet = $quiet)) { | |
ob_start(); | |
} | |
$this->errHandler = new ErrorHandler(); | |
} | |
/** | |
* | |
*/ | |
public function run($deployDir, $deployCacheDir, $release, $releasesToKeep, $symLinks) { | |
try { | |
out('Creating atomic deployment directories...'); | |
$this->initDirectories($deployDir); | |
out('Creating new release directory...'); | |
$this->createReleaseDir($release); | |
out('Copying deploy-cache to new release directory...'); | |
$this->copyCacheToRelease($deployCacheDir); | |
out('Creating symlinks within new release directory...'); | |
$this->createSymLinks($symLinks); | |
out('Switching over to latest release...'); | |
$this->linkCurrentRelease(); | |
out('Pruning old releases...'); | |
$this->pruneOldReleases($releasesToKeep); | |
$result = true; | |
} catch (Exception $e) { | |
$result = false; | |
} | |
// Always clean up | |
$this->cleanUp($result); | |
if (isset($e)) { | |
// Rethrow anything that is not a RuntimeException | |
if (!$e instanceof RuntimeException) { | |
throw $e; | |
} | |
out($e->getMessage(), 'error'); | |
} | |
return $result; | |
} | |
/** | |
* [initDirectories description] | |
* @param string $deployDir Base deployment directory | |
* @return void | |
* @throws RuntimeException If the deploy directory is not writable or dirs can't be created | |
*/ | |
public function initDirectories($deployDir) { | |
$this->deployPath = (is_dir($deployDir) ? rtrim($deployDir, '/') : ''); | |
if (!is_writeable($deployDir)) { | |
throw new RuntimeException('The deploy directory "'.$deployDir.'" is not writable'); | |
} | |
if (!is_dir($this->directories['releases']) && !mkdir($this->directories['releases'])) { | |
throw new RuntimeException('Could not create releases directory.'); | |
} | |
if (!is_dir($this->directories['shared']) && !mkdir($this->directories['shared'])) { | |
throw new RuntimeException('Could not create shared directory.'); | |
} | |
if (!is_dir($this->directories['config']) && !mkdir($this->directories['config'])) { | |
throw new RuntimeException('Could not create config directory.'); | |
} | |
} | |
/** | |
* Creates a release directory under the releases/ directory | |
* @throws RuntimeException If directories can't be created | |
*/ | |
public function createReleaseDir($release) { | |
$this->releasePath = $this->deployPath . DIRECTORY_SEPARATOR . $this->directories['releases']. DIRECTORY_SEPARATOR . $release; | |
$this->releasePath = rtrim($this->releasePath, DIRECTORY_SEPARATOR); | |
// Check to see if this release was already deployed | |
if (is_dir(realpath($this->releasePath))) { | |
$this->releasePath = $this->releasePath . '-' . time(); | |
} | |
if (!is_dir($this->releasePath) && !mkdir($this->releasePath)) { | |
throw new RuntimeException('Could not create release directory: ' . $this->releasePath); | |
} | |
if (!is_writeable($this->releasePath)) { | |
throw new RuntimeException('The release directory "'.$this->releasePath.'" is not writable'); | |
} | |
} | |
/** | |
* Copies the deploy-cache to the release directory | |
*/ | |
public function copyCacheToRelease($deployCacheDir) { | |
$this->errHandler->start(); | |
exec('cp -a -t "' . $this->releasePath . '" "' . $deployCacheDir . '/."', $output, $returnVar); | |
if ($returnVar > 0) { | |
throw new RuntimeException('Could not copy deploy cache to release directory: ' . $output); | |
} | |
$this->errHandler->stop(); | |
} | |
/** | |
* Creates defined symbolic links | |
*/ | |
public function createSymLinks($symLinks) { | |
$this->errHandler->start(); | |
foreach($symLinks as $target => $linkName) { | |
$t = $this->deployPath . DIRECTORY_SEPARATOR . $target; | |
$l = $this->releasePath . DIRECTORY_SEPARATOR . $linkName; | |
try { | |
$this->createSymLink($t, $l); | |
} catch (Exception $e) { | |
throw new RuntimeException("Could not create symlink $t -> $l: " . $e->getMessage()); | |
} | |
} | |
$this->errHandler->stop(); | |
} | |
/** | |
* Sets the deployed release as `current` | |
*/ | |
public function linkCurrentRelease() { | |
$this->errHandler->start(); | |
$releaseTarget = $this->releasePath; | |
$currentLink = $this->deployPath . DIRECTORY_SEPARATOR . 'current'; | |
try { | |
$this->createSymLink($releaseTarget, $currentLink); | |
} catch (Exception $e) { | |
throw new RuntimeException("Could not create current symlink: " . $e->getMessage()); | |
} | |
$this->errHandler->stop(); | |
} | |
/** | |
* Removes old release directories | |
*/ | |
public function pruneOldReleases($releasesToKeep) { | |
if ($releasesToKeep > 0) { | |
$releasesDir = $this->deployPath . DIRECTORY_SEPARATOR . $this->directories['releases']; | |
exec("ls -tp $releasesDir/ | grep '/$' | tail -n +$releasesToKeep | tr " . '\'\n\' \'\0\'' ." | xargs -0 rm -rf --", | |
$output, $returnVar); | |
if ($returnVar > 0) { | |
throw new RuntimeException('Could not prune old releases' . $output); | |
} | |
} | |
} | |
/** | |
* Uses the system method `ln` to create a symlink | |
*/ | |
protected function createSymLink($target, $linkName) { | |
exec("rm -rf $linkName && ln -sfn $target $linkName", $output, $returnVar); | |
if ($returnVar > 0) { | |
throw new RuntimeException($output); | |
} | |
} | |
/** | |
* Cleans up resources at the end of the installation | |
* | |
* @param bool $result If the installation succeeded | |
*/ | |
protected function cleanUp($result) | |
{ | |
if (!$result) { | |
// Output buffered errors | |
if ($this->quiet) { | |
$this->outputErrors(); | |
} | |
// Clean up stuff we created | |
$this->uninstall(); | |
} | |
} | |
/** | |
* Outputs unique errors when in quiet mode | |
* | |
*/ | |
protected function outputErrors() | |
{ | |
$errors = explode(PHP_EOL, ob_get_clean()); | |
$shown = array(); | |
foreach ($errors as $error) { | |
if ($error && !in_array($error, $shown)) { | |
out($error, 'error'); | |
$shown[] = $error; | |
} | |
} | |
} | |
/** | |
* Uninstalls newly-created files and directories on failure | |
* | |
*/ | |
protected function uninstall() | |
{ | |
} | |
} | |
class ErrorHandler | |
{ | |
public $message; | |
protected $active; | |
/** | |
* Handle php errors | |
* | |
* @param mixed $code The error code | |
* @param mixed $msg The error message | |
*/ | |
public function handleError($code, $msg) | |
{ | |
if ($this->message) { | |
$this->message .= PHP_EOL; | |
} | |
$this->message .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg); | |
} | |
/** | |
* Starts error-handling if not already active | |
* | |
* Any message is cleared | |
*/ | |
public function start() | |
{ | |
if (!$this->active) { | |
set_error_handler(array($this, 'handleError')); | |
$this->active = true; | |
} | |
$this->message = ''; | |
} | |
/** | |
* Stops error-handling if active | |
* | |
* Any message is preserved until the next call to start() | |
*/ | |
public function stop() | |
{ | |
if ($this->active) { | |
restore_error_handler(); | |
$this->active = false; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash | |
# | |
# This script is intended for deploying Craft CMS sites within Buddy. | |
# | |
# Usage: | |
# curl -sS https://gist.githubusercontent.com/mkornatz/cf64eb40de4cb7c21a1edd9a2394d455/raw/2a1554b68dd5c391d6e9d5a231cf3bd52f7616d1/buddy-craft-atomic-deploy.sh | sh | |
# | |
# Options (Environment Variables): | |
# ATOMIC_RELEASES_TO_KEEP (default 10) | |
# | |
function log { | |
echo "$1"; | |
} | |
basepath=$(pwd) | |
log "Using ${basepath} as base directory." | |
# Define all directories we're working with in an atomic deploy | |
releases="${basepath}/releases" | |
deployCache="${basepath}/deploy-cache" | |
shared="${basepath}/shared" | |
config="${shared}/config" | |
storage="${shared}/storage" | |
# These will be created as symlinks within the release | |
# ln -sfn ${key} ${value} | |
# ${key} must be an absolute path | |
# ${value} must be relative to the site root | |
symlinks["${config}/.env.php"]=".env.php" | |
symlinks["${storage}"]="craft/storage" | |
# Check for Buddy Environment variables | |
if [ -z "${execution:?}" ]; then | |
log "Buddy $\{execution\} environment variable not found. Are you running this script in a Buddy environment?" | |
exit | |
fi | |
# How many releases to keep around (the rest are pruned at the end) | |
releasesToKeep=10 | |
if [ ! -z "${ATOMIC_RELEASES_TO_KEEP}" ]; then | |
releasesToKeep=${ATOMIC_RELEASES_TO_KEEP} | |
fi | |
# TODO: check if deploy cache directory is empty | |
# Delete previous revision directory if re-running task | |
# TODO: re-running will cause breakages between rm and cp | |
# * Rename the release dir and updated current link | |
# * Remove the renamed dir after copy | |
if [ -d "${releases}/${execution.to_revision.revision}" ] && [ "${execution.refresh}" = "true" ]; then | |
log "Release revision already deployed. Re-running deployment action." | |
log "Removing previous release directory: ${releases}/${execution.to_revision.revision}" | |
rm -rf "${releases:?}/${execution.to_revision.revision}" | |
fi | |
# Copy all deploy cache into release directory | |
if [ ! -d "${releases}/${execution.to_revision.revision}" ]; then | |
log "Creating: ${releases}/${execution.to_revision.revision}" | |
cp -a "${deployCache}" "${releases}/${execution.to_revision.revision}" | |
fi | |
# Link all defined symlinks | |
echo "Creating symlinks..." | |
for i in "${!symlinks[@]}"; do | |
echo "Linking ${symlinks[$i]} to $i" | |
ln -sfn "${i}" "${releases}/${execution.to_revision.revision}/${symlinks[$i]}" | |
done | |
# Remove default Craft storage directory and link to shared storage | |
#log "Linking to shared storage directory (logs, cache, etc)..." | |
#rm -f "${releases:?}/${execution.to_revision.revision}/craft/storage" | |
#ln -sfn "${storage}" "${releases}/${execution.to_revision.revision}/craft/storage" | |
# Link shared config | |
#log "Linking shared configuration files..." | |
#ln -sfn "${config}/.env.php" "${releases}/${execution.to_revision.revision}/.env.php" | |
# Update the current link to point to the latest revision | |
log "Linking current to revision: ${execution.to_revision.revision}" | |
ln -sfn "${releases}/${execution.to_revision.revision}" current | |
# Clears the template cache for the site | |
log "Clearing Craft template cache..." | |
php current/craft/app/etc/console/yiic postdeploy clearTemplateCache | |
# Prunes old releases, but keeps the last 10 to be modified | |
log "Pruning old releases..." | |
ls -tp "${releases}/" | grep '/$' | tail -n "+${releasesToKeep}" | tr '\n' '\0' | xargs -0 rm -rf -- | |
log "All done." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment