Collaboration Store, Part LIII

“But ‘almost done’ is not done!”
Israelmore Ayivor

Well, we’re almost there! Last time, we wrapped up the code to handle any possible Preview issues, so now it is time to finally see if we can issue a Commit and actually get the version of the application installed. As we did with the Preview process, we can hunt down the UI Action that handles the Commit and see if we can steal much, if not all, of the code. Here is what I found:

var commitInProgress = false;

function commitRemoteUpdateSet(control) {
	if (commitInProgress)
		return;
	
	// get remoteUpdateSetId from g_form if invoked on remote update set page
	var rusId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'validateCommitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', rusId);
	ajaxHelper.getXMLAnswer(getValidateCommitUpdateSetResponse);
}

function getValidateCommitUpdateSetResponse(answer) {
	try {
		if (answer == null) {
			console.log('validateCommitRemoteUpdateSet answer was null');
			return;
		}
		console.log('validateCommitRemoteUpdateSet answer was ' + answer);
		var returnedInfo = answer.split(';');
		
		var sysId = returnedInfo[0];
		var encodedQuery = returnedInfo[1];
		var delObjList = returnedInfo[2];

		if (delObjList !== "NONE") {
			console.log('showing data loss confirm dialog');
			showDataLossConfirmDialog(sysId, delObjList, encodedQuery);
		}
		else {
			console.log('skipping data loss confirm dialog');
			runTheCommit(sysId);
		}
	} catch (err) {

	}
}

function runTheCommit(sysId) {
	console.log('running commit on ' + sysId);
	commitInProgress = true;
	var ajaxHelper = new GlideAjax('com.glide.update.UpdateSetCommitAjaxProcessor');
	ajaxHelper.addParam('sysparm_type', 'commitRemoteUpdateSet');
	ajaxHelper.addParam('sysparm_remote_updateset_sys_id', sysId);
	ajaxHelper.getXMLAnswer(getCommitRemoteUpdateSetResponse);
}

var dataLossConfirmDialog;
function destroyDialog() {
	dataLossConfirmDialog.destroy();
}

function showDataLossConfirmDialog(sysId, delObjList, encodedQuery) {
	var dialogClass = typeof GlideModal != 'undefined' ? GlideModal : GlideDialogWindow;
    var dlg = new dialogClass('update_set_data_loss_commit_confirm');
	dataLossConfirmDialog = dlg;
    dlg.setTitle('Confirm Data Loss');
    if(delObjList == null) {
        dlg.setWidth(300);
    } else {
        dlg.setWidth(450);
    }
    
    dlg.setPreference('sysparm_sys_id', sysId);
	dlg.setPreference('sysparm_encodedQuery', encodedQuery);
    dlg.setPreference('sysparm_del_obj_list', delObjList);
	console.log('rendering data loss confirm dialog');
    dlg.render();
}


function getCommitRemoteUpdateSetResponse(answer) {
	try {
		if (answer == null)
			return;
		
		var map = new GwtMessage().getMessages(["Close", "Cancel", "Are you sure you want to cancel this update set?", "Update Set Commit", "Go to Subscription Management"]);
		
		var returnedIds = answer.split(',');
		
		var workerId = returnedIds[0];
		var sysId = returnedIds[1];
		var shouldRefreshNav = returnedIds[2];
		var shouldRefreshApps = returnedIds[3];
		var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
		var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");
		dd.setTitle(map["Update Set Commit"]);
		dd.setPreference('sysparm_renderer_execution_id', workerId);
		dd.setPreference('sysparm_renderer_expanded_levels', '0'); // collapsed root node by default
		dd.setPreference('sysparm_renderer_hide_drill_down', true);
		
		dd.setPreference('sysparm_button_subscription', map["Go to Subscription Management"]);
		dd.setPreference('sysparm_button_close', map["Close"]);
		

		dd.on("bodyrendered", function(trackerObj) {
			var buttonsPanel = $("buttonsPanel");
			var table = new Element("table", {cellpadding: 0, cellspacing: 0, width : "100%"});
			buttonsCell = table.appendChild(new Element("tr")).appendChild(new Element("td"));
			buttonsCell.align = "right";
			buttonsPanel.appendChild(table);
			
			var closeBtn = $("sysparm_button_close");
			if (closeBtn)
				closeBtn.disable();

			var cancelBtn = new Element("button");
			cancelBtn.id = "sysparm_button_cancel";
			cancelBtn.type = "button";
			cancelBtn.innerHTML = map["Cancel"];
			cancelBtn.onclick = function() {
				var response = confirm(map["Are you sure you want to cancel this update set?"]);
				if (response != true)
					return;
				
				var ajaxHelper = new GlideAjax('UpdateSetCommitAjax');
				ajaxHelper.addParam('sysparm_type', 'cancelRemoteUpdateSet');
				ajaxHelper.addParam('sysparm_worker_id', workerId);
				ajaxHelper.getXMLAnswer(getCancelRemoteUpdateSetResponse);
			};
			buttonsCell.appendChild(cancelBtn);
		});
		
		dd.on("executionComplete", function(trackerObj) {
			
			var subBtn = $("sysparm_button_subscription");
			var tableCount = Number(trackerObj.result.custom_table_count)
			
			if (tableCount > 0) {
				  if (subBtn) {
					  subBtn.enable();
					  subBtn.onclick = function() {
						  window.open(trackerObj.result.inventory_uri);
					  };
				 }
			} else {
				subBtn.hide();
			}
			
			var closeBtn = $("sysparm_button_close");
			if (closeBtn) {
				closeBtn.enable();
				closeBtn.onclick = function() {
					dd.destroy();
				};
			}
			
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.hide();
		});
		
		dd.on("beforeclose", function() {
			if (shouldRefreshNav)
				refreshNav();
			
			var top = getTopWindow();
			if (shouldRefreshApps && typeof top.g_application_picker != 'undefined')
				top.g_application_picker.fillApplications();
			
			reloadWindow(window); //reload current form after closing the progress viewer dialog
		});
		
		dd.render();
	} catch (err) {

	}
}

function getCancelRemoteUpdateSetResponse(answer) {
	if (answer == null)
		return;
	
	// Nothing really to do here.
}

Once again, I cannot claim to understand every single thing that is going on here, but that doesn’t mean that I can’t snag the code and see if I can make it work. As with the Preview logic, there is code in there to grab the sys_id of the Remote Update Set from the form. Since our process does not run on that form, that isn’t going to work, but we have already determined the sys_id when we were doing the Preview, so we can rip that line out and use the value that we have already established. Since we are going to need that in more than one function, and there are other global variables present in this script, I decided to make that a global variable as well and not pass it in to each function as an argument. I ended up with the following list of variables and then modified our onLoad function accordingly.

var dataLossConfirmDialog;
var attachmentId = '';
var updateSetId = '';
var commitInProgress = false;

function onLoad() {
	attachmentId = document.getElementById('attachment_id').value;
	updateSetId = document.getElementById('remote_update_set_id').value;
	if (updateSetId) {
		previewRemoteUpdateSet();
	}
}

I pasted in the rest of the Commit code from the UI Action down at the bottom of the Client script of our UI Page and then deleted those global variables that were embedded amongst the various functions. Then I updated our earlier commitUpdateSet function to update the status message with the results of our earlier review of the Preview results and then launch the Commit.

function commitUpdateSet(answer) {
	var result = JSON.parse(answer);
	var message = '';
	if (result.accepted > 0) {
		if (result.accepted > 1) {
			message += result.accepted + ' Flagged Updates Accepted; ';
		} else {
			message += 'One Flagged Update Accepted; ';
		}
	}
	if (result.skipped > 0) {
		if (result.skipped > 1) {
			message += result.skipped + ' Flagged Updates Skipped; ';
		} else {
			message += 'One Flagged Update Skipped; ';
		}
	}
	message += 'Committing Update Set ...';
	document.getElementById('status_text').innerHTML = message;
	commitRemoteUpdateSet();
}

The last thing to do, then, is to modify what happens after the Commit, which in our case will be the updating of the Collaboration Store records to reflect the installation of this version. Once again, we do not want to wait for the operator to hit the Close button, so we can take the same approach that we took with the Preview code and modify the dd.on(“executionComplete” function to be simply this:

dd.on("executionComplete", function(trackerObj) {
	dd.destroy();
	updateStoreData();
});

Of course, we will have to build an updateStoreData function, which should update the version and application records and then return the operator back to the version record form where all of this started, but that’s a job that we will have to take on in our next installment.

Collaboration Store, Part LII

“Long is the road from conception to completion.”
Molière

Last time, we finished up the Update Set Preview process and it looked like all that was left was to code out the Commit process and we would be done with the last major component of this long drawn-out project. Unfortunately, that’s not entirely true. Before we can move on to the Commit process, we have to deal with the fact that the Preview process may have uncovered some issues with the Update Set. In the manual process, these issues are reported to the operator, and the operator is required to deal them all before the Commit option is available. Not only do we need to address that possibility, we also have to add code to update the application and version records to reflect the version that was just installed and to link the newly installed application with the application record. So we have a little more work to do beyond just launching the Commit process before we can declare project completion.

First of all, we need to decide what to do with any Preview issues that may have been detected. Ideally, you would want to give the operator the opportunity to review these issues and make the appropriate decisions based on their knowledge of their instance and the application. However, since we are trying to make this first version as automated as possible, I have decided to have the software make arbitrary decisions about each reported problem, at least for now. In some future version, I may want to pop up a dialog and ask the operator whether they want to do their own review or trust the system to do it for them, but for now, that’s a little more sophisticated than I am ready to tackle. This may not be the best approach, but it is the simplest, and I am trying wrap up the work on this initial version.

My plan is to add yet another client-callable function to our existing ApplicationInstaller Script Include that will hunt down all of the problems and resolve them. The problem records have a field called available_actions that contains a list of all of the actions available for the problem, so I am going to use that as a guide to Accept Remote Update if I can, or Skip Remote Update if I cannot. I also want to keep track of the number of problems found, the number of updates accepted, and the number of updates skipped so that I can report that information back to the caller. In reviewing the code behind the UI Actions that accept and skip updates, I found a call to a global component called GlidePreviewProblemAction, but when I tried to access that component in my scoped Script Include, I got a security violation error. To work around that, I had to add the following new function to our global utilities, where I could make the call without error.

fixRemoteUpdateIssue: function(remUpdGR) {
	var resolution = 'accepted';
	var ppa = new GlidePreviewProblemAction(gs.action, remUpdGR);
	if (remUpdGR.available_actions.contains('43d7d01a97b00100f309124eda2975e4')) {
		ppa.ignoreProblem();
	} else {
		ppa.skipUpdate();
		resolution = 'skipped';
	}
	return resolution;
}

With that out of the way, I was able to put the rest of the code where it belonged, and just called out to the global component for the part that I was unable to do in the scoped component.

evaluatePreview: function() {
	var answer = {problems: 0, accepted: 0, skipped: 0};
	var sysId = this.getParameter('remote_update_set_id');
	if (sysId) {
		problemId = [];
		var remUpdGR = new GlideRecord('sys_update_preview_problem');
		remUpdGR.addQuery('remote_update_set', sysId);
		remUpdGR.query();
		while (remUpdGR.next()) {
			problemId.push(remUpdGR.getUniqueValue());
			answer.problems++;
		}
		var csgu = new global.CollaborationStoreGlobalUtils();
		for (var i=0; i<problemId.length; i++) {
			remUpdGR.get(problemId[i]);
			var resolution = csgu.fixRemoteUpdateIssue(remUpdGR);
			if (resolution == 'accepted') {
				answer.accepted++;
			} else {
				answer.skipped++;
			}
		}
	}
	return JSON.stringify(answer);
}

Now we just need make the GlideAjax call to that function from the client side before we attempt to launch the Commit process. Right now, when the Preview process is complete, a Close button appears on the progress dialog, and when you click on the Close button, our new UI Page reloads and starts all over again because the script that we lifted from the UI Action on the Update Set form was set up to reload that form. For our purposes, we do not want our own page reloaded, and in fact, we don’t even want a Close button; we just want to move on to the process of reviewing the results of the Preview. The relevant portion of the script that we stole looks like this:

dd.on("executionComplete", function(trackerObj) {
	var cancelBtn = $("sysparm_button_cancel");
	if (cancelBtn)
		cancelBtn.remove();
         
	var closeBtn = $("sysparm_button_close");
	if (closeBtn) {
		closeBtn.onclick = function() {
			dd.destroy();
		};
	}
});
     
dd.on("beforeclose", function() {
	reloadWindow(window);
});

Since we do not want to wait for operator action, we can short-cut this entire operation and just move on as soon as execution has been completed. I replaced all of the above with the following:

dd.on("executionComplete", function(trackerObj) {
	dd.destroy();
	checkPreviewResults();
});

Since the Preview process is now complete at this point, and we are now looking at the results, I decided to wrap the original message on the page with a span that had an id attribute so that I could change the message as things moved along. That line of HTML now looks like this:

<span id="status_text">Previewing Uploaded Update Set ...</span>

With that in place, I was able to update the message with the new status before I made the Ajax call to our new Script Include function.

function checkPreviewResults() {
	document.getElementById('status_text').innerHTML = 'Evaluating Preview Results ...';
	var ga = new GlideAjax('ApplicationInstaller');
	ga.addParam('sysparm_name', 'evaluatePreview');
	ga.addParam('remote_update_set_id', updateSetId);
	ga.getXMLAnswer(commitUpdateSet);
}

function commitUpdateSet(answer) {
	alert(answer);
}

I’m not ready to take on the Commit process just yet, so I stubbed out the commitUpdateSet function with a simple alert of the response from our Ajax call. That was enough to let me know that everything was working up to this point, which is what I needed to know before I attempted to move on.

Now that we have dealt with the possibility of Preview problems, we can finally take a look at what it will take to Commit the Update Set. That’s obviously a bit of work, so we’ll leave all of that for our next episode.

Collaboration Store, Part LI

“Plodding wins the race.”
Aesop

Last time, we ended with yet another unresolved fork in the road, whether to launch the Preview process from the upload.do page or to build yet another new page specific to the application installation process. At the time, it seemed as if there were equal merits to either option, but today I have decided that building a new page would be the preferable alternative. For one thing, that keeps the artifacts involved within the scope of our application (our global UI Script to repurpose the upload.do page had to be in the global scope), and it keeps the alterations to upload.do to the bare minimum.

Before we go off and build a new page, though, we will need to figure out how we are going to get there without the involvement of the operator (we want this whole process to be as automatic as possible). Digging through the page source of the original upload.do page, I found something that looks as if it might be relevant to our needs:

<input value="sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set" name="sysparm_referring_url" type="hidden"></input>

Now, the name of this element is sysparm_referring_url, which sounds an awful lot like it would be the URL from which we came; however, this is actually the URL where we end up after the Update Set XML file is uploaded, so I am thinking that if we replaced this value with a link to our own page, maybe we would end up there instead. Only one way to find out …

Those of you following along at home may recall that this value, which appears in the HTML source, actually disappeared somehow before the form was submitted, so I had to add this line of code to our script to put it back:

document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';

Assuming that we create a new UI Page for the remainder of the process and that we want to pass to it the attachment ID, we should be able to replace that line with something like this:

document.getElementsByName('sysparm_referring_url')[0].value = 'ui_page.do?sys_id=<sys_id of our new page>&sysparm_id=' + window.location.search.substring(15);

Now all we need to do is create the page, put something on it, and then add the code that we stole from the UI Action that launches the Update Set Preview. After we hacked up the upload.do page, the end result turned out looking like this:

Modified upload.do page

To keep things looking consistent, we can steal some of the HTML from that page and make our new page look something like this:

New page layout

To make that happen, we can snag most of the HTML from a quick view frame source and then format it and stuff it into a new UI Page called install_application:

<?xml version="1.0" encoding="utf-8" ?>
<j:jelly trim="true" xmlns:j="jelly:core" xmlns:g="glide" xmlns:g2="null">

<div>
  <nav class="navbar navbar-default" role="navigation">
    <div class="container-fluid">
      <div class="navbar-header">
        <button class="btn btn-default icon-chevron-left navbar-btn" onclick="history.back();">
          <span class="sr-only">Back</span>
        </button>
        <h1 style="display:inline-block;" class="navbar-title">Install Application</h1>
      </div>
    </div>
  </nav>
  <div class="section-content">
    <div id="output_messages" class="outputmsg_container outputmsg_hide">
      <button aria-label="Close Messages" id="close-messages-btn" class="btn btn-icon close icon-cross" onclick="GlideUI.get().clearOutputMessages(this); return false;"></button>
      <div class="outputmsg_div" aria-live="polite" role="region" data-server-messages="false"></div>
    </div>
    <div class="row">
      <div class="col-sm-12">
        <h4 style="padding: 30px;">
          &#160;
          <img src="/images/loading_anim4.gif" height="18" width="18"/>
          &#160;
          Previewing Uploaded Update Set ...
        </h4>
      </div>
    </div>
  </div>
</div>
</j:jelly>

That takes care how the page looks. Now we need to deal with how it works. To Preview an uploaded Update Set, you need the Remote Update Set‘s sys_id. We have a URL parameter that contains the sys_id of the Update Set XML file attachment, but that’s not the sys_id that we need at this point. We will have to build a process that uses the attachment sys_id to locate and return the sys_id that we will need. We can just add another function to our existing ApplicationInstaller Script Include.

getRemoteUpdateSetId: function(attachmentId) {
	var sysId = '';

	var sysAttGR = new GlideRecord('sys_attachment');
	if (sysAttGR.get(attachmentId)) {
		var versionGR = new GlideRecord(sysAttGR.getDisplayValue('table_name'));
		if (versionGR.get(sysAttGR.getDisplayValue('table_sys_id'))) {
			var updateSetGR = new GlideRecord('sys_remote_update_set');
			updateSetGR.addQuery('application_name', versionGR.getDisplayValue('member_application'));
			updateSetGR.addQuery('application_scope', versionGR.getDisplayValue('member_application.scope'));
			updateSetGR.addQuery('application_version', versionGR.getDisplayValue('version'));
			updateSetGR.addQuery('state', 'loaded');
			updateSetGR.query();
			if (updateSetGR.next()) {
				sysId = updateSetGR.getUniqueValue();
			}
		}
	}

	return sysId;
}

Basically, we use the passed attachment record sys_id to get the attachment record, then use data found on the attachment record to get the version record, and then use data found on the version record and associated application record to get the remote update set record, and then pull the sys_id that we need from there. Those of you who have been paying close attention may notice that one of the application record fields being used to find the remote update set is scope. The scope of the application was never included in the original list of data fields for the application record, so I had to go back and add it everywhere in the system where an application record was referenced, modified, or moved between instances. That was a bit of work, and hopefully I have found them all, but I think that was everything.

Anyway, now we have a way to turn an attachment record sys_id into a remote update set record sys_id, so we need to add some code to our UI Page to snag the attachment record sys_id from the URL, use it to get the sys_id that we need, and then stick that value on the page somewhere so that it can be picked up by the client-side code. At the top of the HTML for the page, I added this:

<g2:evaluate jelly="true">

var ai = new ApplicationInstaller();
var attachmentId = gs.action.getGlideURI().get('sysparm_id');
var sysId = ai.getRemoteUpdateSetId(attachmentId);

</g2:evaluate>

Then in the body of the page, just under the text, I added this hidden input element:

<input type="hidden" id="remote_update_set_id" value="$[sysId]"/>

That took care of things on the server side. Now we need to build some client-side code that will run when the page is loaded. We can do that with an addLoadEvent like so:

addLoadEvent(function() {  
	onLoad();
});

Our onLoad function can then grab the value from the hidden field and pass it on to the function that we lifted from the Preview Update Set UI Action earlier (which we need to paste into the client code section of our new UI Page).

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

That’s all there is to that. The entire Client script portion of the new UI Page, including the code that we lifted from the UI Action, now looks like this:

function onLoad() {
	var sysId = document.getElementById('remote_update_set_id').value;
	if (sysId) {
		previewRemoteUpdateSet(sysId);
	}
}

addLoadEvent(function() {  
	onLoad();
});

function previewRemoteUpdateSet(sysId) {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', sysId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0');
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);
	dd.setPreference('sysparm_button_close', map["Close"]);
    dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

        cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
        };

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

        var buttonsPanel = $("buttonsPanel");
        if (buttonsPanel)
			buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		var cancelBtn = $("sysparm_button_cancel");
		if (cancelBtn)
			cancelBtn.remove();
		
		var closeBtn = $("sysparm_button_close");
		if (closeBtn) {
			closeBtn.onclick = function() {
				dd.destroy();
			};
		}
	});
	
	dd.on("beforeclose", function() {
		reloadWindow(window);
	});
	
	dd.render();
}

Now all we need to do is pull up the old version record and push that Install button one more time, which I did.

So, there is good news and there is bad news. The good news is that it actually worked! That is to say that clicking on the Install button pulls down the Update Set XML file data, posts it back to the server via the modified upload.do page, and then goes right into previewing the newly created Update Set. That part is very cool, and something that I wasn’t sure that I was going to be able to pull off when I first started thinking about doing this. The bad news is that, once the Preview is complete, the stock code reloads the page and the whole Preview process starts all over again. That’s not good! However, that seems like a minor issue with which we should be able to deal relatively easy. All in all, then, it seems like mostly good news.

Of course, we are still not there yet. Once an Update Set has been Previewed, it sill has to be Committed before the application is actually installed. Rather than continuously reloading the page then, our version of the UI Action code is going to need to launch the Commit process. We should be able to examine the Commit UI Action as we did the Preview UI Action and steal some more code to make that happen. That sounds like a little bit of work, though, so let’s save all of that for our next installment.

Collaboration Store, Part L

“Time is what keeps everything from happening at once.”
Ray Cummings

Welcome to installment #50 of this seemingly never-ending series! That’s a milestone to which we have never even come close on this site. But then, we have never taken on a project of this magnitude before, either. Still, you would think that we would have been done with this endeavor long before now. That’s the way these things go, though. When you strike out into the darkness with just a vague idea of where you want to go, you never really know where you will end up or how long it will take. There are those who would tell you, though, that it’s all about the journey, not the destination! Still, I try to stay focused on the destination. I think we are getting close.

Last time, we wrapped up the coding on our global UI Script that allowed us to repurpose the upload.do page for installing a version of an application. We never really tested it all the way through, though, so we should probably do that before we attempt to go any further. Just to back up a bit, the way that we try this thing out is to pull up a version record for an application and click on the Install button that we added a few episodes back.

Using the Install button to test out the installation process

That should launch the upload.do page, and with the added URL parameter for the attachment sys_id, that should trigger our UI Script, which should then turn that page into this:

Altered upload.do page

Meanwhile, the script should call back to the server for the Update Set XML file information, update the form on the page using that information, and then submit the form. After the form has been submitted, the natural process related to the upload.do page takes you here:

End result of hijacking the upload.do page, a Loaded Update Set from our XML file

So, it looks like it all works, which is good. Unfortunately, the application has still not been installed. From here it is a manual process to first Preview the Update Set, and then Commit it. We don’t really want that to be a manual process, though, so let’s see what we can do to make that all happen without the operator having to click on anything or take any action to move things along. To begin, we should probably take a look how it is done manually, which should help guide us into how we might be able to do it programmatically. If you click on the Update Set in the above screen to bring up the details, you will see a form button, which is just another UI Action, called Preview Update Set.

Preview Update Set UI Action

Using the hamburger menu, we can select Configure -> UI Actions to pull up the list of UI Actions related to this form, and then select the Preview Update Set action and take a peek under the hood. It looks like all of the work is done on the client side with the following script:

function previewRemoteUpdateSet(control) {
	var MESSAGE_KEY_DIALOG_TITLE = "Update Set Preview";
	var MESSAGE_KEY_CLOSE_BUTTON = "Close";
	var MESSAGE_KEY_CANCEL_BUTTON = "Cancel";
	var MESSAGE_KEY_CONFIRMATION = "Confirmation";
	var MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE = "Are you sure you want to cancel this update set preview?";
	var map = new GwtMessage().getMessages([MESSAGE_KEY_DIALOG_TITLE, MESSAGE_KEY_CLOSE_BUTTON, MESSAGE_KEY_CANCEL_BUTTON, MESSAGE_KEY_CONFIRMATION, MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
	var sysId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;
	var dialogClass = window.GlideModal ? GlideModal : GlideDialogWindow;
	var dd = new dialogClass("hierarchical_progress_viewer", false, "40em", "10.5em");

	dd.setTitle(map[MESSAGE_KEY_DIALOG_TITLE]);
	dd.setPreference('sysparm_ajax_processor', 'UpdateSetPreviewAjax');
	dd.setPreference('sysparm_ajax_processor_function', 'preview');
	dd.setPreference('sysparm_ajax_processor_sys_id', sysId);
	dd.setPreference('sysparm_renderer_expanded_levels', '0'); // collapsed root node by default
	dd.setPreference('sysparm_renderer_hide_drill_down', true);
	dd.setPreference('focusTrap', true);

	dd.setPreference('sysparm_button_close', map["Close"]);
	// response from UpdateSetPreviewAjax.previewAgain is the progress worker id
    dd.on("executionStarted", function(response) {
		var trackerId = response.responseXML.documentElement.getAttribute("answer");

		var cancelBtn = new Element("button", {
			'id': 'sysparm_button_cancel',
			'type': 'button',
			'class': 'btn btn-default',
			'style': 'margin-left: 5px; float:right;'
		}).update(map[MESSAGE_KEY_CANCEL_BUTTON]);

        cancelBtn.onclick = function() {
			var dialog = new GlideModal('glide_modal_confirm', true, 300);
			dialog.setTitle(map[MESSAGE_KEY_CONFIRMATION]);
			dialog.setPreference('body', map[MESSAGE_KEY_CANCEL_CONFIRM_DIALOG_TILE]);
			dialog.setPreference('focusTrap', true);
			dialog.setPreference('callbackParam', trackerId);
			dialog.setPreference('defaultButton', 'ok_button');
			dialog.setPreference('onPromptComplete', function(param) {
				var cancelBtn2 = $("sysparm_button_cancel");
				if (cancelBtn2)
					cancelBtn2.disable();
				var ajaxHelper = new GlideAjax('UpdateSetPreviewAjax');
				ajaxHelper.addParam('sysparm_ajax_processor_function', 'cancelPreview');
				ajaxHelper.addParam('sysparm_ajax_processor_tracker_id', param);
				ajaxHelper.getXMLAnswer(_handleCancelPreviewResponse);
			});
			dialog.render();
			dialog.on("bodyrendered", function() {
				var okBtn = $("ok_button");
				if (okBtn) {
					okBtn.className += " btn-destructive";
				}
			});
        };

		var _handleCancelPreviewResponse = function(answer) {
			var cancelBtn = $("sysparm_button_cancel");
			if (cancelBtn)
				cancelBtn.remove();
		};

        var buttonsPanel = $("buttonsPanel");
        if (buttonsPanel)
        	buttonsPanel.appendChild(cancelBtn);
	});

	dd.on("executionComplete", function(trackerObj) {
		var cancelBtn = $("sysparm_button_cancel");
		if (cancelBtn)
			cancelBtn.remove();
		
		var closeBtn = $("sysparm_button_close");
		if (closeBtn) {
			closeBtn.onclick = function() {
				dd.destroy();
			};
		}
	});
	
	dd.on("beforeclose", function() {
		reloadWindow(window);
	});
	
	dd.render();
}

I’m not going to attempt to pretend that I understand all that is going on here. I will say, though, that it looks to me as if we could steal this entire script and launch it from a location of our own choosing without having to have the operator click on any buttons. The one line that I see that would need to be modified is the one that gets the sys_id of the Update Set.

var sysId = typeof g_form != 'undefined' && g_form != null ? g_form.getUniqueValue() : null;

I think to start with, I would just delete that line entirely and pass the sys_id in as an argument to the function. Right now, a variable called control is passed in to the function, but I don’t see where that is used anywhere, so I think that I would just change this:

function previewRemoteUpdateSet(control) {

… to this:

function previewRemoteUpdateSet(sysId) {

… and see where that might take us. Maybe that will work and maybe it won’t, but you never know until you try. Of course, not everyone is a big proponent of that Let’s pull the lever and see what happens approach; once I was told that the last words spoken on Earth will be something like “Gee, I wonder what this button does.” Still, it’s just my nature to try things and see how it all turns out. But first we have to figure out where we can put our stolen script.

I can see two ways to go here: 1) we can just add it to our hack of the upload.do page and keep everything all in one place, or 2) since the upload.do page has done it’s job at this point and we don’t want to hack up a stock component any more than is absolutely necessary, let’s create a UI Page of our own and put the rest of the process in there where we can control everything and keep it within the scope of the application. There are, as usual, pros and cons for both approaches. I don’t know if one way is any better than the other, but we don’t have to decide right this minute. Let’s save that for our next installment.

Collaboration Store, Part XLIX

“Don’t tell me the sky’s the limit when there are footprints on the moon.”
Paul Brandt

Last time, we got started on the global UI Script that will run on the upload.do page to take over the page and repurpose it for our needs. Our interest is to convert an Update Set XML file back into an actual Update Set so that we can apply the Update Set, installing a shared Scoped Application. The upload.do page will help set us on that path, but we need our script to implement just a few little modifications. We got as far as launching the GlideAjax process which will fetch the Update Set XML file details from the server side, and now we need to build the function that will process the results coming back and do something with them. The “answer” returned will be a JSON string, so we just need to turn that back into an object so that we can extract the values. We can do just that much and verify the results by popping an alert using one of the values that should be found in the resulting object, the name of the XML file.

function submitForm(answer) {
	var app = {};
	try {
		app = JSON.parse(answer);
	} catch (e) {
		alert('Error parsing JSON response from server: ' + e);
	}
	alert(app.fileName);
}

There is not much here, but we can push the old Install button on the version page, just to verify that all is well so far.

Verification of the server side code, Ajax call, and JSON parsing

Although that wasn’t much in the way of code, it did verify that the server side Script Include that we built a while back does seem to work, as well as the Ajax call that we built last time and the JSON parsing that we just added today. At this point, we have built a UI Action that sends us over to the upload.do page, taken over the page for our own purposes, hiding the original content and adding content of our own, called back to the server side for the XML file information, and demonstrated that the XML file information has indeed been transferred over to the client side. Now that we have it in hand, we have to use it to emulate a file on the local system and send that faux file back over to the server side as an element of a form post. This is where things get a little tricky.

While digging around trying to find a way to do this, I came across the DataTransfer object. This object contains a list of File objects, and you can add to the list using the add() method of the items property. These two lines of code create a new DataTransfer object and add a new file to the empty list using the data that we retrieved from the Ajax call.

var fileList = new DataTransfer();
fileList.items.add(new File([app.xml], app.fileName, {type: 'application/xml'}));

Now that we have our “file” in a file list, we can populate the files attribute of the input element using the files attribute of our DataTransfer object.

document.getElementById('attachFile').files = fileList.files;

Now we just have to submit the form and see what happens. Actually, I did that, and nothing happened. It seems that there are a couple of other form fields that also need to be valued. What seems weird to me is that, if you look at the source code for the page, those fields do start out with a value, but somewhere along the line those values were removed before the form was posted, so I had to add a couple more lines to put those values back.

document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';
document.getElementsByName('sysparm_target')[0].value = 'sys_remote_update_set';

Now we can submit the form, which is just one more line of code.

document.forms[0].submit();

All together, our new submitForm function looks like this:

function submitForm(answer) {
	var app = {};
	try {
		app = JSON.parse(answer);
	} catch (e) {
		alert('Error parsing JSON response from server: ' + e);
	}
	var fileList = new DataTransfer();
	fileList.items.add(new File([app.xml], app.fileName, {type: 'application/xml'}));
	document.getElementById('attachFile').files = fileList.files;
	document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';
	document.getElementsByName('sysparm_target')[0].value = 'sys_remote_update_set';
	document.forms[0].submit();
}

And that completes (for now) our new global UI Script. Here is the entire script, including all of the work that we did last time out.

if (window.location.pathname == '/upload.do' && window.location.search.startsWith('?attachment_id=')) {
	waitForPageLoad();
}

function waitForPageLoad() {
	if (document.getElementById('attachFile')) {
		installApplication();
	} else {
		setTimeout(waitForPageLoad, 100);
	}
}

function installApplication() {
	var originalContent = document.getElementsByClassName('section-content')[0];
	originalContent.style.visibility = 'hidden';
	var newContent = document.createElement('div');
	newContent.innerHTML = '<h4 style="padding: 30px;">&nbsp;<img src="/images/loading_anim4.gif" height="18" width="18">&nbsp;Uploading Update Set XML file ...</h4>';
	originalContent.parentNode.insertBefore(newContent, originalContent);
	var attachmentId = window.location.search.substring(15);
	var ga = new GlideAjax('x_11556_col_store.ApplicationInstaller');
	ga.addParam('sysparm_name', 'getXML');
	ga.addParam('attachment_id', attachmentId);
	ga.getXMLAnswer(submitForm);
}

function submitForm(answer) {
	var app = {};
	try {
		app = JSON.parse(answer);
	} catch (e) {
		alert('Error parsing JSON response from server: ' + e);
	}
	var fileList = new DataTransfer();
	fileList.items.add(new File([app.xml], app.fileName, {type: 'application/xml'}));
	document.getElementById('attachFile').files = fileList.files;
	document.getElementsByName('sysparm_referring_url')[0].value = 'sys_remote_update_set_list.do?sysparm_fixed_query=sys_class_name=sys_remote_update_set';
	document.getElementsByName('sysparm_target')[0].value = 'sys_remote_update_set';
	document.forms[0].submit();
}

At this point, all that we have accomplished is to load the Update Set. We still have not installed anything. The Update Set still has to be Previewed and then Committed before the version is actually installed. The ultimate goal will be for the operator to be able to just click on that Install button and have everything else takes care of itself, including marking the version record as Installed (and any other version records of the app as not installed). Whether or not we can do all of that without human intervention has yet to be determined, but we have at least accomplished that first step of turning the XML file back into an Update Set. Next time, we will see where we can go from here.

Collaboration Store, Part XLVIII

“Most times, the way isn’t clear, but you want to start anyway. It is in starting that other steps become clearer.”
Israelmore Ayivor

Last time, we created a process to retrieve the Update Set XML data from the server side and then built a UI Action to launch the installation process. At the time that we left off, I was vacillating back and forth between hacking up the original upload.do page and creating a customized copy of my own. Since that time, though, I have decided that I am much too lazy to try to build one of my own, so I am just going to attempt to hack up the one that already exists with as minimal amount of intervention as I can muster. The one way that I know how to do that is to create a global UI Script that modifies the page on the fly without actually altering the source of the page itself. We have already used this technique with our earlier incident email hack, so at least we know that this approach is one that will work.

Unfortunately, you cannot create global UI Scripts in a Scoped Application; the script has to be in the global scope, so this component will be yet another addition to our global components Update Set. I don’t really like having all of these parts outside of the application, but that’s just the way that these things go sometimes. These global scripts run on every single page load in the system, so to be a minimally intrusive as possible, the very first thing that you want to check is whether or not you are running on a page in which this code is needed. For our purposes, we only want this code to run on the upload.do page, and only if our attachment_id parameter is present in the URL.

if (window.location.pathname == '/upload.do' && window.location.search.startsWith('?attachment_id=')) {
	alert('So far, so good ...');
}

We can test this out by going into a version record and clicking on the new Install form button.

First test of the new global UI Script

OK, that works. In fact, that also proves out the code on the UI Action that we created last time. As the alert says, so far, so good. One thing that you will notice, however, is that there is nothing on the underlying screen. This code runs as soon as it is loaded, and the rest of the page has yet to be delivered. Since our plan is to tinker with that page, we really don’t want our code to be running just yet. We will need to wait to make sure that the rest of the page is there as well before we attempt to alter it. We can accomplish that with a little recursive loop that will look for an important field such as the file to be uploaded, and until that element is present, just loop back and check again. Here is a modified version of the script that will accomplish that.

if (window.location.pathname == '/upload.do' && window.location.search.startsWith('?attachment_id=')) {
	waitForPageLoad();
}

function waitForPageLoad() {
	if (document.getElementById('attachFile')) {
		installApplication();
	} else {
		setTimeout(waitForPageLoad, 100);
	}
}

function installApplication() {
	alert('So far, so good ...');
}

If that works as intended, the alert should not pop until at least the parts of the page in which we are interested have arrived.

Second test of the new global UI Script

That’s better. Now at least the stuff that we want to play with is all present in the DOM. The first thing that we will want to do is to hide the original form and then replace it with some kind of message indicating that things are happening in the background and there is nothing for the operator to do right at the moment. Here is a little code that will find the DIV that contains the major components, hides it, and replaces it with something else.

var originalContent = document.getElementsByClassName('section-content')[0];
originalContent.style.visibility = 'hidden';
var newContent = document.createElement('div');
newContent.innerHTML = '<h4 style="padding: 30px;">&nbsp;<img src="/images/loading_anim4.gif" height="18" width="18">&nbsp;Uploading Update Set XML file ...</h4>';
originalContent.parentNode.insertBefore(newContent, originalContent);

There are a couple of things to note on the above code. For one, DOM manipulation is frowned upon in the ServiceNow environment. You will get tagged for that in an Instance Scan as a bad practice, and you should really try to avoid doing things like that if at all possible. Still, sometimes you have to break the rules to get something done; there is a reason that this site is called ServiceNow Hackery and not ServiceNow By The Book. Sometimes you have to step outside of the lines in order to do what you want to do. But again, this should be a last resort and not adopted as a routine way of doing things. The other thing to note is the use of the innerHTML method. Again, the preferred way of doing things would be to create each DOM node individually, set all of the appropriate values on each node, and then link them all up to each other before inserting them into the active DOM. That’s the way that it should be done, but I was just too lazy to go through all of that and I took the easy way out instead. But that’s another thing to which folks might take exception in certain circles.

To test all of this out, we can go back to our version page and click on the new Install button one more time.

Third test of the new global UI Script

With all of that basic housekeeping out of the way, we can now focus on what we are here for. The first thing that we need to do in order to accomplish our goal is to pull down the Update Set details using GlideAjax to access the Script Include that we created last time. Before we do that, though, we need to snag the attachment record sys_id from the URL parameter. With that in hand, we can then make our Ajax call.

var attachmentId = window.location.search.substring(15);
var ga = new GlideAjax('x_11556_col_store.ApplicationInstaller');
ga.addParam('sysparm_name', 'getXML');
ga.addParam('attachment_id', attachmentId);
ga.getXMLAnswer(submitForm);

Now we just need to build a submitForm function that will parse the returned JSON string to access the file name and file contents, and then somehow use that as if it were a file on the local system so that we can submit the form. That sounds like a bit of work in and of itself, and I’m still not exactly sure how I am going to pull that off, so let’s save that exercise for our next exciting installment.

Collaboration Store, Part XLVII

“Relinquish your attachment to the known, step into the unknown, and you will step into the field of all possibilities.”
Deepak Chopra

While we wait patiently for the testing results of the instance sync process to come trickling in, we should go ahead and get started on the application installation process. As I laid out earlier, there are a couple of different ways to go here, and I really do not like either one. However, as much as I would like to have come up with a preferable third alternative, nothing has really come to mind, despite the fact that I have been dragging out the instance sync process for much longer than I should have while I searched in vain for a better solution. We have to keep pushing forward, though, so enough waiting. Let’s get into it.

I have decided to go with the client-side approach, mainly because I don’t want to get involved with the security issues related to attempting to emulate a user session on the server side. Both approaches involve quite a bit more hackery than I really care to include in something that is supposed to be a viable product to be distributed and used by others, but at this point, I don’t see any other way. The client-side approach involves downloading the Update Set XML file from the server, and then presenting it back as if it were a local file uploaded to the import XML process. So, to start with, we will need a Script Include function that will deliver the XML to the client side. Since this will be a client callable Script Include, we will want to create a new one rather than add another function to our existing utilities. We will call this new Script Include ApplicationInstaller, and check the Client callable checkbox when we create it. Our lone function will be called getXML and it will accept a single parameter for the sys_id of the attachment record, and then use that sys_id to fetch the record and return both the file name and the file contents in a JSON string response.

var ApplicationInstaller = Class.create();
ApplicationInstaller.prototype = Object.extendsObject(global.AbstractAjaxProcessor, {

	getXML: function() {
		var answer = {};
		var sysId = this.getParameter("attachment_id");
		if (sysId) {
			var sysAttGR = new GlideRecord('sys_attachment');
			if (sysAttGR.get(sysId)) {
				var gsa = new GlideSysAttachment();
				answer.fileName = sysAttGR.getDisplayValue('file_name');
				answer.xml = gsa.getContent(sysAttGR);
			}
		}
		return JSON.stringify(answer);
	},

    type: 'ApplicationInstaller'
});

There’s nothing too extraordinary about the code here. We do leverage our old friend the GlideSysAttachment to grab the contents of the file, but that’s nothing that we have not already done before. Other than that, it’s pretty basic stuff.

Now that we have a way to fetch the name and contents of the attached file, we will need a way to launch the process, which will either be a clone of the existing upload.do page or the original upload.do page with a little creative hackery to bend it to our will. I hate to mess with the original, but I also don’t want to create an entire second copy if I don’t have to. But let’s not get too far ahead of ourselves just yet. Right now, we just need to build a launcher for the process, and we can easily change the page that we launch once we decide which way to go.

To launch the installation process, we can add a UI Action to the version form, which we can simply label Install. We will want to make this action conditional, since we would not want to see this option on a version that has already been installed. At this point, we don’t need to worry about the code behind the action; we can just create it and see how it renders out on the page.

New UI Action “Install”

After saving the new action, we can pull up a version page and see how it looks.

New UI Action rendered on the version page

Now we need to add some code to make the action actually do something. Basically, we just want to jump over to whatever version of the upload.do page we end up pursuing, but before we do that we need to include a parameter. The client callable Script Include that we just created takes an attachment record sys_id as a parameter, but out action is sitting on the version record, not the attachment record. We will need to hunt down the attachment record to pull out the sys_id and then we can pass that along to our destination so that it can be used to make the GlideAjax call to our Script Include function. Here is the UI Action script to make all of that happen.

var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', current.sys_id);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
if (attachmentGR.next()) {
	action.setRedirectURL('/upload.do?attachment_id=' + attachmentGR.getUniqueValue());
} else {
	gs.addErrorMessage('No Update Set XML file found attached to this version');
}

Once again, this is pretty basic stuff that doesn’t require a whole lot of explanation. We query the attachment table for an XML file attached to the current record, and if we find it, we construct a URL and jet off to that page. If not, we throw up an error message, but that really shouldn’t happen unless something has gone horribly wrong somewhere along the line.

So now we have built enough parts to get us to the page that will actually fetch the XML from the server and then push it back up again. I need to make a decision as to whether I should hack up the original upload.do page or just use it as a guide to make one of my own. I’ll need to give that some thought, so let’s pause for now and we’ll jump into that next time out.

Collaboration Store, Part XLVI

“Fundamentally you can’t do your own QA. it’s a question of seeing you own blind spots.”
Ron Avitzur

Last time, we wrapped up all of the coding for the process that will keep all of the Client instances in sync with the Host instance. I have been dragging this process out a little bit in the hopes of coming across a much better approach to the application installation process, as I am not all that happy with the solutions that have come to mind so far. But we have finally come to the end of the instance sync build, so it’s time to release another Update Set and do a little testing. After that, I’m going to have to plow ahead on the application installation process whether a new and better idea has revealed itself or not (so far, not!). And as long as we are going to be doing a little testing, we might as well do a little regression testing as well on the initial set-up process and the application publication process, just to make sure that we haven’t mangled something up with all of this additional code. It would be nice to know that everything still works.

As with our other recent releases, you will need three items, installed in the following order, to be able to give this all a spin:

  • If you have not done so already, you will need to install the most recent version of SNH Form Fields, which you can find here.
  • Once you have SNH Form Fields installed, you can install the new Scoped Application Update Set.
  • And then once the application has been installed, you can install the Update Set for the additional global components that could not be included in the Scoped Application.

If this is a new installation, then you can test out the initial set-up process when you set up your instance. You can find information on testing the set-up process here.

If this is a new installation, or if you have not yet published any applications, you will want to go through the process of publishing a few applications from as many of your instances as possible to fully test out the instance sync process. You can test the set-up process with a single Host instance, but to fully test the application publishing process or the instance sync process, you will need a few Client instances in your community as well. You can find information on testing the application publishing process here.

Assuming that you have successfully installed all of the parts, have successfully gone through the initial set-up process for your Host and Client instances, and have published a few apps from multiple sources, you are now ready to test the instance sync process. But how would we do that, you ask. Well, there are a number of things that can be done, starting with just making sure that the process runs on the Host instance on schedule and doesn’t do anything at all on any of the Client instances. The best way to verify that is to install the latest version everywhere and then go into the Flow Designer after 24 hours and pull up the flow and check out the execution history. If everything is working as it should, the process should run every day, and on the Host instance, there should be as many iterations of the loop as there are active Client instances in the community.

You don’t necessarily have to wait for the process to run on schedule, though, if you want to some more in-depth testing. You can use the Test button on the flow to run the process as many times as you would like to test out various scenarios. Here are just a a few of the things that you can try, just to see how things work out.

  • Delete one of the Client instance records on one of the Clients and then run the sync process to see if the Client and all of its applications have been restored on that instance.
  • Delete one of the application records on one of the Clients and then run the sync process to see if the application and all of its versions have been restored on that instance.
  • Delete one of the version records on one of the Clients and then run the sync process to see if the version and its attached Update Set XML file have been restored on that instance.
  • Delete an attached Update Set XML file on one of version records on one of the Clients and then run the sync process to see if the attachment has been restored on that instance.

You can also take a look at the System Logs to see the breadcrumbs left behind by the process to see if they are all accurate and meaningful. Any suggestions or helpful hints in this area would also be quite helpful. Basically, you just want to create a situation where one or more Client instances is out of sync with the Host, and then either let the process run as scheduled or manually kick start it in the Flow Designer, and then check to make sure that everything is back in sync again.

Once again, I would like to express my appreciation to all of those who have assisted with the testing in the past, and to anyone who has not participated in the past, but would like to join in, welcome to the party! You all have my sincere gratitude for your efforts, and as always, any and all comments are welcome and appreciated. If you tried everything out and were not able to uncover any of the bugs that are surely buried in there somewhere, let me know that as well. It is always welcome news to find out that things are working as they should! Thanks to all of you for your help.

Next time, if there are no test results to review, we will finally get into the application installation process.

Collaboration Store, Part XLV

“Progress isn’t made by early risers. It’s made by lazy men trying to find easier ways to do something.”
Robert Heinlein

Last time, we completed the code to sync up the version records, so now all we have left to do is to do basically the same thing for the Update Set XML files attached to the version records. The attachments are a slightly different animal, though, so we can’t just clone what we have done before as we were able to do when using the application record process as a basis for creating the version record process. For one thing, the relationship between instances and their applications is one-to-many, just as the relationship between applications and their various versions is one-to-many. Theoretically, there should only be one attached Update Set XML file for any given version. Technically, there is nothing stopping someone from attaching additional files to a version record, and in the future we may, in fact, want to attach things to a version record like release notes or user guides, but there should only be one Update Set for each version.

One thing that I ended up doing was to go back into the syncVersions function and add in the capability to send the sys_id of the version record to the syncAttachments function. Because the sys_attachments table uses both a table_name and a table_sys_id property to link to the attachment to its record, I couldn’t really do any dot-walking to come up with a query to find the right attachment record. So I added a versionIdList in addition to the versionList to capture the sys_id of the version record along with the version number. The updated version of the syncVersions function now looks like this:

syncVersions: function(targetGR, thisApplication, thisInstance, remoteAppId) {
	var versionList = [];
	var versionIdList = [];
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application.name', thisApplication);
	versionGR.addQuery('member_application.provider.instance', thisInstance);
	versionGR.query();
	while (versionGR.next()) {
		versionList.push(versionGR.getDisplayValue('version'));
		versionIdList.push(versionGR.getUniqueValue());
	}
	if (versionList.length > 0) {
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId);
		var response = request.execute();
		if (response.haveError()) {
			gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list: ' + jsonString);
			}
			if (Array.isArray(jsonObject.result)) {
				for (var i=0; i<versionList.length; i++) {
					var thisVersion = versionList[i];
					var thisVersionId = versionIdList[i];
					var remoteVerId = '';
					for (var j=0; j<jsonObject.result.length && remoteVerId == ''; j++) {
						if (jsonObject.result[j].version == thisVersion) {
							remoteVerId = jsonObject.result[j].sys_id;
						}
					}
					if (remoteVerId == '') {
						remoteVerId = this.sendVersion(targetGR, thisVersion, thisApplication, thisInstance, remoteAppId);
					}
					this.syncAttachments(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list: ' + response.getBody());
			}
		} else {
			gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list: ' + response.getStatusCode());
		}
	} else {
		gs.info('InstanceSyncUtils.syncVersions - No versions to sync for application ' + thisApplication + ' on instance ' + thisInstance);
	}
}

With the sys_id in hand, we could now build a simple query to fetch the attachment record.

var attachmentList = [];
var attachmentGR = new GlideRecord('sys_attachment');
attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
attachmentGR.addQuery('table_sys_id', thisVersionId);
attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
attachmentGR.query();
while (attachmentGR.next()) {
	attachmentList.push(attachmentGR.getUniqueValue());
}

I’m still building up a list here, even though there should only be one XML attachment for each version record, but since I copied most of the code from the version function, I just left that in place. In practice, this should always be a list of one item. Assuming we have an attachment (which we always should!), we need to check the target instance to see if they already have it as well. For that, of course, we resort to making another REST API call.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/sys_attachment?sysparm_fields=sys_id&sysparm_query=table_name%3Dx_11556_col_store_member_application_version%5Etable_sys_id%3D' + remoteVerId + '%5Econtent_typeCONTAINSxml');

Once we build the request, we execute it and check the results.

var response = request.execute();
if (response.haveError()) {
	gs.error('InstanceSyncUtils.syncAttachments - Error returned from attempt to fetch attachment list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
	var jsonString = response.getBody();
	var jsonObject = {};
	try {
		jsonObject = JSON.parse(jsonString);
	} catch (e) {
		gs.error('InstanceSyncUtils.syncAttachments - Unparsable JSON string returned from attempt to fetch attachment list: ' + jsonString);
	}
	if (Array.isArray(jsonObject.result)) {
		if (jsonObject.result.length == 0) {
			this.sendAttachment(targetGR, attachmentList[0], remoteVerId, thisVersion, thisApplication);
		}
	} else {
		gs.error('InstanceSyncUtils.syncAttachments - Invalid response body returned from attempt to fetch attachment list: ' + response.getBody());
	}
} else {
	gs.error('InstanceSyncUtils.syncAttachments - Invalid HTTP response code returned from attempt to fetch attachment list: ' + response.getStatusCode());
}

All together, the new syncAttachments function looks like this:

syncAttachments: function(targetGR, thisVersionId, thisVersion, thisApplication, thisInstance, remoteVerId) {
	var attachmentList = [];
	var attachmentGR = new GlideRecord('sys_attachment');
	attachmentGR.addQuery('table_name', 'x_11556_col_store_member_application_version');
	attachmentGR.addQuery('table_sys_id', thisVersionId);
	attachmentGR.addQuery('content_type', 'CONTAINS', 'xml');
	attachmentGR.query();
	while (attachmentGR.next()) {
		attachmentList.push(attachmentGR.getUniqueValue());
	}
	if (attachmentList.length > 0) {
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/sys_attachment?sysparm_fields=sys_id&sysparm_query=table_name%3Dx_11556_col_store_member_application_version%5Etable_sys_id%3D' + remoteVerId + '%5Econtent_typeCONTAINSxml');
		var response = request.execute();
		if (response.haveError()) {
			gs.error('InstanceSyncUtils.syncAttachments - Error returned from attempt to fetch attachment list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				gs.error('InstanceSyncUtils.syncAttachments - Unparsable JSON string returned from attempt to fetch attachment list: ' + jsonString);
			}
			if (Array.isArray(jsonObject.result)) {
				if (jsonObject.result.length == 0) {
					this.sendAttachment(targetGR, attachmentList[0], remoteVerId, thisVersion, thisApplication);
				}
			} else {
				gs.error('InstanceSyncUtils.syncAttachments - Invalid response body returned from attempt to fetch attachment list: ' + response.getBody());
			}
		} else {
			gs.error('InstanceSyncUtils.syncAttachments - Invalid HTTP response code returned from attempt to fetch attachment list: ' + response.getStatusCode());
		}
	} else {
		gs.info('InstanceSyncUtils.syncAttachments - No attachments to sync for version ' + thisVersionId + ' of application ' + thisApplication + ' on instance ' + thisInstance);
	}
}

If there was no matching attachment on the target instance, we need to send it over, which we accomplish with a new sendAttachment function modeled after the earlier sendVersion function.

sendAttachment: function(targetGR, attachmentId, remoteVerId, thisVersion, thisApplication) {
	var attachmentGR = new GlideRecord('sys_attachment');
	if (attachmentGR.get(attachmentId)) {
		gs.info('InstanceSyncUtils.sendAttachment - Sending attachment ' + attachmentGR.getDisplayValue('file_name') + ' from version ' + thisVersion + ' of application ' + thisApplication + ' to target instance ' + targetGR.getDisplayValue('instance'));
		var result = this.CSU.pushAttachment(attachmentGR, targetGR, remoteVerId);
		if (result.error) {
			gs.error('InstanceSyncUtils.sendAttachment - Error occurred attempting to push attachment ' + attachmentGR.getDisplayValue('file_name') + ' from version ' + thisVersion + ' of application ' + thisApplication + ' to target instance ' + targetGR.getDisplayValue('instance') + ': ' + result.errorCode + '; ' + result.errorMessage);
		} else if (result.status != 200 && result.status != 201) {
			gs.error('InstanceSyncUtils.sendAttachment - Invalid HTTP response code returned from attempt to push attachment ' + attachmentGR.getDisplayValue('file_name') + ' from version ' + thisVersion + ' of application ' + thisApplication + ' to target instance ' + targetGR.getDisplayValue('instance') + ': ' + result.status);
		} else if (!result.obj) {
			gs.error('InstanceSyncUtils.sendAttachment - Invalid response body returned from attempt to push attachment ' + attachmentGR.getDisplayValue('file_name') + ' from version ' + thisVersion + ' of application ' + thisApplication + ' to target instance ' + targetGR.getDisplayValue('instance') + ': ' + result.body);
		}
	}
}

That completes all of the coding for the process of syncing all of the Client instances with the Host instance. If all is working correctly, it should ensure that all member instances have the same list of instances, the same list of applications from each contributing instance, the same list of versions for each application, and the associated Update Set XML file attachment for each version. Of course, there is still quite a bit of testing to do to make sure that all of this works as intended, so I will put together yet another Update Set and talk about how folks can help test all of this out in our next installment.

Collaboration Store, Part XLIV

“I am so clever that sometimes I don’t understand a single word of what I am saying.”
Oscar Wilde

Last time, we completed the shared functions in the CollaborationStoreUtils Script Include that will push the various record types from one instance to another. These functions will work to push data from a Client instance to the Host as well as push data from the Host to any of the Clients. Now that we have those in place, we can get back to the business of syncing up the Client instances with the Host. We have already built the code to sync up the list of instances, as well as the code to sync up the applications for each instance. Now we need to create the code to sync up the versions for each application. We will use the same approach that we used for all of the others, and much of the code will look very similar to that which we have already put into place.

Once again, we will start by gathering up all of the records associated with the active higher-level record, fetching the versions associated with the current application just as we previously gathered up all of the applications associated with the providing instance. Other than the table and query parameters, this should look quite familiar to those of you who have been following along at home.

var versionList = [];
var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
versionGR.addQuery('member_application.name', thisApplication);
versionGR.addQuery('member_application.provider.instance', thisInstance);
versionGR.query();
while (versionGR.next()) {
	versionList.push(versionGR.getDisplayValue('version'));
}

If there are version records present on the Host, then we need to use the REST API to go gather up the same list from the target Client. As usual, we start out by building our RESTMessageV2 request object.

var request  = new sn_ws.RESTMessageV2();
request.setHttpMethod('get');
request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
request.setRequestHeader("Accept", "application/json");
request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId);

Once we have the request constructed, we execute it and check the response.

var response = request.execute();
if (response.haveError()) {
	gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
} else if (response.getStatusCode() == '200') {
	var jsonString = response.getBody();
	var jsonObject = {};
	try {
		jsonObject = JSON.parse(jsonString);
	} catch (e) {
		gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list: ' + jsonString);
	}
	if (Array.isArray(jsonObject.result)) {
		...
	} else {
		gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list: ' + response.getBody());
	}
} else {
	gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list: ' + response.getStatusCode());
}

Assuming all went well, the last thing that we need to do is to loop through all of the versions present on the Host and see if they are also present on the Client. If not, then we need to send them over, which we can now do with our new shared functions in CollaborationStoreUtils. And of course, whether we had to push the version over or not, we will need to check to see if the version record has the attached Update Set XML file.

for (var i=0; i<versionList.length; i++) {
	var thisVersion = versionList[i];
	var remoteVerId = '';
	for (var j=0; j<jsonObject.result.length && remoteVerId == ''; j++) {
		if (jsonObject.result[j].version == thisVersion) {
			remoteVerId = jsonObject.result[j].sys_id;
		}
	}
	if (remoteVerId == '') {
		remoteVerId = this.sendVersion(targetGR, thisApplication, thisInstance, thisVersion, remoteAppId);
	}
	this.syncAttachments(targetGR, thisVersion, remoteVerId);
}

Putting it all together, the entire function looks like this:

syncVersions: function(targetGR, thisApplication, thisInstance, remoteAppId) {
	var versionList = [];
	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application.name', thisApplication);
	versionGR.addQuery('member_application.provider.instance', thisInstance);
	versionGR.query();
	while (versionGR.next()) {
		versionList.push(versionGR.getDisplayValue('version'));
	}
	if (versionList.length > 0) {
		var request  = new sn_ws.RESTMessageV2();
		request.setHttpMethod('get');
		request.setBasicAuth(this.CSU.WORKER_ROOT + targetGR.getDisplayValue('instance'), targetGR.getDisplayValue('token'));
		request.setRequestHeader("Accept", "application/json");
		request.setEndpoint('https://' + targetGR.getDisplayValue('instance') + '.service-now.com/api/now/table/x_11556_col_store_member_application_version?sysparm_fields=version%2Csys_id&sysparm_query=member_application%3D' + remoteAppId);
		var response = request.execute();
		if (response.haveError()) {
			gs.error('InstanceSyncUtils.syncVersions - Error returned from attempt to fetch version list: ' + response.getErrorCode() + ' - ' + response.getErrorMessage());
		} else if (response.getStatusCode() == '200') {
			var jsonString = response.getBody();
			var jsonObject = {};
			try {
				jsonObject = JSON.parse(jsonString);
			} catch (e) {
				gs.error('InstanceSyncUtils.syncVersions - Unparsable JSON string returned from attempt to fetch version list: ' + jsonString);
			}
			if (Array.isArray(jsonObject.result)) {
				for (var i=0; i<versionList.length; i++) {
					var thisVersion = versionList[i];
					var remoteVerId = '';
					for (var j=0; j<jsonObject.result.length && remoteVerId == ''; j++) {
						if (jsonObject.result[j].version == thisVersion) {
							remoteVerId = jsonObject.result[j].sys_id;
						}
					}
					if (remoteVerId == '') {
						remoteVerId = this.sendVersion(targetGR, thisApplication, thisInstance, thisVersion, remoteAppId);
					}
					this.syncAttachments(targetGR, thisVersion, remoteVerId);
				}
			} else {
				gs.error('InstanceSyncUtils.syncVersions - Invalid response body returned from attempt to fetch version list: ' + response.getBody());
			}
		} else {
			gs.error('InstanceSyncUtils.syncVersions - Invalid HTTP response code returned from attempt to fetch version list: ' + response.getStatusCode());
		}
	} else {
		gs.info('InstanceSyncUtils.syncVersions - No versions to sync for application ' + thisApplication + ' on instance ' + thisInstance);
	}
}

Now we have referenced two new functions that will need to built out as well, sendVersion and syncAttachments. The sendVersion function will be pretty much a clone of the sendApplication function that we already built, and it will call our new pushVersion function in CollaborationStoreUtils.

sendVersion: function(targetGR, thisApplication, thisInstance, thisVersion, remoteAppId) {
	var sysId = '';

	var versionGR = new GlideRecord('x_11556_col_store_member_application_version');
	versionGR.addQuery('member_application.name', thisApplication);
	versionGR.addQuery('member_application.provider.instance', thisInstance);
	versionGR.addQuery('version', thisVersion);
	versionGR.query();
	if (versionGR.next()) {
		gs.info('InstanceSyncUtils.sendApplication - Sending version ' + thisVersion + ' of application ' + versionGR.getDisplayValue('member_application') + ' to target instance ' + targetGR.getDisplayValue('instance'));
		var result = this.CSU.pushVersion(versionGR, targetGR, remoteAppId);
		if (result.error) {
			gs.error('InstanceSyncUtils.sendVersion - Error occurred attempting to push version ' + thisVersion + ' of application ' + remoteAppId + ' : ' + result.errorCode + '; ' + result.errorMessage);
		} else if (result.status != 200 && result.status != 201) {
			gs.error('InstanceSyncUtils.sendVersion - Invalid HTTP response code returned from attempt to push version ' + thisVersion + ' of application ' + remoteAppId + ' : ' + result.status);
		} else if (!result.obj) {
			gs.error('InstanceSyncUtils.sendVersion - Invalid response body returned from attempt to push version ' + thisVersion + ' of application ' + remoteAppId + ' : ' + result.body);
		} else {
			sysId = result.obj.sys_id;
		}
	}

	return sysId;
}

The other missing referenced function is syncAttachments. This one will be similar to the syncVersions function above, but because we will be working with attached files, there will be some minor differences in the tools that we will use to make all of that happen. We will get into all of that next time out.