Skip to content

Commit b58ee44

Browse files
authored
Merge pull request #420 from rundeck-plugins/RUN-3327-issues-with-ansible-inline-workflow-executions-where-it-shows-an-unwanted-output
RUN-3327: Issues with ansible inline workflow executions where it shows an unwanted output
2 parents c1c121a + d991f56 commit b58ee44

4 files changed

Lines changed: 292 additions & 2 deletions

File tree

src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleInventory.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,27 @@ public AnsibleInventory addHost(String nodeName, String host, Map<String, String
5959
for (String r: reserved){
6060
attributes.remove(r);
6161
}
62-
all.addHost(nodeName, host, attributes);
62+
6363
// Create Ansible groups by attribute
6464
// Group by osFamily is needed for windows hosts setup
6565
String[] attributeGroups = { "osFamily", "tags" };
6666
for (String g: attributeGroups) {
6767
if (attributes.containsKey(g)) {
6868
String[] groupNames = attributes.get(g).toLowerCase().split(",");
6969
for (String groupName: groupNames) {
70-
all.getOrAddChildHostGroup(groupName.trim()).addHost(nodeName);
70+
// Sanitize group name: Ansible only allows letters, numbers, underscores, and hyphens
71+
// Note: group names are already lowercased above, so we only need to match lowercase
72+
// Replace invalid characters (like colons, spaces, special chars) with underscores
73+
String sanitizedGroupName = groupName.trim().replaceAll("[^a-z0-9_\\-]", "_");
74+
all.getOrAddChildHostGroup(sanitizedGroupName).addHost(nodeName);
7175
}
7276
}
7377
}
78+
79+
//remove "tags" since it's reserved in Ansible and we've already used it for grouping
80+
attributes.remove("tags");
81+
82+
all.addHost(nodeName, host, attributes);
7483
return this;
7584
}
7685
}

src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleResourceModelSource.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,30 @@ public void processHosts(Map<String, Object> hostNames, HashSet<String> tags) th
786786
}
787787

788788
for (Map.Entry<String, Object> hostNode : hosts.entrySet()) {
789+
// Filter out invalid host keys that aren't Strings (can occur with YAML anchors/aliases)
790+
Object hostKeyObj = hostNode.getKey();
791+
if (hostKeyObj == null) {
792+
log.warn("Skipping host entry with null key");
793+
continue;
794+
}
795+
if (!(hostKeyObj instanceof String)) {
796+
log.warn("Skipping invalid host entry with non-String key: {}", hostKeyObj.getClass().getName());
797+
continue;
798+
}
799+
800+
// Additional validation: skip keys that are JSON objects (likely serialized data structures)
801+
String hostKey = (String) hostKeyObj;
802+
try {
803+
JsonElement jsonElement = JsonParser.parseString(hostKey);
804+
if (jsonElement.isJsonObject()) {
805+
log.warn("Skipping host entry with key that is a JSON object (likely serialized data): {}",
806+
hostKey.length() > 100 ? hostKey.substring(0, 100) + "..." : hostKey);
807+
continue;
808+
}
809+
} catch (Exception e) {
810+
// Not valid JSON - treat as a legitimate host key and continue processing
811+
}
812+
789813
NodeEntryImpl node = createNodeEntry(hostNode);
790814
addNode(node, tags);
791815
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.rundeck.plugins.ansible.ansible
2+
3+
import com.google.gson.Gson
4+
import spock.lang.Specification
5+
6+
/**
7+
* AnsibleInventory test
8+
*/
9+
class AnsibleInventorySpec extends Specification {
10+
11+
void "addHost should sanitize group names with invalid characters"() {
12+
given: "an AnsibleInventory instance"
13+
AnsibleInventory inventory = new AnsibleInventory()
14+
Gson gson = new Gson()
15+
16+
when: "adding a host with tags containing colons"
17+
Map<String, String> attributes = new HashMap<>()
18+
attributes.put("tags", "runner:tag:ansible,testnodes")
19+
inventory.addHost("test-node", "127.0.0.1", attributes)
20+
21+
then: "group names should have colons replaced with underscores"
22+
String json = gson.toJson(inventory)
23+
json.contains("runner_tag_ansible")
24+
json.contains("testnodes")
25+
!json.contains("runner:tag:ansible")
26+
}
27+
28+
void "addHost should sanitize group names with special characters"() {
29+
given: "an AnsibleInventory instance"
30+
AnsibleInventory inventory = new AnsibleInventory()
31+
Gson gson = new Gson()
32+
33+
when: "adding a host with tags containing various special characters"
34+
Map<String, String> attributes = new HashMap<>()
35+
attributes.put("tags", "tag@with#special!chars,normal-tag_ok")
36+
inventory.addHost("test-node", "127.0.0.1", attributes)
37+
38+
then: "special characters should be replaced with underscores"
39+
String json = gson.toJson(inventory)
40+
json.contains("tag_with_special_chars")
41+
json.contains("normal-tag_ok")
42+
}
43+
44+
void "addHost should remove reserved tags attribute from host variables"() {
45+
given: "an AnsibleInventory instance"
46+
AnsibleInventory inventory = new AnsibleInventory()
47+
Gson gson = new Gson()
48+
49+
when: "adding a host with tags attribute"
50+
Map<String, String> attributes = new HashMap<>()
51+
attributes.put("tags", "tag1,tag2")
52+
attributes.put("custom_attr", "value")
53+
inventory.addHost("test-node", "127.0.0.1", attributes)
54+
55+
then: "tags should not appear in host variables but custom attributes should"
56+
String json = gson.toJson(inventory)
57+
json.contains("custom_attr")
58+
json.contains("value")
59+
// Verify tags is not in the host's attributes (not in quoted key context)
60+
!json.contains('"tags"')
61+
}
62+
63+
void "addHost should remove reserved Ansible variables"() {
64+
given: "an AnsibleInventory instance"
65+
AnsibleInventory inventory = new AnsibleInventory()
66+
Gson gson = new Gson()
67+
68+
when: "adding a host with reserved Ansible variables"
69+
Map<String, String> attributes = new HashMap<>()
70+
attributes.put("hostvars", "should_be_removed")
71+
attributes.put("group_names", "should_be_removed")
72+
attributes.put("groups", "should_be_removed")
73+
attributes.put("environment", "should_be_removed")
74+
attributes.put("custom_var", "should_remain")
75+
inventory.addHost("test-node", "127.0.0.1", attributes)
76+
77+
then: "reserved variables should be removed"
78+
String json = gson.toJson(inventory)
79+
json.contains("custom_var")
80+
json.contains("should_remain")
81+
!json.contains("hostvars")
82+
!json.contains("group_names")
83+
!json.contains("should_be_removed")
84+
}
85+
86+
void "addHost should create groups from osFamily attribute and retain it in host variables"() {
87+
given: "an AnsibleInventory instance"
88+
AnsibleInventory inventory = new AnsibleInventory()
89+
Gson gson = new Gson()
90+
91+
when: "adding a host with osFamily attribute"
92+
Map<String, String> attributes = new HashMap<>()
93+
attributes.put("osFamily", "unix")
94+
attributes.put("custom_attr", "test_value")
95+
inventory.addHost("test-node", "127.0.0.1", attributes)
96+
97+
then: "unix group should be created AND osFamily should remain in host variables"
98+
String json = gson.toJson(inventory)
99+
json.contains("unix")
100+
// osFamily should NOT be removed (unlike tags) - it's needed by other components
101+
json.contains('"osFamily":"unix"')
102+
json.contains("custom_attr")
103+
}
104+
105+
void "addHost should handle multiple tags and create multiple groups"() {
106+
given: "an AnsibleInventory instance"
107+
AnsibleInventory inventory = new AnsibleInventory()
108+
Gson gson = new Gson()
109+
110+
when: "adding a host with multiple tags"
111+
Map<String, String> attributes = new HashMap<>()
112+
attributes.put("tags", "linux,web-server,production")
113+
inventory.addHost("test-node", "127.0.0.1", attributes)
114+
115+
then: "all groups should be created"
116+
String json = gson.toJson(inventory)
117+
json.contains("linux")
118+
json.contains("web-server")
119+
json.contains("production")
120+
}
121+
}

src/test/groovy/com/rundeck/plugins/ansible/plugin/AnsibleResourceModelSourceSpec.groovy

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,4 +278,140 @@ class AnsibleResourceModelSourceSpec extends Specification {
278278
return yaml.dump(all)
279279
}
280280

281+
void "processHosts should process valid String host keys without errors"() {
282+
given: "a plugin with standard inventory"
283+
Framework framework = Mock(Framework) {
284+
getPropertyLookup() >> Mock(IPropertyLookup){
285+
getProperty("framework.tmp.dir") >> '/tmp'
286+
}
287+
getBaseDir() >> Mock(File) {
288+
getAbsolutePath() >> '/tmp'
289+
}
290+
}
291+
AnsibleResourceModelSource plugin = new AnsibleResourceModelSource(framework)
292+
Properties config = new Properties()
293+
config.put('project', 'project_1')
294+
config.put(AnsibleDescribable.ANSIBLE_GATHER_FACTS, 'false')
295+
plugin.configure(config)
296+
Services services = Mock(Services) {
297+
getService(KeyStorageTree.class) >> Mock(KeyStorageTree)
298+
}
299+
plugin.setServices(services)
300+
301+
when: "YAML contains valid String keys"
302+
// Note: Testing non-String keys directly is difficult because Java's type system
303+
// prevents creating Map<String, Object> with non-String keys. The defensive code
304+
// handles the rare edge case where complex YAML anchor/alias patterns produce non-String keys during parsing.
305+
Yaml yaml = new Yaml()
306+
def validHost = ['node-1' : ['hostname' : 'host-1']]
307+
def hosts = ['hosts' : validHost]
308+
def groups = ['ungrouped' : hosts]
309+
def children = ['children' : groups]
310+
def all = ['all' : children]
311+
String result = yaml.dump(all)
312+
313+
AnsibleInventoryListBuilder inventoryListBuilder = Mock(AnsibleInventoryListBuilder) {
314+
build() >> Mock(AnsibleInventoryList) {
315+
getNodeList() >> result
316+
}
317+
}
318+
plugin.ansibleInventoryListBuilder = inventoryListBuilder
319+
INodeSet nodes = plugin.getNodes()
320+
321+
then: "valid String keys are processed successfully"
322+
nodes.size() == 1
323+
nodes.getNodeNames().contains('node-1')
324+
}
325+
326+
void "processHosts should filter out JSON object keys"() {
327+
given: "a plugin configured"
328+
Framework framework = Mock(Framework) {
329+
getPropertyLookup() >> Mock(IPropertyLookup){
330+
getProperty("framework.tmp.dir") >> '/tmp'
331+
}
332+
getBaseDir() >> Mock(File) {
333+
getAbsolutePath() >> '/tmp'
334+
}
335+
}
336+
AnsibleResourceModelSource plugin = new AnsibleResourceModelSource(framework)
337+
Properties config = new Properties()
338+
config.put('project', 'project_1')
339+
config.put(AnsibleDescribable.ANSIBLE_GATHER_FACTS, 'false')
340+
plugin.configure(config)
341+
Services services = Mock(Services) {
342+
getService(KeyStorageTree.class) >> Mock(KeyStorageTree)
343+
}
344+
plugin.setServices(services)
345+
346+
when: "YAML contains a key that is a valid JSON object (serialized data)"
347+
Yaml yaml = new Yaml()
348+
def hosts = [
349+
'valid-node': ['hostname': 'valid-host'],
350+
'{"inventory":"data","nested":{"key":"value"}}': ['hostname': 'json-object-key'],
351+
'{simple-curly-name}': ['hostname': 'not-json-host'] // Not valid JSON, should be kept
352+
]
353+
def hostsMap = ['hosts': hosts]
354+
def groups = ['ungrouped': hostsMap]
355+
def children = ['children': groups]
356+
def all = ['all': children]
357+
String result = yaml.dump(all)
358+
359+
AnsibleInventoryListBuilder inventoryListBuilder = Mock(AnsibleInventoryListBuilder) {
360+
build() >> Mock(AnsibleInventoryList) {
361+
getNodeList() >> result
362+
}
363+
}
364+
plugin.ansibleInventoryListBuilder = inventoryListBuilder
365+
INodeSet nodes = plugin.getNodes()
366+
367+
then: "JSON object key is filtered out but valid nodes remain"
368+
nodes.size() == 2
369+
nodes.getNodeNames().contains('valid-node')
370+
nodes.getNodeNames().contains('{simple-curly-name}') // Not valid JSON, so kept
371+
!nodes.getNodeNames().contains('{"inventory":"data","nested":{"key":"value"}}')
372+
}
373+
374+
void "processHosts should handle valid nodes without exception"() {
375+
given: "a plugin configured"
376+
Framework framework = Mock(Framework) {
377+
getPropertyLookup() >> Mock(IPropertyLookup){
378+
getProperty("framework.tmp.dir") >> '/tmp'
379+
}
380+
getBaseDir() >> Mock(File) {
381+
getAbsolutePath() >> '/tmp'
382+
}
383+
}
384+
AnsibleResourceModelSource plugin = new AnsibleResourceModelSource(framework)
385+
Properties config = new Properties()
386+
config.put('project', 'project_1')
387+
config.put(AnsibleDescribable.ANSIBLE_GATHER_FACTS, 'false')
388+
plugin.configure(config)
389+
Services services = Mock(Services) {
390+
getService(KeyStorageTree.class) >> Mock(KeyStorageTree)
391+
}
392+
plugin.setServices(services)
393+
394+
when: "processing hosts through the normal flow"
395+
Yaml yaml = new Yaml()
396+
def hosts = ['valid-node': ['hostname': 'valid-host']]
397+
def hostsMap = ['hosts': hosts]
398+
def groups = ['ungrouped': hostsMap]
399+
def children = ['children': groups]
400+
def all = ['all': children]
401+
String result = yaml.dump(all)
402+
403+
AnsibleInventoryListBuilder inventoryListBuilder = Mock(AnsibleInventoryListBuilder) {
404+
build() >> Mock(AnsibleInventoryList) {
405+
getNodeList() >> result
406+
}
407+
}
408+
plugin.ansibleInventoryListBuilder = inventoryListBuilder
409+
INodeSet nodes = plugin.getNodes()
410+
411+
then: "no exception thrown and node is processed correctly"
412+
notThrown(Exception)
413+
nodes.size() == 1
414+
nodes.getNodeNames().contains('valid-node')
415+
}
416+
281417
}

0 commit comments

Comments
 (0)