[More Posts On This Topic]
This is post 7 in a series devoted to extending CruiseControl.NET to support multiple build configurations for a single project. In this post, I will revisit the dashboard plugin and discuss how the property changes are committed. This will be a lengthy post.
Where We’ve Been
Before I get into the meat of the post, I’d like to recap what I’ve covered so far. The idea is to create some user interface element in the dashboard from which the user can modify nant script properties and have them used by the nant script at build time. I’ve chosen to implement this as a dashboard plugin with a corresponding server side plugin. The dashboard plugin provides the user interface for editing the nant property values, and the server-side plugin provides the functionality of passing these property values to the nant script at build time.
Back to the Dashboard Plugin
The last post covered the server side plugin. It’s complete, and doesn’t need to be revisited. The remaining work all takes place within the dashboard plugin. The fifth post in this series introduced the implementation of the Dashboard plugin, but left it incomplete. I’ll correct that shortly.
Reference: Dashboard Plugin Source
Before I get into the details of the remaining implementation of the plugin, I’ll post the source. You can review it now, or come back later:
/// <summary>
/// This class implements the custom dashboard plugin that will allow the user
/// to modify the project's nant script properties via the browser.
///
/// This plugin is a project plugin, and appears as a link via the project page.
/// </summary>
[ReflectorType( "propertiesPlugin" )]
public class PropertiesPlugin : ICruiseAction , IPlugin
{
/// <summary>
/// The xpath to the params task
/// </summary>
private const string XPATH_PARAMS = "project/tasks/parameterizedNant";
/// <summary>
/// The name of the plugin
/// </summary>
private const string PLUGIN_DESCRIPTION = "NAnt Project Properties";
/// <summary>
/// The name of the action
/// </summary>
private const string ACTION_NAME = "ViewProjectProperties";
/// <summary>
/// Generates the html view used by the dashboard; injected into the constructor
/// by the runtime
/// </summary>
private readonly IVelocityViewGenerator _viewGenerator;
/// <summary>
/// Reference back to the actual ccnet server.
/// </summary>
private readonly ICruiseManagerWrapper _manager;
/// <summary>
/// Initializes the plugin. The arguments are set by the dashboard/netreflector runtime
/// </summary>
public PropertiesPlugin( IVelocityViewGenerator viewGenerator , ICruiseManagerWrapper manager )
{
_viewGenerator = viewGenerator;
_manager = manager;
} //END constructor
#region ICruiseAction Members
/// <summary>
/// Executes the request
/// </summary>
public IResponse Execute( ICruiseRequest cruiseRequest )
{
// get the request and the current project
IRequest req = cruiseRequest.Request;
// read all of the properties as they are currently defined in the project's config
List<PropertyItem> existingProperties = ReadItems( cruiseRequest.ProjectSpecifier );
// determine if we are displaying the properties, or committing the properties
if( IsRefresh( req ) )
return Commit( existingProperties , cruiseRequest );
else
return ExecuteNew( existingProperties , cruiseRequest );
} //END Execute method
#endregion
/// <summary>
/// Determines if the current page view is a normal view, or if the form has
/// been submitted as a postback(and subsequently the modified property values will
/// need to be committed back to the ccnet server).
/// </summary>
private bool IsRefresh( IRequest request )
{
string key = request.FindParameterStartingWith( "Refresh" );
return key != string.Empty;
} //END IsRefresh method
/// <summary>
/// Reads all of the properties from the project's configuration
/// </summary>
private List<PropertyItem> ReadItems( IProjectSpecifier projectSpec )
{
// read the project configuration, in xml form
string config = _manager.GetProject( projectSpec );
// load the config into an xml document
XmlDocument doc = new XmlDocument();
doc.LoadXml( config );
// find the parameterized task node. If no node is defined, then return
// an empty list of property items
XmlElement node = ( XmlElement ) doc.SelectSingleNode( XPATH_PARAMS );
if( node == null )
return new List<PropertyItem>();
// deserialize the custom server plugin from the xml node
NetReflectorTypeTable typeTable = NetReflectorTypeTable.CreateDefault();
typeTable.Add( typeof( PropertyItem ) );
typeTable.Add( typeof( ParameterizedNAntTask ) );
ParameterizedNAntTask task = ( ParameterizedNAntTask ) NetReflector.Read( node , typeTable );
return new List<PropertyItem>( task.Properties );
} //END ReadItems method
/// <summary>
/// Updates the configuration
/// </summary>
private void UpdateConfiguration( IProjectSpecifier projectSpec , List<PropertyItem> properties )
{
string config = _manager.GetProject( projectSpec );
// load the config into an xml document
XmlDocument doc = new XmlDocument();
doc.LoadXml( config );
// find the parameterized task node
XmlElement node = ( XmlElement ) doc.SelectSingleNode( XPATH_PARAMS );
if( node == null )
return;
// load the configuration
NetReflectorTypeTable typeTable = NetReflectorTypeTable.CreateDefault();
typeTable.Add( typeof( PropertyItem ) );
typeTable.Add( typeof( ParameterizedNAntTask ) );
// extract the current parameterized
ParameterizedNAntTask task = ( ParameterizedNAntTask ) NetReflector.Read( node , typeTable );
// update the tasks's properties and save the configuration
task.Properties = properties.ToArray();
// write out the xml to a string builder and load the xml into another node
StringBuilder sb = new StringBuilder();
using( XmlTextWriter writer = new XmlTextWriter( new StringWriter( sb ) ) )
{
NetReflector.Write( writer , task );
}
XmlDocument newDoc = new XmlDocument();
newDoc.LoadXml( sb.ToString() );
XmlElement clonedNode = ( XmlElement ) doc.ImportNode( newDoc.DocumentElement , true );
XmlElement parent = ( XmlElement ) node.ParentNode;
parent.ReplaceChild( clonedNode , node );
// send the new xml/config back to the server
sb = new StringBuilder();
using( XmlTextWriter writer = new XmlTextWriter( new StringWriter( sb ) ) )
{
writer.Formatting = Formatting.Indented;
doc.Save( writer );
}
string newXml = sb.ToString();
_manager.UpdateProject( projectSpec , newXml );
} //END UpdateConfiguration method
/// <summary>
/// Gets the property items that have been posted back
/// </summary>
private void GetUpdatedItems( IRequest req , List<PropertyItem> list )
{
bool keyFound = true;
int counter = 0;
// each property is defined dynamically in the html view via a text input element.
// these elements follow the names "row0", "row1", etc, based on ordinal position
// relative to the original list of items.
// loop until there are no more keys
while( keyFound )
{
string key = string.Format( "row{0}" , counter );
if( req.FindParameterStartingWith( key ) == string.Empty )
break;
string value = req.Params[ key ];
list[ counter ].Value = value;
counter++;
} //END loop
} //END GetUpdatedItems method
/// <summary>
/// Commits the modified/updated property values back to the ccnet server
/// </summary>
/// <param name="properties"></param>
/// <param name="cruiseRequest"></param>
/// <returns></returns>
private IResponse Commit( List<PropertyItem> properties , ICruiseRequest cruiseRequest )
{
IProjectSpecifier projectSpec = cruiseRequest.ProjectSpecifier;
IServerSpecifier serverSpec = cruiseRequest.ServerSpecifier;
IRequest req = cruiseRequest.Request;
Hashtable map = new Hashtable();
// get the list of updated property values and pass them back
// to the ccnet server
GetUpdatedItems( req , properties );
UpdateConfiguration( projectSpec , properties );
map.Add( "projectName" , cruiseRequest.ProjectName );
map.Add( "props" , properties );
return _viewGenerator.GenerateView( "ViewProjectProperties.vm" , map );
} //END Commit method
/// <summary>
/// Generates a new view from scratch with the property items that are presently defined.
/// </summary>
private IResponse ExecuteNew( List<PropertyItem> properties , ICruiseRequest cruiseRequest )
{
Hashtable map = new Hashtable();
map.Add( "props" , properties );
map.Add( "projectName" , cruiseRequest.ProjectName );
return _viewGenerator.GenerateView( "ViewProjectProperties.vm" , map );
} //END ExecuteNew method
#region IPlugin Members
/// <summary>
/// Returns the description for the link
/// </summary>
public string LinkDescription
{
get
{
return PLUGIN_DESCRIPTION;
}
} //END LinkDescription property
public INamedAction[] NamedActions
{
get
{
return new INamedAction[] { new ImmutableNamedAction( ACTION_NAME , this ) };
}
} //END NamedActions property
#endregion
} //END PropertiesPlugin class
Synopsis
The server side plugin wraps the built-in nant task. It adds support for explicit nant property definitions within the ccnet project configuration file. The dashboard plugin will use the server-side plugin to acquire these properties and to write the properties back to the configuration file.
Getting the NAnt Properties
Before the properties can be edited by the user, they must be enumerated from the ccnet project configuration file. This poses quite a challenge.
The problem is that the dashboard is executing in a different context from the ccnet server itself. The ccnet server is either running from the command line or as a service, and the dashboard is hosted by asp.net. There is a remoting api, but it’s pretty limited. The api doesn’t expose much functionality – it’s mostly limited to read-only access or kicking off builds. However, the api does provide limited access to a project configuration.
The ReadItems method uses this functionality to acquire the project configuration, and uses its own copy of the server-side plugin to deserialize the nant properties. It’s a bit of a hack, but it works.
Here’s the code listing for the ReadItems method:
/// <summary>
/// Reads all of the properties from the project's configuration
/// </summary>
private List<PropertyItem> ReadItems( IProjectSpecifier projectSpec )
{
// read the project configuration, in xml form
string config = _manager.GetProject( projectSpec );
// load the config into an xml document
XmlDocument doc = new XmlDocument();
doc.LoadXml( config );
// find the parameterized task node. If no node is defined, then return
// an empty list of property items
XmlElement node = ( XmlElement ) doc.SelectSingleNode( XPATH_PARAMS );
if( node == null )
return new List<PropertyItem>();
// deserialize the custom server plugin from the xml node
NetReflectorTypeTable typeTable = NetReflectorTypeTable.CreateDefault();
typeTable.Add( typeof( PropertyItem ) );
typeTable.Add( typeof( ParameterizedNAntTask ) );
ParameterizedNAntTask task = ( ParameterizedNAntTask ) NetReflector.Read( node , typeTable );
return new List<PropertyItem>( task.Properties );
} //END ReadItems method
The first line of the method acquires the project configuration. It calls the GetProject method on the ICruiseManagerWrapper interface(defined as the _manager member, set via constructor injection). The GetProject method accepts an IProjectSpecifier interface, which defines the project to retrieve. Fortunately, the IProjectSpecifier object reference is available from the ICruiseRequest the dashboard plugin is processing.
The return value of the GetProject method is a simple string. This string is the xml form of the project configuration.
This xml is loaded into an XmlDocument object. From there, a single node is selected from the XmlDocument via a SelectSingleNode call. The xpath is “project/tasks/parameterizedNant”. This gives us the actual xml node that represents the parameterizedNant server-side plugin. It’s important to understand that we are not actually interacting with the parameterizedNant object running within the ccnet process via remoting. Instead we’re going to be creating our own copy.
The next bit of code uses the NetReflector tricks used throughout the ccnet sourcecode. Ultimately all it does is deserialize the retrieved node into a new parameterizedNant object instance.
Finally the ReadItems method returns the object’s Properties value, which is List.
Which values were updated by the user?
So now we’ve retrieved the available nant property values from the project configuration. We’ve dumped them to the dashboard so the user can edit them(this work was covered in Post 5). When the user clicks the Submit button, we get a post back, and we need to determine which values actually changed.
Here’s the code listing for the GetUpdatedItems method:
/// <summary>
/// Gets the property items that have been posted back
/// </summary>
private void GetUpdatedItems( IRequest req , List<PropertyItem> list )
{
bool keyFound = true;
int counter = 0;
// each property is defined dynamically in the html view via a text input element.
// these elements follow the names "row0", "row1", etc, based on ordinal position
// relative to the original list of items.
// loop until there are no more keys
while( keyFound )
{
string key = string.Format( "row{0}" , counter );
if( req.FindParameterStartingWith( key ) == string.Empty )
break;
string value = req.Params[ key ];
list[ counter ].Value = value;
counter++;
} //END loop
} //END GetUpdatedItems method
This code looks for a series of postback values named “row1″, “row2″, etc. We know the naming of these values because that’s how they are named in the template. The value names are sequenced, so if we can’t find the next key in the sequence, we know there are no more values to process.
The sequence of these keys are based on the ordinal position of the list of PropertyItem objects. So the first object will be represented by “row0″, the second “row1″, etc. If the key exists, we replace the corresponding PropertyItem object’s value with the key’s value.
Committing the changes
When the Submit button is clicked, the plugin commits all of the property values back to the configuration file, then re-generates the view with the updated property values. This behavior is facilitated by two methods: Commit, and UpdateConfiguration. The Commit method is pretty simple, but all of the real committing work takes place in UpdateConfiguration. Here’s the code listing:
/// <summary>
/// Updates the configuration
/// </summary>
private void UpdateConfiguration( IProjectSpecifier projectSpec , List<PropertyItem> properties )
{
string config = _manager.GetProject( projectSpec );
// load the config into an xml document
XmlDocument doc = new XmlDocument();
doc.LoadXml( config );
// find the parameterized task node
XmlElement node = ( XmlElement ) doc.SelectSingleNode( XPATH_PARAMS );
if( node == null )
return;
// load the configuration
NetReflectorTypeTable typeTable = NetReflectorTypeTable.CreateDefault();
typeTable.Add( typeof( PropertyItem ) );
typeTable.Add( typeof( ParameterizedNAntTask ) );
// extract the current parameterized
ParameterizedNAntTask task = ( ParameterizedNAntTask ) NetReflector.Read( node , typeTable );
// update the tasks's properties and save the configuration
task.Properties = properties.ToArray();
// write out the xml to a string builder and load the xml into another node
StringBuilder sb = new StringBuilder();
using( XmlTextWriter writer = new XmlTextWriter( new StringWriter( sb ) ) )
{
NetReflector.Write( writer , task );
}
XmlDocument newDoc = new XmlDocument();
newDoc.LoadXml( sb.ToString() );
XmlElement clonedNode = ( XmlElement ) doc.ImportNode( newDoc.DocumentElement , true );
XmlElement parent = ( XmlElement ) node.ParentNode;
parent.ReplaceChild( clonedNode , node );
// send the new xml/config back to the server
sb = new StringBuilder();
using( XmlTextWriter writer = new XmlTextWriter( new StringWriter( sb ) ) )
{
writer.Formatting = Formatting.Indented;
doc.Save( writer );
}
string newXml = sb.ToString();
_manager.UpdateProject( projectSpec , newXml );
} //END UpdateConfiguration method
This method literally does the opposite of the ReadItems method. It takes an IProjectSpecifier object reference and a list of PropertyItem objects, and updates the project’s configuration with the property values.
Like ReadItems, UpdateConfiguration gets the latest project configuration from the _manager member. It loads the xml into an XmlDocument object and deserializes the current parameterizedNant object from the xml.
From there, it sets the parameterizedNant object’s properties to be the new list of properties collected from the dashboard plugin. It then uses NetReflector to serialize the parameterizedNant object back to xml, injects that xml into the project configuration, and saves the configuration back to the ccnet server via a call to the ICruiseManagerWrapper’s UpdateProject method.
Viola! The properties that were just collected from the user will find themselves persisted to the project configuration file. We’re done!
Problem 1: Concurrency
The dashboard is a website, thus it is entirely possible that multiple users could commit modified properties back to the ccnet server at the same time. I’ve not tested this possibility, as my ccnet environment doesn’t get much traffic from users other than myself. I don’t know if ccnet will serialize these requests or what; all I know is that the dashboard plugin does not protect against this issue. Just keep that in mind.
Problem 2: Destructive Write Operation
The process of reading and writing the xml for the project configuration is destructive. Any whitespace and/or xml comments embedded within your project configuration file will be overwritten. I’m willing to accept this, but some people may not be.
Problem 3: Changes are Permanent
When a user changes a property value, that value is permanent. At least until it is explicitly modified by hand, or until some one changes the value again via the plugin. In other words, this plugin does not allow the nant script to use these properties for one-off builds.
For example, the entire reason I developed this plugin was so I could have a single project configuration produce either Debug or Release builds. As it stands now, if I switched the configuration from Debug to Release, all builds going forward will be Release builds. Of course, I could always change it back to a Debug build, but it will not happen automatically.
Consequently, this approach is not suitable for something like defining explicit version numbers for a build.
Next?
Until this point, I’ve posted all of the code in fragments. I would like to post all of the code in self-contained downloadable form, but I don’t know if that’s possible for reasons that I will not discuss at present. I know that this series of post is not the most organized, but I hope the information has been presented coherently enough to be of use to some one out there.