Skip to content

Commit f97c6de

Browse files
committed
Add cascading remote option values issue #80
1 parent 84a211d commit f97c6de

File tree

9 files changed

+573
-52
lines changed

9 files changed

+573
-52
lines changed

rundeckapp/grails-app/controllers/ScheduledExecutionController.groovy

+105-5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.dtolabs.rundeck.core.common.INodeEntry
3030
import org.apache.commons.collections.list.TreeList
3131
import org.codehaus.groovy.grails.commons.ConfigurationHolder
3232
import com.dtolabs.rundeck.app.api.ApiBulkJobDeleteRequest
33+
import org.apache.commons.httpclient.SimpleHttpConnectionManager
3334

3435
class ScheduledExecutionController {
3536
def Scheduler quartzScheduler
@@ -262,7 +263,7 @@ class ScheduledExecutionController {
262263
def values=[]
263264
if (opt.realValuesUrl) {
264265
//load expand variables in URL source
265-
String srcUrl = expandUrl(opt, opt.realValuesUrl.toExternalForm(), scheduledExecution)
266+
String srcUrl = expandUrl(opt, opt.realValuesUrl.toExternalForm(), scheduledExecution,params.extra?.option)
266267
String cleanUrl=srcUrl.replaceAll("^(https?://)([^:@/]+):[^@/]*@",'$1$2:****@');
267268
def remoteResult=[:]
268269
def result=null
@@ -326,9 +327,13 @@ class ScheduledExecutionController {
326327
}
327328
}else if(!err){
328329
err.message = "Empty result"
330+
err.code='empty'
329331
}
330-
331-
return render(template: "/framework/optionValuesSelect", model: [optionSelect: opt, values: result, srcUrl: cleanUrl, err: err,fieldPrefix:params.fieldPrefix,selectedvalue:params.selectedvalue]);
332+
def model= [optionSelect: opt, values: result, srcUrl: cleanUrl, err: err, fieldPrefix: params.fieldPrefix, selectedvalue: params.selectedvalue]
333+
if(params.extra?.option?.get(opt.name)){
334+
model.selectedoptsmap=[(opt.name):params.extra.option.get(opt.name)]
335+
}
336+
return render(template: "/framework/optionValuesSelect", model: model);
332337
} else {
333338
return error.call()
334339
}
@@ -394,14 +399,23 @@ class ScheduledExecutionController {
394399
* ${job.PROPERTY} and ${option.PROPERTY}. available properties are
395400
* limited
396401
*/
397-
String expandUrl(Option opt, String url, ScheduledExecution scheduledExecution) {
402+
String expandUrl(Option opt, String url, ScheduledExecution scheduledExecution,selectedoptsmap=[:]) {
398403
def invalid = []
399-
String srcUrl = url.replaceAll(/(\$\{(job|option)\.(.+?)\})/,
404+
String srcUrl = url.replaceAll(/(\$\{(job|option)\.([^\.}]+?(\.value)?)\})/,
400405
{Object[] group ->
401406
if(group[2]=='job' && jobprops[group[3]] && scheduledExecution.properties.containsKey(jobprops[group[3]])) {
402407
scheduledExecution.properties.get(jobprops[group[3]]).toString().encodeAsURL()
403408
}else if(group[2]=='option' && optprops[group[3]] && opt.properties.containsKey(optprops[group[3]])) {
404409
opt.properties.get(optprops[group[3]]).toString().encodeAsURL()
410+
}else if(group[2]=='option' && group[4]=='.value' ) {
411+
def optname= group[3].substring(0, group[3].length() - '.value'.length())
412+
def value=selectedoptsmap&& selectedoptsmap instanceof Map?selectedoptsmap[optname]:null
413+
//find option with name
414+
def Option expopt = scheduledExecution.options.find {it.name == optname}
415+
if(value && expopt.multivalued && (value instanceof Collection || value instanceof String[])){
416+
value = value.join(expopt.delimiter)
417+
}
418+
value?:''
405419
} else {
406420
invalid << group[0]
407421
group[0]
@@ -2639,8 +2653,94 @@ class ScheduledExecutionController {
26392653
model.selectedoptsmap = frameworkService.parseOptsFromString(params.argString)
26402654
}
26412655
model.localNodeName=framework.getFrameworkNodeName()
2656+
2657+
//determine option dependencies based on valuesURl embedded references
2658+
//map of option name to list of option names which depend on it
2659+
def depopts=[:]
2660+
//map of option name to list of option names it depends on
2661+
def optdeps=[:]
2662+
scheduledExecution.options.each { Option opt->
2663+
if(opt.realValuesUrl){
2664+
(opt.realValuesUrl=~/\$\{option\.([^.}\s]+?)\.value\}/ ).each{match,oname->
2665+
if(oname==opt.name){
2666+
return
2667+
}
2668+
//add opt to list of dependents of oname
2669+
if(!depopts[oname]){
2670+
depopts[oname]=[opt.name]
2671+
}else{
2672+
depopts[oname] << opt.name
2673+
}
2674+
//add oname to list of dependencies of opt
2675+
if(!optdeps[opt.name]){
2676+
optdeps[opt.name]=[oname]
2677+
}else{
2678+
optdeps[opt.name] << oname
2679+
}
2680+
}
2681+
}
2682+
}
2683+
model.dependentoptions=depopts
2684+
model.optiondependencies=optdeps
2685+
//topo sort the dependencies
2686+
def toporesult = toposort(scheduledExecution.options*.name, depopts, optdeps)
2687+
model.optionordering= toporesult.result
2688+
if(scheduledExecution.options && !toporesult.result){
2689+
log.warn("Cyclic dependency for options for job ${scheduledExecution.extid}: (${toporesult.cycle})")
2690+
model.optionsDependenciesCyclic=true
2691+
}
2692+
26422693
return model
26432694
}
2695+
private deepClone(Map map) {
2696+
def copy = [:]
2697+
map.each { k, v ->
2698+
if (v instanceof List){
2699+
copy[k] = v.clone()
2700+
}
2701+
else {
2702+
copy[k] = v
2703+
}
2704+
}
2705+
return copy
2706+
}
2707+
2708+
/**
2709+
* Return topo sorted list of nodes, if acyclic
2710+
* @param nodes
2711+
* @param oedgesin
2712+
* @param iedgesin
2713+
* @return
2714+
*/
2715+
private toposort(List nodes,Map oedgesin,Map iedgesin){
2716+
def Map oedges = deepClone(oedgesin)
2717+
def Map iedges = deepClone(iedgesin)
2718+
def l = new ArrayList()
2719+
def s = new TreeSet(nodes.findAll {!iedges[it]})
2720+
while(s){
2721+
def n = s.first()
2722+
s.remove(n)
2723+
l.add(n)
2724+
//for each node dependent on n
2725+
def edges = new ArrayList()
2726+
if(oedges[n]){
2727+
edges.addAll(oedges[n])
2728+
}
2729+
edges.each{p->
2730+
oedges[n].remove(p)
2731+
iedges[p].remove(n)
2732+
if(!iedges[p]){
2733+
s<<p
2734+
}
2735+
}
2736+
}
2737+
if (iedges.any {it.value} || oedges.any{it.value}){
2738+
//cyclic graph
2739+
return [cycle: iedges]
2740+
}else{
2741+
return [result:l]
2742+
}
2743+
}
26442744
def executeFragment = {
26452745
Framework framework = frameworkService.getFrameworkFromUserSession(session, request)
26462746
def scheduledExecution = scheduledExecutionService.getByIDorUUID(params.id)

rundeckapp/grails-app/i18n/messages.properties

+2-1
Original file line numberDiff line numberDiff line change
@@ -313,4 +313,5 @@ documentation.reference.cron.url=http://www.quartz-scheduler.org/docs/tutorials/
313313
Workflow.Step.adhocFilepath.description=Enter the path to a script file on the server or a URL
314314
Workflow.Step.adhocRemoteString.description=Enter the shell command, e.g.\: echo this is a test
315315
Workflow.Step.argString.description=Enter the commandline arguments for the script
316-
Workflow.Step.adhocLocalString.description=Enter the entire script to execute
316+
Workflow.Step.adhocLocalString.description=Enter the entire script to execute
317+
remote.options.warning.cyclicDependencies=Note\: Some remote option values have cyclic dependencies, please manually click the "reload" button for options when a dependency has changed.

rundeckapp/grails-app/views/framework/_commandOptions.gsp

+89-12
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
<%@ page import="grails.converters.deep.JSON; grails.util.Environment" %>
12
<%--
23
used by _editOptions.gsp template
34
--%>
@@ -17,29 +18,43 @@ used by _editOptions.gsp template
1718
<table>
1819
<tr>
1920
<td style="vertical-align:top">
20-
<table class="simpleForm">
21-
<g:each var="optName" in="${optsmap.keySet().sort()}">
21+
<table class="simpleForm" id="_commandOptions">
22+
<g:each var="optName" in="${optionordering?:optsmap.keySet().sort()}">
2223
<g:set var="optionSelect" value="${optsmap[optName].selopt }"/>
2324
<g:set var="optRequired" value="${optionSelect.required}"/>
2425
<g:set var="optDescription" value="${optionSelect.description}"/>
2526
<g:set var="fieldName" value="${usePrefix+'option.'+optName}"/>
2627
<g:set var="optionHasValue" value="${optionSelect.defaultValue || selectedoptsmap && selectedoptsmap[optName]}"/>
2728
<g:set var="hasError" value="${jobexecOptionErrors?jobexecOptionErrors[optName]:null}"/>
2829
<g:set var="fieldNamekey" value="${rkey+'_'+optName+'_label'}"/>
30+
<g:set var="fieldhiddenid" value="${rkey+'_'+optName+'_h'}"/>
2931
<tr>
30-
<td class="${hasError?'fieldError':''} remoteoptionfield" id="${fieldNamekey}"><span style="display:none;" class="remotestatus"></span> ${optName}:</td>
32+
<td class="${hasError?'fieldError':''} remoteoptionfield" id="${fieldNamekey}"><span style="display:none;" class="remotestatus"></span> ${optName.encodeAsHTML()}:
33+
<g:if test="${Environment.current == Environment.DEVELOPMENT && grailsApplication.config.rundeck?.debug}">
34+
(${optiondependencies? optiondependencies[optName]:'-'})(${dependentoptions? dependentoptions[optName]:'-'})
35+
</g:if>
36+
</td>
37+
%{--determine if option has all dependencies met--}%
38+
<g:set var="optionDepsMet" value="${!optiondependencies[optName] || selectedoptsmap && optiondependencies[optName].every {selectedoptsmap[it]}}" />
3139
<td>
3240
<g:if test="${optionSelect.realValuesUrl !=null}">
3341
<g:set var="holder" value="${rkey+'_'+optName+'_hold'}"/>
3442
<span id="${holder}" >
43+
<g:if test="${!optionDepsMet}">
44+
<span class="info note">
45+
Select a value for these options: ${optiondependencies[optName].join(', ').encodeAsHTML()}
46+
</span>
47+
</g:if>
48+
<g:hiddenField name="${fieldName}" value="${selectedoptsmap?selectedoptsmap[optName]:''}" id="${fieldhiddenid}"/>
49+
<span class="loading"></span>
3550
</span>
36-
<g:javascript>
37-
_loadRemoteOptionValues("${holder}",'${scheduledExecutionId}','${optName}','${usePrefix}','${selectedoptsmap?selectedoptsmap[optName]:''}','${fieldNamekey}',true);
38-
</g:javascript>
51+
<g:if test="${Environment.current == Environment.DEVELOPMENT && grailsApplication.config.rundeck?.debug}">
52+
<a onclick="_remoteOptionControl('_commandOptions').loadRemoteOptionValues('${optName.encodeAsJavaScript()}');return false;" href="#">${optName.encodeAsHTML()} reload</a>
53+
</g:if>
3954
</g:if>
4055
<g:else>
4156
<g:render template="/framework/optionValuesSelect"
42-
model="${[elemTarget:rkey+'_'+optName,optionSelect:optionSelect, fieldPrefix:usePrefix,fieldName:'option.'+optName,selectedoptsmap:selectedoptsmap]}"/>
57+
model="${[elemTarget:rkey+'_'+optName,optionSelect:optionSelect, fieldPrefix:usePrefix,fieldName:'option.'+optName,selectedoptsmap:selectedoptsmap,fieldkey: fieldhiddenid]}"/>
4358
</g:else>
4459

4560
<span id="${optName.encodeAsHTML()+'_state'}">
@@ -48,19 +63,81 @@ used by _editOptions.gsp template
4863
<img src="${resource( dir:'images',file:'icon-small-warn.png' )}" class="warnimg"
4964
alt="Required Option" title="Required Option" width="16px" height="16px" />
5065
<g:if test="${hasError && hasError.contains('required')}">
51-
<span class="error label">${hasError}</span>
66+
<span class="error label">${hasError.encodeAsHTML()}</span>
5267
</g:if>
5368
</span>
5469
</g:if>
5570
<g:if test="${hasError && !hasError.contains('required')}">
56-
<span class="error label">${hasError}</span>
71+
<span class="error label">${hasError.encodeAsHTML()}</span>
5772
</g:if>
5873
</span>
59-
<div class="info note">${optDescription}</div>
74+
<div class="info note">${optDescription?.encodeAsHTML()}</div>
6075
</td>
6176
</tr>
6277
</g:each>
78+
79+
<%--
80+
Javascript for configuring remote option cascading/dependencies
81+
--%>
82+
<g:javascript>
83+
fireWhenReady('_commandOptions', function(){
84+
var remoteOptions = _remoteOptionControl('_commandOptions');
85+
<g:if test="${optionsDependenciesCyclic}">
86+
remoteOptions.cyclic=true;
87+
</g:if>
88+
<g:each var="optName" in="${optionordering ?: optsmap.keySet().sort()}">
89+
<g:set var="optionSelect" value="${optsmap[optName].selopt}"/>
90+
<g:set var="fieldName" value="${usePrefix + 'option.' + optName}"/>
91+
<g:set var="fieldNamekey" value="${rkey + '_' + optName + '_label'}"/>
92+
<g:set var="holder" value="${rkey + '_' + optName + '_hold'}"/>
93+
<g:set var="fieldhiddenid" value="${rkey + '_' + optName + '_h'}"/>
94+
<g:set var="optionDepsMet"
95+
value="${!optiondependencies[optName] || selectedoptsmap && optiondependencies[optName].every {selectedoptsmap[it]}}"/>
96+
<g:if test="${optiondependencies[optName]}">
97+
remoteOptions.addOptionDependencies("${optName.encodeAsJavaScript()}", ${optiondependencies[optName] as JSON});
98+
</g:if>
99+
<g:if test="${dependentoptions[optName]}">
100+
<%-- If option has dependents, register them to refresh when this option value changes --%>
101+
remoteOptions.addOptionDeps("${optName.encodeAsJavaScript()}", ${dependentoptions[optName] as JSON});
102+
103+
104+
<g:if test="${optionSelect.enforced}">
105+
<%-- Will be a drop down list, so trigger change automatically. --%>
106+
remoteOptions.setOptionAutoReload("${optName.encodeAsJavaScript()}",true);
107+
</g:if>
108+
</g:if>
109+
<g:if test="${optionSelect.realValuesUrl != null}">
110+
<%-- If option has a remote URL, register data used for ajax reload --%>
111+
remoteOptions.addOption("${optName.encodeAsJavaScript()}","${holder.encodeAsJavaScript()}",'${scheduledExecutionId.encodeAsJavaScript()}','${optName.encodeAsJavaScript()}','${usePrefix.encodeAsJavaScript()}','${selectedoptsmap ? selectedoptsmap[optName]?.encodeAsJavaScript() : ''}','${fieldNamekey.encodeAsJavaScript()}',true);
112+
113+
<g:if test="${!optiondependencies[optName] || optionsDependenciesCyclic}">
114+
remoteOptions.loadonstart["${optName.encodeAsJavaScript()}"]=true;
115+
</g:if>
116+
<g:else>
117+
remoteOptions.setOptionAutoReload("${optName.encodeAsJavaScript()}",true);
118+
</g:else>
119+
<g:if test="${optionSelect.multivalued}">
120+
remoteOptions.setFieldMultiId('${optName.encodeAsJavaScript()}','${fieldhiddenid.encodeAsJavaScript()}');
121+
</g:if>
122+
<g:else>
123+
remoteOptions.setFieldId('${optName.encodeAsJavaScript()}','${fieldhiddenid.encodeAsJavaScript()}');
124+
</g:else>
125+
</g:if>
126+
<g:else>
127+
remoteOptions.addLocalOption("${optName.encodeAsJavaScript()}");
128+
</g:else>
129+
</g:each>
130+
<%-- register observers for field value changes --%>
131+
remoteOptions.observeChanges();
132+
if(typeof(_registerJobExecUnloadHandler)=='function'){
133+
_registerJobExecUnloadHandler(remoteOptions.unload.bind(remoteOptions));
134+
}
135+
});
136+
</g:javascript>
63137
</table>
138+
<g:if test="${optionsDependenciesCyclic}">
139+
<g:message code="remote.options.warning.cyclicDependencies" />
140+
</g:if>
64141
</td>
65142
<g:if test="${showDTFormat}">
66143
<td style="vertical-align:top" >
@@ -115,11 +192,11 @@ used by _editOptions.gsp template
115192
</g:if>
116193
<g:elseif test="${notfound}">
117194
<div class="info note">Choose a valid command (notfound).</div>
118-
<g:if test="${selectedargstring}"><div>Old value: ${selectedargstring}</div></g:if>
195+
<g:if test="${selectedargstring}"><div>Old value: ${selectedargstring.encodeAsHTML()}</div></g:if>
119196
</g:elseif>
120197
<g:elseif test="${!authorized}">
121198
<div class="info note">Not authorized to execute chosen command.</div>
122-
<g:if test="${selectedargstring}"><div>Old value: ${selectedargstring}</div></g:if>
199+
<g:if test="${selectedargstring}"><div>Old value: ${selectedargstring.encodeAsHTML()}</div></g:if>
123200
</g:elseif>
124201
<g:else>
125202
<span class="info note">None for this job</span>

0 commit comments

Comments
 (0)