Skip to content

Instantly share code, notes, and snippets.

@mkornatz
Last active December 4, 2017 19:22
Show Gist options
  • Save mkornatz/cf64eb40de4cb7c21a1edd9a2394d455 to your computer and use it in GitHub Desktop.
Save mkornatz/cf64eb40de4cb7c21a1edd9a2394d455 to your computer and use it in GitHub Desktop.
A simple script for atomic deployments within Buddy CI.
<?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;
}
}
}
#!/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