April 13, 2009

'Phar' Out!

Traceability
Integration
Agile

Practical, scalable, ubiquitous; these are the words that come to mind when describing PHP. Exciting is not usually very high on the list. Imagine my surprise when a colleague introduced me to Phar archives, a rather exciting new feature that will be standard in PHP 5.3.

Conceptually, Phars are a lot like JARs. They provide a way to pack an entire PHP application into a single file. The cool thing about Phar archives is that you can run an application from within a Phar without unpacking it. Best of all, PHP 5.3 makes this possible without requiring any additional extensions.

Today's web applications are built on layers of frameworks and are often made up of thousands of files. Containing all of these files in a single archive not only simplifies distribution and deployment, it just feels 'right'. From an SCM perspective, it also presents a new opportunity for build automation. Wouldn't it be nice if a new Phar was built and checked-in every time a change was submitted? I think so too…

Now, before we get in too deep, you might want some more reference material on Phars. IBM's developerWorks has a nice article on the subject, and you can check out the Phar section of the PHP manual. Also, to play along at home you will need PHP 5.3. At the moment, the freshest version is 5.3.0RC1.

To demonstrate the concept, I have written a sample script that takes a Perforce depot path and builds a Phar archive from it. The script will also tag the Phar (using the metadata feature of Phars) with a build number. The highest change number in the source depot path is used for the build number. Finally, the script will submit the Phar back to the depot. By default the target filename is constructed from the source depot path (by appending .phar), however, you can pass any filename you want through the optional second parameter.

This script also exploits a particularly fun and little known feature of Perforce by setting the filetype of the Phar archive to binary+S10. This instructs Perforce to keep only the 10 most recent revisions of the file in the depot. In this way, you never need to worry about the script consuming excessive amounts of drive space on your server. If you aren't happy with this default, the script will take a third, optional, 'p4-filetype' parameter so that you can change the behavior. Check out the File Types section of our Command Reference for more information about the various file types and modifiers that are available. 

I suggest working this script into your continuous integration process. There are many ways that you could do this depending on your tools. One easy way to automate the script is to launch it via 'p4 triggers'. To do this, simply add a line like the one below to your triggers table. A word of warning: running the script as a trigger could affect submit performance. To avoid slowing down submit, consider forking the script off as a background process.

change-submit //depot/project/release/...
 "php /path/to/p4-phar.php //depot/project/release"

Here is the script itself. Please keep in mind that this is sample code. It should run as-is, but I encourage you to test and enhance it for your own purposes before putting it into production. Hope you enjoy it. If you have any questions or suggestions, please post them to the comments.

<?php
/ **

* P 4 - P H A R . P H P
* -----------------------
*
* This script builds a Phar archive from a given Perforce
* depot path, tags it with a build number and submits the
* resulting file back to the depot. You will need to
* designate a Perforce user and client to connect to the
* Perforce Server. You can set your connection parameters
* below.

*
* NOTE:
* You must enable phar creation by setting
* 'phar.readonly = 0' in php.ini or via php -d.
*
* USAGE:
* php p4-phar.php phar-source [phar-target] [p4-filetype]
*
* Options:
* phar-source depot path to the source files.
* phar-target optional - depot filename for the phar
* archive, defaults to '<phar-source>.phar'.
* p4-filetype optional - filetype of phar archive,
* defaults to binary+S10 (to keep only 10
* revisions).
* Example:
* $ php p4-phar.php //depot/project/release
*/
// please define your perforce connection parameters.
define('P4PORT','perforce:1666');// <- set p4 port here
define('P4USER','phar-builder');// <- set user here
define('P4CLIENT','phar-workspace');// <- set client here
define('P4PASSWD', null);// <- set password here
define('P4TICKET', null);// <- or, set ticket here
define('P4PATH','p4');// <- set the path to p4

// import the command-line arguments.
define('USAGE',"php p4-phar.php <phar-source> "
."[<phar-target>] [<p4-filetype>]");
if(isset($argv[1])){
$pharSource=$argv[1];
}else{
print("You must provide the depot path to the files you"
." wish to phar.\nFor example: 'php p4-phar.php "
." //depot/project/release'\n\n"
."Usage: ". USAGE ."\n");
exit(1);
}
if(isset($argv[2])){
$pharTarget=$argv[2];
}else{
$pharTarget=$pharSource.'.phar';
}
if(isset($argv[3])){
$p4Filetype=$argv[3];
}else{
$p4Filetype='binary+S10';
}

// connect to perforce and sync the source files.
$p4=newP4(P4PORT, P4USER, P4CLIENT, P4PASSWD, P4TICKET);
$p4->sync($pharSource.'/...','-p');

// open the phar for add or edit.

$p4->open($pharTarget,$p4Filetype);

// generate the phar from the source files.
$phar=newPhar($p4->getLocalPath($pharTarget));
$phar->buildFromDirectory($p4->getLocalPath($pharSource));
$phar->setStub($phar->createDefaultStub('index.php'));

// tag the distribution with a build number.
$change=$p4->getHeadChange($pharSource.'/...#have');
$phar->setMetadata(array('build'=>$change));

// submit the phar.
$p4->submit($pharTarget,"Auto submit of phar archive for: '"
.$pharSource."'.");

/**
* A rudimentary p4 class.
*/
class P4
{
function__construct($port,$user,$client,
$passwd= null,$ticket= null)
{
$this->p4 = P4PATH
.' -p '.escapeshellarg($port)
.' -u '.escapeshellarg($user)
.' -c '.escapeshellarg($client);
if($ticket){
$this->p4 .=' -P '.escapeshellarg($ticket);
}elseif($passwd){
$this->p4 .=' -P '.escapeshellarg($passwd);
}
}

functionrun($command)
{
return `$this->p4 $command`;
}

functionsync($fileSpec,$flags= null)
{
$result=$this->run('sync '.$flags.' '
.escapeshellarg($fileSpec));
if(!$result){
print("Failed to sync source files: "
.$fileSpec."\n");
exit(1);
}
}

functionopen($depotFile,$fileType)
{
$this->run('flush '.escapeshellarg($depotFile));
$this->run('add -t '.$fileType.' '
.escapeshellarg($depotFile));
$this->run('edit -t '.$fileType.' '
.escapeshellarg($depotFile));
}

functionsubmit($depotFile,$description)
{
$result=$this->run("submit -d "
.escapeshellarg($description)." "
.escapeshellarg($depotFile));
if(!$result){
print("Failed to submit phar archive: "
.$depotFile."\n");
exit(1);
}
}

functiongetLocalPath($depotPath)
{
$result=$this->run('where '
.escapeshellarg($depotPath));
$parts=explode(' ',$result);
if(count($parts)==3){
returntrim($parts[2]);
}else{
print("Can't determine local path for: "
.$depotPath."\n");
exit(1);
}
}

functiongetHeadChange($depotPath)
{
$result=$this->run('changes -m1 '
.escapeshellarg($depotPath));
$parts=explode(' ',$result);
if(is_numeric($parts[1])){
return$parts[1];
}else{
print("Can't get head change for: "
.$depotPath."\n");
exit(1);
}
}
}
?>