Implementing a staging/live website system with symfony and Apostrophe CMS

In this post we will show you how to implement a system where you can have a separate URL for managing, staging and testing a website that is separated from the live/production URL. This system allows you to make changes to a website and preview them before actually going live. It can be implemented with any website, we will show the necessary requirements and steps to implement with symfony and Apostrophe CMS, but you can also take parts of the tutorial and implement them without these applications. We have found this workflow suitable in a number of projects and wanted to share it.

The basic idea is to have an environment where you can make changes, break things, fix them, view them, and when everything is done and revised, “push” them to the live/production environment. The pushing may include database and files (images, css, js) changes. Apostrophe CMS includes the sfSyncContentPlugin, which we already talked about, so we use it to copy the “data” portion of the symfony project from the staging to the production installation. Note that these installations may reside on different directories of the same server or it can perfectly work with different physical servers, having the staging/QA server and a production one.

Instead of having the web server process take care of this, we prefer having a “background” process that runs in the server and does the job. For this requirement we will use Gearman, which allows you to execute commands in the backgroud triggered by a remote process.

Since the website for this project is built using Apostrophe CMS which features a very nice administration UI, we included a “Publish” button on its navigation accessible only to a subset of the CMS users which have the privilege to publish content changes. The Publish button will in effect call a symfony action which in turn triggers the Gearman process in the background, which actually performs the final action.

The whole system is comprised of different applications and components which all play together to create this powerful system, let’s list them here:

We assume that you have Apache and PHP working. Also, for the symfony and Apostrophe CMS sections, we assume you are knowledgeable about this and you have them working.

First, we need to install Gearman and Supervisord. Refer to each software for specifics on how to install these.

With Gearman, we will have a daemon in the background waiting to receive jobs. Supervisord is used to make sure our Gearman daemon keeps running at all times.

Matthew Weier O’Phinney wrote a nice introduction about using Gearman and PHP together.

As for supervisord, installing it is quite easy. Sean Coates has also written a very nice post about the topic.

Our supervisord configuration looks something like this:

[program:gearman]
command=/usr/local/sbin/gearmand -u root
numprocs=1
directory=/var/www/
stdout_logfile=/var/log/supervisord.log
autostart=true
autorestart=true
user=root
[program:gearmanworker]
command=/usr/bin/php /var/www/mysite/batch/gearmanWorker.php
numprocs=1
directory=/var/www/
stdout_logfile=/var/log/supervisord.log
autostart=true
autorestart=true
user=root

This will run a gearmand process and a PHP process with gearmanWorker.php which will receive the tasks and execute them.

We also need to have the gearman PHP extension, installation in most cases is just a matter of executing pecl install gearman and adding extension=”gearman.so” to the php.ini file.

As we said, gearmanWorker.php receives the tasks and execute and commands that are needed to “publish” our website. Here is the code for gearmanWorker.php

function publish($job) {
  $output = '';
  ob_start();
  try {
    $st = time();
    echo "Received job at " .  date("Y-m-d H:i:s", $st) . ": " . $job->handle() . "\n";
    $workload = $job->workload();
    $cmds = unserialize($workload);
    foreach ($cmds as $cmd) {
      echo "\n\n" . date('Y-m-d H:i:s') . " - Executing $cmd\n";
      passthru($cmd);
    }
    $et = time();
    echo "Finished job at " . date("Y-m-d H:i:s", $et) . ": " . $job->handle() . "\n";
    echo "Job time: ".($et-$st)." secs.\n";
    $output = ob_get_clean();
    echo $output;
  } catch (Exception $ex) {
    $output .= "\nException: " . $ex->getMessage();
  }

  mail('my@example.com', 'gearmanWorker job report', $output);
  return true;
}

echo "Starting at " . date("Y-m-d H:i:s") . "\n";

# Create our worker object.
$gmworker = new GearmanWorker();

# Add default server (localhost).
$gmworker->addServer();

# Register function "reverse" with the server. Change the worker function to
# "reverse_fn_fast" for a faster worker with no output.
$gmworker->addFunction("publish", "publish");

print "Waiting for job...\n";
while ($gmworker->work()) {
  if ($gmworker->returnCode() != GEARMAN_SUCCESS) {
    echo "return_code: " . $gmworker->returnCode() . "\n";
    break;
  }
}

The publish symfony action that will initiate the gearman tasks. Create a module/action for this and add the following action code:

public function executePublish(sfWebRequest $request)
{
    $user = $this->getUser();
    if (!$user->hasCredential('cms_admin')) {
      $this->error = 'Unauthorized access.';
      return sfView::ERROR;
    }

    $cfg = sfContext::getInstance()->getConfiguration();

    $env = $cfg->getEnvironment();
    if ($env != "staging") {
      $this->error = 'Wrong environment. Please contact support.';
      return sfView::ERROR;
    }

    $cmds = array();

    $cmds[] = "/usr/bin/php /var/www/mysite/symfony project:disable prod";

    $cmds[] = "/usr/bin/php /var/www/mysite_staging/symfony project:sync-content --sshargs=\"-q\" frontend staging to prod@prod";

    $cmds[] = "/usr/bin/php /var/www/mysite/symfony project:enable prod";

    $gmclient= new GearmanClient();

	// add the default server (localhost)
    $gmclient->addServer();

	// run reverse client in the background
    $job_handle = $gmclient->doBackground("publish", serialize($cmds));

    $output[] = "Finished!";

    $this->output = implode("", $output);
}

Make sure you add a route pointing to the action, something like this:

apps/frontend/config/routing.yml:

publish:
  url:  /publish
  param: { module: mymodule, action: publish }

In he action we check that the environment matches “staging” so we do not run the publish code in the production environment. Then we create a list of commands that will get executed by the gearmanWorker.php which runs as root, or any other user you configure it as, so it has permissions to copy data, clear the cache, etc. In the commands, we execute the cli actions provided by sfSyncContentPlugin which transfer database content and “data” files from one environment to the other.

Finally, we need to add the Publish button to Apostrophe’s Admin Menu. The manual does a great job of explaining how to do so, we need to do it the programmatically way to just show the button when the user has enough privileges, here is a summary:

apps/frontend/config/frontendConfiguration.class.php

class frontendConfiguration extends sfApplicationConfiguration
{
  public function configure()
  {
      $this->dispatcher->connect('a.getGlobalButtons',
      array('Tools', 'getGlobalButtons'));

  }
}

class Tools
{
  static public function getGlobalButtons()
  {
    $user = sfContext::getInstance()->getUser();
    if ($user->hasCredential('cms_admin'))
    {
      aTools::addGlobalButtons(array(
              new aGlobalButton('publish', 'Publish', '@publish', 'a-publish')));
    }
  }
}

This will add a Publish button to the Admin Menu only when the user has “cms_admin” credential enabled.

When clicking on it, it will call the publish action which in turn creates the list of commands to execute sfSyncContentPlugin finally executed by the gearmanWorker.php

As we said, this post shows how to do it the symfony / Apostrophe way, but if you think with an open mind, this same process can be applied to other softwares like WordPress

15

09 2010
  • http://www.facebook.com/lukas.k.smith Lukas Kahwe Smith

    Wow I like it a lot!

    Just thinking through some of the workflows that are now possible and potential troubles:
    This basically adds a publishing queue to apostrophe. But how to handle “hot fixes”? Aka you might publish every few hours, so content editors start working on their changes. But then suddenly you need to quickly fix something, I guess with this approach you wouuld essentially have to do it twice (once in prod and staging) because the “publish” button would obviously publish all changes, even the ones that are not ready. I guess hot fixes should be rare enough.

    Another potential issue is that content editors might not get done in time for the next publish or they might need to work a few publish cycles in advance because they are going to be busy or something. Here I do not really see a good solution. Some uses cases could be handled by adding a time control to accompany the simple active/inactive switch apotrophe currently has. This handles new pages nicely, but not really editing of existing pages.

    But I want to get versioning for pages in apostrophe anyways. Plus being able to have an older version be active while a new version is being worked on. Oh and I would actually want the active flag to be changes to support different states (draft, review, ready, active, deleted etc.).

  • Anonymous

    Hi Lukas!

    I knew you were going to be interested in this topic.

    Of course this solution is not good for cases where there are many editors/contributors. The CMS (in this case Apostrophe) needs to provide more control over what displays in the page. Hopefully some of what you mention gets implemented.

    Another limitation with our approach is that it does not allow for changes to come from the live site. There is no “merging” of data from prod and staging. So sites that depend on content coming from the Internet may have trouble with this. This is a tricky problem to solve and hopefully something comes into play.

    What we really wanted to highlight, and I hope it went through, is that this approach can be applied to almost any website / software, it is not limited to symfony and/or Apostrophe.

  • http://www.facebook.com/lukas.k.smith Lukas Kahwe Smith

    If I am seeing the approach in the sync plugin right, it basically dumps the SQL files and then inserts them again. So I guess there might be some issues during the time the content is updated.

    Maybe a way to minimize this is to load the data into a temp database, lock the entire db and then rename the db. That being said this would need to be done on the FS level since the command in MySQL was only available quite briefly: http://dev.mysql.com/doc/refman/5.1/en/rename-database.html

    BTW: since the plugin only syncs one database, I guess any user supplied content should just be put into a separate database and so it would be “safe”.

  • http://judicialsupport.com/ process server

    Nice to meet you.