Using Perforce Chronicle for Application Configuration

Following Paul Hammant’s post App-config workflow using SCM and subsequent proof of concept backed by Git, I will show that an app-config application backed by Perforce is possible using Perforce Chronicle.

Perforce and permissions for branches

Perforce is an enterprise-class source control management (SCM) system, remarkably similar to Subversion (Subversion was inspired by Perforce :) Perforce is more bulletproof than Subversion in many ways and it’s generally faster. Git does not impose any security constraints or permissions on branches, Perforce gives comprehensive security options allowing you to control access to different branches: for example, development, staging, and production. Subversion, however, can support permissions on branches with some extra configuration (Apache plus mod_dav_svn/mod_dav_authz). For these reasons, Perforce is a better option for storing configuration data than either Git or Subversion.

Perforce CMS as an application server

Perforce Chronicle is a content management system (CMS) using Perforce as the back-end store for configuration and content. The app-config application is built on top of Chronicle because Perforce does not offer a web view into the depot the way Subversion can through Apache. Branching and maintaining divergence between environments can be managed through the user interface, and Chronicle provides user authentication and management, so access between different configuration files can be restricted appropriately. The INSTALL.txt file that is distributed with Chronicle helps with an easy install, mine being set up to run locally from http://localhost.

There is a key issue in using Chronicle, however. The system is designed for the management of content and not necessarily arbitrary files. In order to make the app-config application work, I had to add a custom content type and write a module. Configuration and HTML are both plain-text content, so I created a “Plain Text” content type with the fields title and content:

  1. Go to “Manage” > “Content Types”
  2. Click “Add Content Type”
  3. Enter the following information:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Id:       plaintext
Label:    Plain Text
Group:    Assets
Elements:

[title]
type = text
options.label = Title
options.required = true
display.tagName = h1
display.filters.0 = HtmlSpecialChars

[content]
type = textarea
options.label = Content
options.required = true
display.tagName = pre
display.filters.0 = HtmlSpecialChars

Click “Save”.

The Config App

I’ve borrowed heavily from Paul’s app-config HTML page, which uses AngularJS to manage the UI and interaction with the server. Where Paul’s app-config app used the jshon command to encode and decode JSON, Zend Framework has a utility class for encoding, decoding, and pretty-printing JSON, and Chronicle also ships with the simplediff utility for performing diffs with PHP.

The source JSON configuration is the same, albeit sorted:

(stack_configuration.json) download
1
2
3
4
5
6
7
8
9
10
11
12
13
{
 "bannedNicks":[
  "derek",
  "dino",
  "ffff",
  "jjjj",
  "werwer"
 ],
 "defaultErrorReciever":"piglet@thoughtworks.com",
 "lighton":true,
 "loadMaxPercent":"88",
 "nextShutdownDate":"8\/9\/2012"
}

The index.html page has been modified from the original to support only the basic commit and diffs functionality:

(index.html) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
        "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html lang="en" xmlns:ng="http://angularjs.org">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
  <title>Configuration application (alpha)</title>
  <script type="text/javascript" ng:autobind src="http://code.angularjs.org/0.9.19/angular-0.9.19.min.js"></script>
  <style type="text/css">
    ins { color: #00CC00; text-decoration: none; }
    del { color: #CC0000; text-decoration: none; }
  </style>
</head>
<body ng:controller="AppCfg">
<script type="text/javascript">
    function AppCfg($resource, $xhr) {
        var self = this;
        this.newNickname = "";
        this.svrMessage;
        this.message;
        this.cfg = $resource("/appconfig/stack_configuration.json").get({});

        this.save = function() {
            self.cfg.$save({message: self.message}, function() {
                alert("Config saved to server");
            }, function() {
                alert("ERROR on save");
            });
            self.message = "";
        };

        this.newNick = function() {
            self.cfg.bannedNicks.push(self.newNickname);
            self.newNickname = "";
        };

        this.diffs = function() {
            $xhr("post", "/appconfig/diffs/stack_configuration.json", angular.toJson(self.cfg), function(code, svrMessage) {
                self.svrMessage = svrMessage;
            });
        };

        this.deleteNick = function(nick) {
            var oldBannedNicks = self.cfg.bannedNicks;
            self.cfg.bannedNicks = [];
            angular.forEach(oldBannedNicks, function(n) {
                if (nick != n) {
                    self.cfg.bannedNicks.push(n);
                }
            });
        };
    }

    AppCfg.$inject = ["$resource", "$xhr"];
</script>
  Light is on:  <input type="checkbox" name="cfg.lighton"/> <br/>
  Default Error Reciever (email): <input name="cfg.defaultErrorReciever" ng:validate="email"/> <br/>
  Max Load Percentage: <input name="cfg.loadMaxPercent" ng:validate="number:0:100"/> <br/>
  Next Shutdown Date: <input name="cfg.nextShutdownDate" ng:validate="date"/> <br/>
  Banned nicks:
      <ol>
        <li ng:repeat="nick in cfg.bannedNicks"><span>{{nick}} &nbsp;&nbsp;<a ng:click="deleteNick(nick)">[X]</a></span></li>
    </ol>
  <form ng:submit="newNick()">
    <input type="text" name="newNickname" size="20"/>
    <input type="submit" value="&lt;-- Add Nick"/><br/>
  </form>
  <hr/>
  <button ng:click="diffs()">View Diffs</button><br/>
  <button ng:disabled="{{!message}}" ng:click="save()">Commit Changes</button> Commit Message: <input name="message"></input><br/>
  Last Server operation: <br/>
  <div ng:bind="svrMessage | html:'unsafe'">
  </div>
</body>
</html>

Both of these assets were added by performing:

  1. Click “Add” from the top navbar
  2. Click “Add Content”
  3. Select “Assets” > “Plain Text”
  4. For “Title”, enter “index.html” or “stack_configuration.json”
  5. Paste in the appropriate “Content”
  6. Click “URL”, select “Custom”, and enter the same value as “Title” (otherwise, Chronicle will convert underscores to dashes, so be careful!)
  7. Click “Save”, enter a commit message, then click the next “Save”
  8. Both assets should be viewable as mangled Chronicle content entries from http://localhost/index.html and http://localhost/stack_configuration.json. You normally will not use these URLs.

At this point, neither asset is actually usable. Most content is heavily decorated with additional HTML and then displayed within a layout template, but I want both the index.html and stack_configuration.json assets to be viewable as standalone files and provide a REST interface for AngularJS to work against.

Come back PHP! All is forgiven

Chronicle is largely built using Zend Framework and makes adding extra modules to the system pretty easy. My module needs to be able to display plaintext assets, update their content using an HTTP POST, and provide diffs between the last commit and the current content.

To create the module, the following paths need to be added:

  • INSTALL/application/appconfig
  • INSTALL/application/appconfig/controllers
  • INSTALL/application/appconfig/views/scripts/index

Declare the module with INSTALL/application/appconfig/module.ini:

(module.ini) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
version = 1.0
description = Application config proof of concept
icon = images/icon.png
tags = config

[maintainer]
name = Perforce Software
email = support@perforce.com
url = http://www.perforce.com

[routes]
appconfig.type = Zend_Controller_Router_Route_Regex
appconfig.route = 'appconfig/(.+)'
appconfig.reverse = appconfig/%s
appconfig.defaults.module = appconfig
appconfig.defaults.controller = index
appconfig.defaults.action = index
appconfig.map.resource = 1

appconfig-operation.type = Zend_Controller_Router_Route_Regex
appconfig-operation.route = 'appconfig/([^/]+)/(.+)'
appconfig-operation.reverse = appconfig/%s/%s
appconfig-operation.defaults.module = appconfig
appconfig-operation.defaults.controller = index
appconfig-operation.defaults.action = index
appconfig-operation.map.action = 1
appconfig-operation.map.resource = 2

Add a view script for displaying plaintext assets, INSTALL/application/appconfig/views/scripts/index/index.phtml:

(index.phtml) download
1
<?=$this->entry->getValue('content') ?>

Add a view script for displaying diffs, INSTALL/application/appconfig/views/scripts/index/diffs.phtml:

(diffs.phtml) download
1
<pre><?=$this->diffs ?></pre>

And a controller at INSTALL/application/appconfig/controllers/IndexController.phtml:

(IndexController.php) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<?php

defined('LIBRARY_PATH') or define('LIBRARY_PATH', dirname(__DIR__));
require_once LIBRARY_PATH . '/simplediff/simplediff.php';

class Appconfig_IndexController extends Zend_Controller_Action
{
    private $entry;

    private $mimeTypes = array(
        '.html' => 'text/html',
        '.json' => 'application/json',
    );

    public function preDispatch()
    {
        $request = $this->getRequest();
        $request->setParams(Url_Model_Url::fetch($request->getParam('resource'))->getParams());
        $this->entry = P4Cms_Content::fetch($request->getParam('id'), array('includeDeleted' => true));
    }

    public function indexAction()
    {
        $this->getResponse()->setHeader('Content-Type', $this->getMimeType(), true);
        $this->view->entry = $this->entry;

        if ($this->getRequest()->isPost()) {
            $this->entry->setValue('content', $this->getJsonPost());
            $this->entry->save($this->getRequest()->getParam('message'));
        }
    }

    private function getMimeType()
    {
        $url = $this->entry->getValue('url');
        $suffix = substr($url['path'], strrpos($url['path'], '.'));

        if (array_key_exists($suffix, $this->mimeTypes)) {
            return $this->mimeTypes[$suffix];
        } else {
            return 'text/plain';
        }
    }

    public function diffsAction()
    {
        $this->getResponse()->setHeader('Content-Type', 'text/html', true);
        $this->view->diffs = htmlDiff($this->entry->getValue('content'), $this->getJsonPost());
    }

    public function postDispatch()
    {
        $this->getHelper('layout')->disableLayout();
    }

    private function getJsonPost()
    {
        if ($this->getRequest()->isPost()) {
            return $this->prettyPrint(file_get_contents('php://input'));
        } else {
            throw new Exception('Can\'t get JSON without POST');
        }
    }

    private function prettyPrint($json)
    {
        $array = Zend_Json::decode($json);
        $this->sort($array);

        return Zend_Json::prettyPrint(Zend_Json::encode($array), array('indent' => ' '));
    }

    private function sort(array &$array)
    {
        if (count(array_filter(array_keys($array), 'is_string')) > 0) {
            ksort($array);
        }

        foreach($array as &$value) {
            if (is_array($value)) {
                $this->sort($value);
            }
        }
    }
}

AngularJS

After all files are in place, Chronicle needs to be notified that the new module exists by going to “Manage” > “Modules”, where the “Appconfig” module will be listed if all goes well :) Both assets will now be viewable from http://localhost/appconfig/index.html and http://localhost/appconfig/stack_configuration.json. AngularJS’ $resource service is used in index.html to fetch stack_configuration.json and post changes back.

From http://localhost/appconfig/index.html, the data from stack_configuration.json is loaded into the form:

Edits to stack_configuration.json can be made using the form, and the diffs viewed by clicking on “View Diffs”:

The changes can be saved by entering a commit message and clicking “Commit Changes”. After which, clicking “View Diffs” will show no changes:

To show that edits have in fact been made to stack_configuration.json, go to http://localhost/stack_configuration.json, select “History” and click on “History List”:

Chronicle also provides an interface for viewing diffs between revisions:

Disk Usage

Something to remember in using Chronicle is that each resource requested from Perforce is written to disk before being served to the client. This means that for each request to index.html, Chronicle allocates a new Perforce workspace, checks out the associated file, serves it to the client, then deletes the file and the workspace at the end of the request. This allocate/checkout/serve/delete cycle executes for stack_configuration.json and every other resource in the system.

@TODO

Security!

There’s one major flaw with the appconfig module: it performs zero access checks. By default, Chronicle can be configured to disallow anonymous access by going to “Manage” > “Permissions” and deselecting all permissions for “anonymous” and “members”. Logging out and attempting to access either http://localhost/appconfig/stack_configuration.json or http://localhost/appconfig/index.html will now give an error page and prompt you to log in. Clicking “New User” will also give an error, as anonymous users don’t have the permission to create users.

Access rights on content are checked by the content module, but are also hard-coded in the associated controllers as IF-statements. A better solution will be required for proper access management in the appconfig module.

Better integration

Chronicle’s content module provides JSON integration for most of its actions, but these mostly exist to support the Dojo Toolkit-enabled front-end. Integrating with these actions over JSON requires detailed knowledge of Chronicle’s form structures.

Chronicle has some nice interfaces for viewing diffs. If I could call those up from index.html I would be major happy :)

Automatic creation of plaintext content type

Before the appconfig module is usable, the plaintext content type has to be created. I would like to automate creation of the plaintext content type when the module is first enabled.

Making applications aware of updates to configuration

When stack_configuration.json is updated, there’s no way to notify applications to the change, and no interface provided so they may poll for changes. I’m not entirely sure at this point what an appropriate solution would look like. In order to complete the concept, I’d first have to create a client app dependent on that configuration.

Better interfaces for manipulating plaintext assets

I had to fiddle with index.html quite a bit. This basically involved editing a local copy of index.html, then pasting the entire contents into the associated form in Chronicle. I have not tried checking out index.html directly from Perforce, and I imagine that any edits would need to be made within Chronicle. Github offers an in-browser raw editor, and something like that would be real handy in Chronicle.

Handling conflicts

There is no logic in the appconfig module to catch conflicts if there are two users editing the same file. Conflicts are detectible because an exception is thrown if there is a conflict, but I’m not sure what the workflow for resolution is in Chronicle terms, or how to integrate with it. Who wins?

Working with branches

I did not take the time to see how Chronicle manages branches. I will need to verify that Chronicle and the appconfig module can work with development, staging, and production branches, with maintained divergence. For example, we’re still trying to figure out how to attach visual clients like P4V to the repository and work independently of Chronicle.

Kudos

I would like to thank the guys at Perforce for their assistance and answering all my questions as I worked with Chronicle, especially Randy Defauw.