From 7bd89d500b6b8b11b7fe7fd54d6178ddaf096641 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Wed, 19 Nov 2025 16:51:38 -0300 Subject: [PATCH 01/46] add new feature to add the node auth in the generated inventory --- .../ansible/ansible/AnsibleDescribable.java | 8 ++ .../ansible/ansible/AnsibleRunner.java | 56 +++++++- .../ansible/AnsibleRunnerContextBuilder.java | 124 ++++++++++++++++-- .../AnsiblePlaybookInlineWorkflowStep.java | 2 + .../plugin/AnsiblePlaybookWorkflowStep.java | 4 +- .../plugins/ansible/util/AnsibleUtil.java | 10 ++ 6 files changed, 189 insertions(+), 15 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java index 0919418f..5b8696b1 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java @@ -100,6 +100,7 @@ public static String[] getValues() { public static final String ANSIBLE_INVENTORY_INLINE = "ansible-inventory-inline"; public static final String ANSIBLE_INVENTORY = "ansible-inventory"; public static final String ANSIBLE_GENERATE_INVENTORY = "ansible-generate-inventory"; + public static final String ANSIBLE_GENERATE_INVENTORY_NODES_AUTH = "ansible-generate-inventory-nodes-auth"; public static final String ANSIBLE_MODULE = "ansible-module"; public static final String ANSIBLE_MODULE_ARGS = "ansible-module-args"; public static final String ANSIBLE_DEBUG = "ansible-debug"; @@ -233,6 +234,13 @@ public static String[] getValues() { .description("Generate Ansible inventory from Rundeck nodes.") .build(); + static final Property GENERATE_INVENTORY_NODES_AUTH = PropertyBuilder.builder() + .booleanType(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH) + .required(false) + .title("Generate inventory, pass node authentication from rundeck nodes") + .description("Pass authentication from rundeck nodes.") + .build(); + public static Property EXECUTABLE_PROP = PropertyUtil.freeSelect( ANSIBLE_EXECUTABLE, "Executable", diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index ae653aea..2072f1a4 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -127,6 +127,15 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte ansibleRunnerBuilder.inventory(inventory); } + Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); + if(generateInventoryNodeAuth){ + Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); + if (nodesAuth != null && !nodesAuth.isEmpty()) { + ansibleRunnerBuilder.addNodeAuthToInventory(true); + ansibleRunnerBuilder.nodesAuthentication(nodesAuth); + } + } + String limit = contextBuilder.getLimit(); if (limit != null) { ansibleRunnerBuilder.limits(limit); @@ -291,9 +300,13 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte File tempSshVarsFile ; File tempBecameVarsFile ; File vaultPromptFile; + File tempNodeAuthFile = null; String customTmpDirPath; + Boolean addNodeAuthToInventory; + Map> nodesAuthentication; + public void deleteTempDirectory(Path tempDirectory) throws IOException { Files.walkFileTree(tempDirectory, new SimpleFileVisitor() { @Override @@ -396,6 +409,40 @@ public int run() throws Exception { if (inventory != null && !inventory.isEmpty()) { procArgs.add("-i"); procArgs.add(inventory); + + if(addNodeAuthToInventory) { + Map hostUsers = new LinkedHashMap<>(); + Map hostPasswords = new LinkedHashMap<>(); + nodesAuthentication.forEach((nodeName, authValues) -> { + String user = authValues.get("ansible_user"); + String password = authValues.get("ansible_password"); + if (user != null) { + hostUsers.put(nodeName, user); + } + if (password != null) { + String encryptedPassword = password; + if (useAnsibleVault) { + try { + encryptedPassword = encryptExtraVarsKey(password); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + hostPasswords.put(nodeName, encryptedPassword); + } + }); + // Build YAML structure + Map yamlData = new LinkedHashMap<>(); + yamlData.put("host_passwords", hostPasswords); + yamlData.put("host_users", hostUsers); + try { + String yamlContent = mapperYaml.writeValueAsString(yamlData); + tempNodeAuthFile = AnsibleUtil.createTemporaryFile("", "all.yaml", yamlContent, customTmpDirPath); + } catch (IOException e) { + throw new RuntimeException("Failed to write all.yaml for node auth", e); + } + } + } if (limits != null && limits.size() == 1) { @@ -513,8 +560,6 @@ public int run() throws Exception { //SET env variables Map processEnvironment = new HashMap<>(); - - if (configFile != null && !configFile.isEmpty()) { if (debug) { System.out.println(" ANSIBLE_CONFIG: " + configFile); @@ -663,6 +708,10 @@ public int run() throws Exception { vaultPromptFile.deleteOnExit(); } + if (tempNodeAuthFile != null && !tempNodeAuthFile.delete()) { + tempNodeAuthFile.deleteOnExit(); + } + if (usingTempDirectory && !retainTempDirectory) { deleteTempDirectory(baseDirectory); } @@ -824,4 +873,5 @@ public String encryptExtraVarsKey(String extraVars) throws Exception { } -} \ No newline at end of file +} + diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index ef74733b..449ec7dd 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -193,17 +193,7 @@ public String getSshPassword() throws ConfigurationException { if (null != storagePath) { //look up storage value - Path path = PathUtil.asPath(storagePath); - try { - ResourceMeta contents = context.getStorageTree().getResource(path) - .getContents(); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - contents.writeContent(byteArrayOutputStream); - return byteArrayOutputStream.toString(); - } catch (StorageException | IOException e) { - throw new ConfigurationException("Failed to read the ssh password for " + - "storage path: " + storagePath + ": " + e.getMessage()); - } + return getPasswordFromPath(storagePath); } else { return null; @@ -211,6 +201,21 @@ public String getSshPassword() throws ConfigurationException { } } + public String getPasswordFromPath(String storagePath) throws ConfigurationException { + //look up storage value + Path path = PathUtil.asPath(storagePath); + try { + ResourceMeta contents = context.getStorageTree().getResource(path) + .getContents(); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + contents.writeContent(byteArrayOutputStream); + return byteArrayOutputStream.toString(); + } catch (StorageException | IOException e) { + throw new ConfigurationException("Failed to read the ssh password for " + + "storage path: " + storagePath + ": " + e.getMessage()); + } + } + public Integer getSSHTimeout() throws ConfigurationException { Integer timeout = null; final String stimeout = PropertyResolver.resolveProperty( @@ -249,6 +254,23 @@ public String getSshUser() { return user; } + public String getSshNodeUser(INodeEntry node) { + final String user; + user = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_SSH_USER, + null, + getFrameworkProject(), + getFramework(), + node, + getJobConf() + ); + + if (null != user && user.contains("${")) { + return DataContextUtils.replaceDataReferencesInString(user, getContext().getDataContext()); + } + return user; + } + public AuthenticationType getSshAuthenticationType() { String authType = PropertyResolver.resolveProperty( @@ -880,4 +902,84 @@ public Map getListOptions(){ } return options; } + + public Map> getNodesAuthenticationMap(){ + + Map> authenticationNodesMap = new HashMap<>(); + + this.context.getNodes().forEach((node) -> { + String keyPath = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, + null, + getFrameworkProject(), + getFramework(), + node, + getJobConf() + ); + + Map auth = new HashMap<>(); + + if(null!=keyPath){ + try { + auth.put("ansible_password",getPasswordFromPath(keyPath) ); + } catch (ConfigurationException e) { + throw new RuntimeException(e); + } + + } + String userName = getSshNodeUser(node); + + if(null!=userName){ + auth.put("ansible_user",userName ); + } + + authenticationNodesMap.put(node.getNodename(), auth); + }); + + return authenticationNodesMap; + } + + + public List getListNodesKeyPath(){ + + List secretPaths = new ArrayList<>(); + + this.context.getNodes().forEach((node) -> { + String keyPath = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_SSH_KEYPATH, + null, + getFrameworkProject(), + getFramework(), + node, + getJobConf() + ); + + if(null!=keyPath){ + if(!secretPaths.contains(keyPath)){ + secretPaths.add(keyPath); + } + + } + }); + + return secretPaths; + } + + + public Boolean generateInventoryNodesAuth() { + Boolean generateInventoryNodesAuth = null; + String sgenerateInventoryNodesAuth = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, + null, + getFrameworkProject(), + getFramework(), + getNode(), + getJobConf() + ); + + if (null != sgenerateInventoryNodesAuth) { + generateInventoryNodesAuth = Boolean.parseBoolean(sgenerateInventoryNodesAuth); + } + return generateInventoryNodesAuth; + } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 8a0a09ad..acf2f979 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -43,6 +43,8 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(PLAYBOOK_INLINE_PROP); builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); + builder.property(GENERATE_INVENTORY_PROP); + builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(INVENTORY_INLINE_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 1f5ba521..0fef6462 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -43,6 +43,8 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); + builder.property(GENERATE_INVENTORY_PROP); + builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -127,7 +129,7 @@ public Description getDescription() { @Override public List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration); - return AnsibleUtil.getSecretsPath(builder); + return AnsibleUtil.getSecretsPathWorkflowSteps(builder); } @Override public Map getRuntimeProperties(ExecutionContext context) { diff --git a/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java b/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java index 126a36f7..9be0accd 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java @@ -86,6 +86,16 @@ public static List getSecretsPath(AnsibleRunnerContextBuilder builder){ } + public static List getSecretsPathWorkflowSteps(AnsibleRunnerContextBuilder builder){ + List secretPaths = getSecretsPath(builder); + List secretPathsNodes =builder.getListNodesKeyPath(); + + if(secretPathsNodes!=null && !secretPathsNodes.isEmpty()){ + secretPaths.addAll(secretPathsNodes); + } + return secretPaths; + } + public static Map getRuntimeProperties(ExecutionContext context, String propertyPrefix) { Map properties = null; From de654c6953feceb348e8fc953af61d4c0bb1aad3 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Thu, 11 Dec 2025 16:05:55 -0300 Subject: [PATCH 02/46] fix getting ssh-password from nodes --- .../plugins/ansible/ansible/AnsibleRunnerContextBuilder.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 449ec7dd..3fa916bb 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -946,7 +946,7 @@ public List getListNodesKeyPath(){ this.context.getNodes().forEach((node) -> { String keyPath = PropertyResolver.resolveProperty( - AnsibleDescribable.ANSIBLE_SSH_KEYPATH, + AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, null, getFrameworkProject(), getFramework(), @@ -958,7 +958,6 @@ public List getListNodesKeyPath(){ if(!secretPaths.contains(keyPath)){ secretPaths.add(keyPath); } - } }); From 1fa16df32c71429dbc5b841070f3b7e3213790a0 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 11 Dec 2025 20:06:20 -0300 Subject: [PATCH 03/46] group_vars and properties in nodeexecutor --- .../ansible/ansible/AnsibleRunner.java | 41 ++++++++++++++++--- .../ansible/plugin/AnsibleNodeExecutor.java | 11 ++++- .../AnsiblePlaybookInlineWorkflowStep.java | 2 - .../plugin/AnsiblePlaybookWorkflowStep.java | 2 - 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 2072f1a4..8947afb6 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -128,7 +128,7 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte } Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); - if(generateInventoryNodeAuth){ + if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); if (nodesAuth != null && !nodesAuth.isEmpty()) { ansibleRunnerBuilder.addNodeAuthToInventory(true); @@ -300,11 +300,13 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte File tempSshVarsFile ; File tempBecameVarsFile ; File vaultPromptFile; - File tempNodeAuthFile = null; + File tempNodeAuthFile; + File groupVarsDir; String customTmpDirPath; - Boolean addNodeAuthToInventory; + @Builder.Default + Boolean addNodeAuthToInventory = false; Map> nodesAuthentication; public void deleteTempDirectory(Path tempDirectory) throws IOException { @@ -410,7 +412,7 @@ public int run() throws Exception { procArgs.add("-i"); procArgs.add(inventory); - if(addNodeAuthToInventory) { + if(addNodeAuthToInventory != null && addNodeAuthToInventory && nodesAuthentication != null && !nodesAuthentication.isEmpty()) { Map hostUsers = new LinkedHashMap<>(); Map hostPasswords = new LinkedHashMap<>(); nodesAuthentication.forEach((nodeName, authValues) -> { @@ -437,7 +439,30 @@ public int run() throws Exception { yamlData.put("host_users", hostUsers); try { String yamlContent = mapperYaml.writeValueAsString(yamlData); - tempNodeAuthFile = AnsibleUtil.createTemporaryFile("", "all.yaml", yamlContent, customTmpDirPath); + + // Create group_vars directory structure + File inventoryFile = new File(inventory); + File inventoryParentDir = inventoryFile.getParentFile(); + + if (inventoryParentDir != null) { + groupVarsDir = new File(inventoryParentDir, "group_vars"); + + if (!groupVarsDir.exists()) { + if (!groupVarsDir.mkdirs()) { + throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath()); + } + } + + // Create all.yaml in group_vars directory + tempNodeAuthFile = new File(groupVarsDir, "all.yaml"); + java.nio.file.Files.writeString(tempNodeAuthFile.toPath(), yamlContent); + tempNodeAuthFile.deleteOnExit(); + groupVarsDir.deleteOnExit(); + } else { + // Fallback to temp file if inventory has no parent directory + tempNodeAuthFile = AnsibleUtil.createTemporaryFile("group_vars", "all.yaml", yamlContent, customTmpDirPath); + } + } catch (IOException e) { throw new RuntimeException("Failed to write all.yaml for node auth", e); } @@ -712,6 +737,12 @@ public int run() throws Exception { tempNodeAuthFile.deleteOnExit(); } + if (groupVarsDir != null && groupVarsDir.exists()) { + if (!groupVarsDir.delete()) { + groupVarsDir.deleteOnExit(); + } + } + if (usingTempDirectory && !retainTempDirectory) { deleteTempDirectory(baseDirectory); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java index f7421c3b..e75ae84e 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java @@ -36,6 +36,7 @@ public class AnsibleNodeExecutor implements NodeExecutor, AnsibleDescribable, Pr builder.property(WINDOWS_EXECUTABLE_PROP); builder.property(CONFIG_FILE_PATH); builder.property(GENERATE_INVENTORY_PROP); + builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(SSH_AUTH_TYPE_PROP); builder.property(SSH_USER_PROP); builder.property(SSH_PASSWORD_STORAGE_PROP); @@ -63,6 +64,8 @@ public class AnsibleNodeExecutor implements NodeExecutor, AnsibleDescribable, Pr builder.frameworkMapping(ANSIBLE_CONFIG_FILE_PATH,FWK_PROP_PREFIX + ANSIBLE_CONFIG_FILE_PATH); builder.mapping(ANSIBLE_GENERATE_INVENTORY,PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY,FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH,PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH,FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); builder.mapping(ANSIBLE_SSH_AUTH_TYPE,PROJ_PROP_PREFIX + ANSIBLE_SSH_AUTH_TYPE); builder.frameworkMapping(ANSIBLE_SSH_AUTH_TYPE,FWK_PROP_PREFIX + ANSIBLE_SSH_AUTH_TYPE); builder.mapping(ANSIBLE_SSH_USER,PROJ_PROP_PREFIX + ANSIBLE_SSH_USER); @@ -197,7 +200,13 @@ public List listSecretsPath(ExecutionContext context, INodeEntry node) { jobConf.put(AnsibleDescribable.ANSIBLE_LIMIT,node.getNodename()); AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(node, context, context.getFramework(), jobConf); - return AnsibleUtil.getSecretsPath(builder); + List secretPaths = AnsibleUtil.getSecretsPath(builder); + List secretPathsNodes = builder.getListNodesKeyPath(); + + if(secretPathsNodes != null && !secretPathsNodes.isEmpty()){ + secretPaths.addAll(secretPathsNodes); + } + return secretPaths; } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index acf2f979..8a0a09ad 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -43,8 +43,6 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(PLAYBOOK_INLINE_PROP); builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); - builder.property(GENERATE_INVENTORY_PROP); - builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(INVENTORY_INLINE_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 0fef6462..762ad794 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -43,8 +43,6 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); - builder.property(GENERATE_INVENTORY_PROP); - builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); From 71b4af994b7b0e80cdab5bb5471e51a3e187589d Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 18 Dec 2025 22:03:05 -0300 Subject: [PATCH 04/46] Added Inline Playbook option for testing --- .../plugin/AnsiblePlaybookInlineWorkflowNodeStep.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java index a9a1d40c..5827ccc6 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java @@ -43,6 +43,8 @@ public class AnsiblePlaybookInlineWorkflowNodeStep implements NodeStepPlugin, An builder.property(PLAYBOOK_INLINE_PROP); builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); + builder.property(GENERATE_INVENTORY_PROP); + builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -121,7 +123,13 @@ public void executeNodeStep( @Override public List listSecretsPathWorkflowNodeStep(ExecutionContext context, INodeEntry node, Map configuration) { AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(node, context, context.getFramework(), configuration); - return AnsibleUtil.getSecretsPath(builder); + List secretPaths = AnsibleUtil.getSecretsPath(builder); + List secretPathsNodes = builder.getListNodesKeyPath(); + + if(secretPathsNodes != null && !secretPathsNodes.isEmpty()){ + secretPaths.addAll(secretPathsNodes); + } + return secretPaths; } @Override From 70d64d2f7ec9fa65499e6b3c6233a39702070c9d Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 23 Dec 2025 22:09:55 -0300 Subject: [PATCH 05/46] authentitacion debug logs --- .../ansible/AnsibleRunnerContextBuilder.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 3fa916bb..128b5046 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -209,7 +209,7 @@ public String getPasswordFromPath(String storagePath) throws ConfigurationExcept .getContents(); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); contents.writeContent(byteArrayOutputStream); - return byteArrayOutputStream.toString(); + return byteArrayOutputStream.toString("UTF-8"); } catch (StorageException | IOException e) { throw new ConfigurationException("Failed to read the ssh password for " + "storage path: " + storagePath + ": " + e.getMessage()); @@ -907,6 +907,8 @@ public Map> getNodesAuthenticationMap(){ Map> authenticationNodesMap = new HashMap<>(); + System.err.println("DEBUG: getNodesAuthenticationMap called"); + this.context.getNodes().forEach((node) -> { String keyPath = PropertyResolver.resolveProperty( AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, @@ -917,17 +919,28 @@ public Map> getNodesAuthenticationMap(){ getJobConf() ); + System.err.println("DEBUG: Node " + node.getNodename() + " keyPath: " + keyPath); + Map auth = new HashMap<>(); if(null!=keyPath){ try { - auth.put("ansible_password",getPasswordFromPath(keyPath) ); + String password = getPasswordFromPath(keyPath); + System.err.println("DEBUG: Retrieved password for " + node.getNodename() + ": " + (password != null ? password.substring(0, Math.min(3, password.length())) + "..." : "null")); + System.err.println("DEBUG: Password length: " + (password != null ? password.length() : 0)); + System.err.println("DEBUG: Password bytes: " + (password != null ? java.util.Arrays.toString(password.getBytes("UTF-8")) : "null")); + auth.put("ansible_password", password); } catch (ConfigurationException e) { + System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); throw new RuntimeException(e); + } catch (Exception e2) { + System.err.println("DEBUG: Unexpected error: " + e2.getMessage()); + throw new RuntimeException(e2); } } String userName = getSshNodeUser(node); + System.err.println("DEBUG: Node " + node.getNodename() + " userName: " + userName); if(null!=userName){ auth.put("ansible_user",userName ); @@ -936,6 +949,7 @@ public Map> getNodesAuthenticationMap(){ authenticationNodesMap.put(node.getNodename(), auth); }); + System.err.println("DEBUG: authenticationNodesMap has " + authenticationNodesMap.size() + " entries"); return authenticationNodesMap; } From 1f7a7931b00b7aa1c9eccf9d4cbf39a7fea16da4 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Tue, 6 Jan 2026 11:10:05 -0300 Subject: [PATCH 06/46] fix encrypted group vars for generated inventory enable the node auth just for workflow steps --- .../ansible/ansible/AnsibleRunner.java | 89 ++++++++++++++----- .../ansible/AnsibleRunnerContextBuilder.java | 10 +-- .../AnsiblePlaybookInlineWorkflowStep.java | 17 +++- .../plugin/AnsiblePlaybookWorkflowStep.java | 15 +++- 4 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 8947afb6..341c6415 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -6,6 +6,7 @@ import com.dtolabs.rundeck.core.utils.SSHAgentProcess; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import lombok.Builder; import lombok.Data; @@ -127,15 +128,6 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte ansibleRunnerBuilder.inventory(inventory); } - Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); - if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ - Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); - if (nodesAuth != null && !nodesAuth.isEmpty()) { - ansibleRunnerBuilder.addNodeAuthToInventory(true); - ansibleRunnerBuilder.nodesAuthentication(nodesAuth); - } - } - String limit = contextBuilder.getLimit(); if (limit != null) { ansibleRunnerBuilder.limits(limit); @@ -284,7 +276,12 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte @Builder.Default private boolean useAnsibleVault = false; - static ObjectMapper mapperYaml = new ObjectMapper(new YAMLFactory()); + static ObjectMapper mapperYaml = new ObjectMapper( + new YAMLFactory() + .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + ); static ObjectMapper mapperJson = new ObjectMapper(); private ProcessExecutor.ProcessExecutorBuilder processExecutorBuilder; @@ -399,8 +396,8 @@ public int run() throws Exception { // 3) become-password is used for node authentication if (encryptExtraVars && extraVars != null && !extraVars.isEmpty() || sshUsePassword || - (become && becomePassword != null && !becomePassword.isEmpty())) { - + (become && becomePassword != null && !becomePassword.isEmpty()) || + (addNodeAuthToInventory != null && addNodeAuthToInventory)){ useAnsibleVault = ansibleVault.checkAnsibleVault(); if(!useAnsibleVault) { @@ -413,6 +410,7 @@ public int run() throws Exception { procArgs.add(inventory); if(addNodeAuthToInventory != null && addNodeAuthToInventory && nodesAuthentication != null && !nodesAuthentication.isEmpty()) { + Map hostUsers = new LinkedHashMap<>(); Map hostPasswords = new LinkedHashMap<>(); nodesAuthentication.forEach((nodeName, authValues) -> { @@ -425,7 +423,7 @@ public int run() throws Exception { String encryptedPassword = password; if (useAnsibleVault) { try { - encryptedPassword = encryptExtraVarsKey(password); + encryptedPassword = ansibleVault.encryptVariable("ansible_password", password); } catch (Exception e) { throw new RuntimeException(e); } @@ -433,15 +431,17 @@ public int run() throws Exception { hostPasswords.put(nodeName, encryptedPassword); } }); - // Build YAML structure - Map yamlData = new LinkedHashMap<>(); - yamlData.put("host_passwords", hostPasswords); - yamlData.put("host_users", hostUsers); + + // Build YAML content using helper method + String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers); + try { - String yamlContent = mapperYaml.writeValueAsString(yamlData); // Create group_vars directory structure File inventoryFile = new File(inventory); + + System.err.println("DEBUG: inventoryFile: " + inventoryFile.getAbsolutePath()); + File inventoryParentDir = inventoryFile.getParentFile(); if (inventoryParentDir != null) { @@ -456,13 +456,17 @@ public int run() throws Exception { // Create all.yaml in group_vars directory tempNodeAuthFile = new File(groupVarsDir, "all.yaml"); java.nio.file.Files.writeString(tempNodeAuthFile.toPath(), yamlContent); - tempNodeAuthFile.deleteOnExit(); - groupVarsDir.deleteOnExit(); } else { // Fallback to temp file if inventory has no parent directory tempNodeAuthFile = AnsibleUtil.createTemporaryFile("group_vars", "all.yaml", yamlContent, customTmpDirPath); } + System.err.println("DEBUG: tempNodeAuthFile: " + tempNodeAuthFile.getAbsolutePath()); + + //set extra vars to resolve the host specific authentication + procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); + procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); + } catch (IOException e) { throw new RuntimeException("Failed to write all.yaml for node auth", e); } @@ -844,6 +848,51 @@ public boolean registerKeySshAgent(String keyPath) throws Exception { } + /** + * Builds YAML content for Ansible group_vars with vault-encrypted passwords and host users. + * This method manually constructs the YAML because Jackson cannot properly handle Ansible's + * custom !vault tag, which would result in an extra | character. + * + * @param hostPasswords Map of host names to vault-encrypted password strings + * @param hostUsers Map of host names to usernames + * @return YAML content string ready to be written to all.yaml + */ + private String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { + StringBuilder yamlContent = new StringBuilder(); + yamlContent.append("host_passwords:\n"); + + for (Map.Entry entry : hostPasswords.entrySet()) { + yamlContent.append(" ").append(entry.getKey()).append(": "); + String vaultValue = entry.getValue(); + + // The vault value already contains "!vault |\n" followed by the encrypted content + // We just need to split and indent properly (4 spaces for vault content lines) + if (vaultValue.contains("\n")) { + String[] lines = vaultValue.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i == 0) { + // First line: "!vault |" + yamlContent.append(lines[i]).append("\n"); + } else if (i < lines.length - 1 || !lines[i].isEmpty()) { + // Vault content: indent with 4 spaces + yamlContent.append(" ").append(lines[i]).append("\n"); + } + } + } else { + // Single line value (shouldn't happen with vault, but handle it) + yamlContent.append(vaultValue).append("\n"); + } + } + + yamlContent.append("\nhost_users:\n"); + for (Map.Entry entry : hostUsers.entrySet()) { + yamlContent.append(" ").append(entry.getKey()).append(": ") + .append(entry.getValue()).append("\n"); + } + + return yamlContent.toString(); + } + public String encryptExtraVarsKey(String extraVars) throws Exception { Map extraVarsMap = new HashMap<>(); Map encryptedExtraVarsMap = new HashMap<>(); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 128b5046..b821bfdb 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -256,15 +256,7 @@ public String getSshUser() { public String getSshNodeUser(INodeEntry node) { final String user; - user = PropertyResolver.resolveProperty( - AnsibleDescribable.ANSIBLE_SSH_USER, - null, - getFrameworkProject(), - getFramework(), - node, - getJobConf() - ); - + user = node.getUsername(); if (null != user && user.contains("${")) { return DataContextUtils.replaceDataReferencesInString(user, getContext().getDataContext()); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 8a0a09ad..479eaf01 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -37,6 +37,10 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.name(SERVICE_PROVIDER_NAME); builder.title("Ansible Playbook Inline"); builder.description("Runs an Inline Ansible Playbook."); + builder.metadata( + "automaticWorkflowStepRunnerAssociation", + "true" + ); builder.property(BINARIES_DIR_PATH_PROP); builder.property(BASE_DIR_PROP); @@ -93,8 +97,19 @@ public void executeStep(PluginStepContext context, Map configura configuration, pluginGroup); + try { runner = AnsibleRunner.buildAnsibleRunner(contextBuilder); + + Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); + if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ + Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); + if (nodesAuth != null && !nodesAuth.isEmpty()) { + runner.setAddNodeAuthToInventory(true); + runner.setNodesAuthentication(nodesAuth); + } + } + runner.setCustomTmpDirPath(AnsibleUtil.getCustomTmpPathDir(contextBuilder.getFramework())); } catch (ConfigurationException e) { throw new StepException("Error configuring Ansible runner: " + e.getMessage(), e, AnsibleException.AnsibleFailureReason.ParseArgumentsError); @@ -132,7 +147,7 @@ public Description getDescription() { @Override public List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration); - return AnsibleUtil.getSecretsPath(builder); + return AnsibleUtil.getSecretsPathWorkflowSteps(builder); } @Override diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 762ad794..1f4fb319 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -36,7 +36,10 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.name(SERVICE_PROVIDER_NAME); builder.title("Ansible Playbook"); builder.description("Runs an Ansible Playbook."); - + builder.metadata( + "automaticWorkflowStepRunnerAssociation", + "true" + ); builder.property(BINARIES_DIR_PATH_PROP); builder.property(BASE_DIR_PROP); builder.property(PLAYBOOK_PATH_PROP); @@ -94,6 +97,16 @@ public void executeStep(PluginStepContext context, Map configura try { runner = AnsibleRunner.buildAnsibleRunner(contextBuilder); + + Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); + if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ + Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); + if (nodesAuth != null && !nodesAuth.isEmpty()) { + runner.setAddNodeAuthToInventory(true); + runner.setNodesAuthentication(nodesAuth); + } + } + runner.setCustomTmpDirPath(AnsibleUtil.getCustomTmpPathDir(contextBuilder.getFramework())); } catch (ConfigurationException e) { throw new StepException("Error configuring Ansible runner: " + e.getMessage(), e, AnsibleException.AnsibleFailureReason.ParseArgumentsError); From 4a4c3c29e429ba4b18d540258ef9418c3799e35c Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 6 Jan 2026 12:15:19 -0300 Subject: [PATCH 07/46] Added checkboxes at project level, added execution-tmp folder creation and cleanup --- .../ansible/AnsibleRunnerContextBuilder.java | 112 +++++++++++++++++- .../AnsiblePlaybookInlineWorkflowStep.java | 7 ++ .../plugin/AnsiblePlaybookWorkflowStep.java | 7 ++ .../ansible/plugin/AnsiblePluginGroup.java | 28 +++++ 4 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index b821bfdb..b646e94b 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -40,6 +40,7 @@ public class AnsibleRunnerContextBuilder { private final Map jobConf; private final Collection nodes; private final Collection tempFiles; + private File executionSpecificDir; private AnsiblePluginGroup pluginGroup; @@ -610,6 +611,17 @@ public Boolean generateInventory() { if (null != sgenerateInventory) { generateInventory = Boolean.parseBoolean(sgenerateInventory); } + + // Fallback to plugin group configuration if not set + if (generateInventory == null || !generateInventory) { + if (this.pluginGroup != null && this.pluginGroup.getGenerateInventory() != null && this.pluginGroup.getGenerateInventory()) { + this.context.getExecutionLogger().log( + 4, "plugin group set getGenerateInventory: " + this.pluginGroup.getGenerateInventory() + ); + generateInventory = this.pluginGroup.getGenerateInventory(); + } + } + return generateInventory; } @@ -620,7 +632,8 @@ public String getInventory() throws ConfigurationException { if (isGenerated != null && isGenerated) { - File tempInventory = new AnsibleInventoryBuilder(this.nodes, AnsibleUtil.getCustomTmpPathDir(framework)).buildInventory(); + String executionSpecificDir = getExecutionSpecificTmpDir(); + File tempInventory = new AnsibleInventoryBuilder(this.nodes, executionSpecificDir).buildInventory(); tempFiles.add(tempInventory); inventory = tempInventory.getAbsolutePath(); return inventory; @@ -640,7 +653,8 @@ public String getInventory() throws ConfigurationException { the builder gets the nodes from rundeck in rundeck node format and converts to ansible inventory we don't want that, we simply want the list we provided in ansible format */ - File tempInventory = new AnsibleInlineInventoryBuilder(inline_inventory,AnsibleUtil.getCustomTmpPathDir(framework)).buildInventory(); + String executionSpecificDir = getExecutionSpecificTmpDir(); + File tempInventory = new AnsibleInlineInventoryBuilder(inline_inventory, executionSpecificDir).buildInventory(); tempFiles.add(tempInventory); inventory = tempInventory.getAbsolutePath(); return inventory; @@ -769,12 +783,53 @@ public INodeEntry getNode() { public void cleanupTempFiles() { + System.err.println("DEBUG: cleanupTempFiles called"); + + // Clean up individual temp files for (File temp : tempFiles) { if (!getDebug()) { + System.err.println("DEBUG: Deleting temp file: " + temp.getAbsolutePath()); temp.delete(); } } tempFiles.clear(); + + // Clean up execution-specific directory (including group_vars) + if (executionSpecificDir != null && executionSpecificDir.exists()) { + System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); + if (!getDebug()) { + System.err.println("DEBUG: Deleting execution-specific directory recursively"); + deleteDirectoryRecursively(executionSpecificDir); + System.err.println("DEBUG: Execution-specific directory deleted"); + } else { + System.err.println("DEBUG: Skipping cleanup due to debug mode"); + } + } else { + System.err.println("DEBUG: No execution-specific directory to cleanup"); + } + } + + /** + * Recursively deletes a directory and all its contents. + * + * @param directory The directory to delete + */ + private void deleteDirectoryRecursively(File directory) { + if (directory == null || !directory.exists()) { + return; + } + + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteDirectoryRecursively(file); + } else { + file.delete(); + } + } + } + directory.delete(); } public Boolean getUseSshAgent() { @@ -985,6 +1040,59 @@ public Boolean generateInventoryNodesAuth() { if (null != sgenerateInventoryNodesAuth) { generateInventoryNodesAuth = Boolean.parseBoolean(sgenerateInventoryNodesAuth); } + + // Fallback to plugin group configuration if not set + if (generateInventoryNodesAuth == null || !generateInventoryNodesAuth) { + if (this.pluginGroup != null && this.pluginGroup.getGenerateInventoryNodesAuth() != null && this.pluginGroup.getGenerateInventoryNodesAuth()) { + this.context.getExecutionLogger().log( + 4, "plugin group set getGenerateInventoryNodesAuth: " + this.pluginGroup.getGenerateInventoryNodesAuth() + ); + generateInventoryNodesAuth = this.pluginGroup.getGenerateInventoryNodesAuth(); + } + } + return generateInventoryNodesAuth; } + + /** + * Creates and returns an execution-specific temporary directory path. + * This ensures that each execution has its own isolated directory for inventory and group_vars, + * preventing conflicts when multiple workflow step executions run in parallel. + * + * @return The path to the execution-specific directory + */ + private String getExecutionSpecificTmpDir() { + // Return cached directory if already created + if (executionSpecificDir != null) { + System.err.println("DEBUG: Using cached execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + return executionSpecificDir.getAbsolutePath(); + } + + String executionId = null; + + // Get execution ID from data context + if (context.getDataContext() != null && context.getDataContext().get("job") != null) { + executionId = context.getDataContext().get("job").get("execid"); + System.err.println("DEBUG: Execution ID from context: " + executionId); + } + + // Get base tmp directory + String baseTmpDir = AnsibleUtil.getCustomTmpPathDir(framework); + + // Create execution-specific directory + if (executionId != null && !executionId.isEmpty()) { + executionSpecificDir = new File(baseTmpDir, "ansible-exec-" + executionId); + if (!executionSpecificDir.exists()) { + System.err.println("DEBUG: Creating execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + executionSpecificDir.mkdirs(); + } else { + System.err.println("DEBUG: Execution-specific directory already exists: " + executionSpecificDir.getAbsolutePath()); + } + return executionSpecificDir.getAbsolutePath(); + } + + // Fallback to base tmp dir if execution ID is not available + System.err.println("DEBUG: No execution ID found, falling back to base tmp dir: " + baseTmpDir); + return baseTmpDir; + } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 479eaf01..3b12cabd 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -48,6 +48,7 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); + builder.property(GENERATE_INVENTORY_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -66,6 +67,12 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(BECOME_PASSWORD_STORAGE_PROP); builder.property(DISABLE_LIMIT_PROP); + // Project and framework level mappings for inventory generation + builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + DESC = builder.build(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 1f4fb319..710d1377 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -46,6 +46,7 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); + builder.property(GENERATE_INVENTORY_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -64,6 +65,12 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(BECOME_PASSWORD_STORAGE_PROP); builder.property(DISABLE_LIMIT_PROP); + // Project and framework level mappings for inventory generation + builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + DESC = builder.build(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java index 743665f0..064092fa 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java @@ -39,6 +39,22 @@ public void setEncryptExtraVars(Boolean encryptExtraVars) { this.encryptExtraVars = encryptExtraVars; } + public Boolean getGenerateInventory() { + return generateInventory; + } + + public void setGenerateInventory(Boolean generateInventory) { + this.generateInventory = generateInventory; + } + + public Boolean getGenerateInventoryNodesAuth() { + return generateInventoryNodesAuth; + } + + public void setGenerateInventoryNodesAuth(Boolean generateInventoryNodesAuth) { + this.generateInventoryNodesAuth = generateInventoryNodesAuth; + } + @PluginProperty( title = "Ansible config file path", description = "Set ansible config file path." @@ -57,6 +73,18 @@ public void setEncryptExtraVars(Boolean encryptExtraVars) { ) Boolean encryptExtraVars; + @PluginProperty( + title = "Generate inventory", + description = "Generate an Ansible inventory from the targeted nodes." + ) + Boolean generateInventory; + + @PluginProperty( + title = "Generate inventory with node authentication", + description = "Add node-specific authentication credentials to the generated inventory via group_vars." + ) + Boolean generateInventoryNodesAuth; + @Override public Description getDescription() { DescriptionBuilder builder = DescriptionBuilder.builder(); From 1590942a90c046a2144de05d936797ef56e5f86d Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 6 Jan 2026 14:37:17 -0300 Subject: [PATCH 08/46] Added Vault validation and Name escaping, added debug logs to yaml file creation --- .../ansible/ansible/AnsibleRunner.java | 135 +++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 341c6415..57476dd0 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -433,41 +433,58 @@ public int run() throws Exception { }); // Build YAML content using helper method + System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords and " + hostUsers.size() + " users"); String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers); + System.err.println("DEBUG: YAML content built successfully, length: " + yamlContent.length()); try { // Create group_vars directory structure File inventoryFile = new File(inventory); - System.err.println("DEBUG: inventoryFile: " + inventoryFile.getAbsolutePath()); + System.err.println("DEBUG: inventory file exists: " + inventoryFile.exists()); File inventoryParentDir = inventoryFile.getParentFile(); + System.err.println("DEBUG: inventoryParentDir: " + (inventoryParentDir != null ? inventoryParentDir.getAbsolutePath() : "null")); if (inventoryParentDir != null) { groupVarsDir = new File(inventoryParentDir, "group_vars"); + System.err.println("DEBUG: group_vars directory path: " + groupVarsDir.getAbsolutePath()); if (!groupVarsDir.exists()) { + System.err.println("DEBUG: Creating group_vars directory"); if (!groupVarsDir.mkdirs()) { throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath()); } + System.err.println("DEBUG: group_vars directory created successfully"); + } else { + System.err.println("DEBUG: group_vars directory already exists"); } // Create all.yaml in group_vars directory tempNodeAuthFile = new File(groupVarsDir, "all.yaml"); + System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); java.nio.file.Files.writeString(tempNodeAuthFile.toPath(), yamlContent); + System.err.println("DEBUG: all.yaml written successfully, file size: " + tempNodeAuthFile.length() + " bytes"); } else { // Fallback to temp file if inventory has no parent directory + System.err.println("DEBUG: No parent directory, using temporary file"); tempNodeAuthFile = AnsibleUtil.createTemporaryFile("group_vars", "all.yaml", yamlContent, customTmpDirPath); + System.err.println("DEBUG: Temporary all.yaml created at: " + tempNodeAuthFile.getAbsolutePath()); } System.err.println("DEBUG: tempNodeAuthFile: " + tempNodeAuthFile.getAbsolutePath()); + System.err.println("DEBUG: tempNodeAuthFile exists: " + tempNodeAuthFile.exists()); + System.err.println("DEBUG: tempNodeAuthFile readable: " + tempNodeAuthFile.canRead()); //set extra vars to resolve the host specific authentication + System.err.println("DEBUG: Adding ansible extra vars for host-specific auth"); procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); } catch (IOException e) { + System.err.println("ERROR: Failed to write all.yaml: " + e.getMessage()); + e.printStackTrace(); throw new RuntimeException("Failed to write all.yaml for node auth", e); } } @@ -858,17 +875,31 @@ public boolean registerKeySshAgent(String keyPath) throws Exception { * @return YAML content string ready to be written to all.yaml */ private String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { + System.err.println("DEBUG: buildGroupVarsYaml called with " + hostPasswords.size() + " hosts"); + StringBuilder yamlContent = new StringBuilder(); yamlContent.append("host_passwords:\n"); for (Map.Entry entry : hostPasswords.entrySet()) { - yamlContent.append(" ").append(entry.getKey()).append(": "); + String originalKey = entry.getKey(); + String escapedKey = escapeYamlKey(originalKey); + System.err.println("DEBUG: Processing host: " + originalKey + " -> escaped: " + escapedKey); + + yamlContent.append(" ").append(escapedKey).append(": "); String vaultValue = entry.getValue(); + // Validate vault format + if (!isValidVaultFormat(vaultValue)) { + System.err.println("ERROR: Invalid vault format for host: " + originalKey); + throw new RuntimeException("Invalid vault format for host: " + originalKey); + } + System.err.println("DEBUG: Vault format validated for host: " + originalKey); + // The vault value already contains "!vault |\n" followed by the encrypted content // We just need to split and indent properly (4 spaces for vault content lines) if (vaultValue.contains("\n")) { String[] lines = vaultValue.split("\n", -1); + System.err.println("DEBUG: Vault value has " + lines.length + " lines for host: " + originalKey); for (int i = 0; i < lines.length; i++) { if (i == 0) { // First line: "!vault |" @@ -880,17 +911,111 @@ private String buildGroupVarsYaml(Map hostPasswords, Map entry : hostUsers.entrySet()) { - yamlContent.append(" ").append(entry.getKey()).append(": ") - .append(entry.getValue()).append("\n"); + String originalKey = entry.getKey(); + String originalValue = entry.getValue(); + String escapedKey = escapeYamlKey(originalKey); + String escapedValue = escapeYamlValue(originalValue); + System.err.println("DEBUG: Adding user - host: " + originalKey + " -> " + escapedKey + ", user: " + originalValue + " -> " + escapedValue); + + yamlContent.append(" ").append(escapedKey).append(": ") + .append(escapedValue).append("\n"); } - return yamlContent.toString(); + String result = yamlContent.toString(); + System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes):"); + System.err.println("DEBUG: ========== YAML START =========="); + System.err.println(result); + System.err.println("DEBUG: ========== YAML END =========="); + + return result; + } + + /** + * Escapes YAML special characters in keys. + * If the key contains special characters, wraps it in quotes. + * + * @param key The key to escape + * @return Escaped key safe for YAML + */ + private String escapeYamlKey(String key) { + // YAML special characters that require quoting + if (key.matches(".*[:\\[\\]{}#&*!|>'\"%@`].*") || + key.startsWith("-") || + key.startsWith("?") || + key.matches("^[0-9].*")) { + // Escape any existing quotes and wrap in quotes + return "\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + return key; + } + + /** + * Escapes YAML special characters in values. + * If the value contains special characters, wraps it in quotes. + * + * @param value The value to escape + * @return Escaped value safe for YAML + */ + private String escapeYamlValue(String value) { + // Check if value needs quoting + if (value.matches(".*[:\\[\\]{}#&*!|>'\"%@`].*") || + value.startsWith("-") || + value.startsWith("?") || + value.trim().isEmpty()) { + // Escape any existing quotes and wrap in quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + return value; + } + + /** + * Validates that a string is in proper Ansible vault format. + * Expected format: "!vault |\n" followed by vault-encrypted content + * + * @param vaultValue The vault value to validate + * @return true if valid vault format, false otherwise + */ + private boolean isValidVaultFormat(String vaultValue) { + if (vaultValue == null || vaultValue.trim().isEmpty()) { + return false; + } + + // Must start with "!vault" (case insensitive) + String trimmed = vaultValue.trim(); + if (!trimmed.toLowerCase().startsWith("!vault")) { + return false; + } + + // For multiline vault values, should contain the pipe character and newline + if (vaultValue.contains("\n")) { + // Check if it follows the "!vault |" format + String firstLine = vaultValue.split("\n", 2)[0]; + if (!firstLine.trim().matches("!vault\\s*\\|?")) { + return false; + } + + // Should have encrypted content after the first line + String[] lines = vaultValue.split("\n"); + if (lines.length < 2) { + return false; + } + + // Vault content lines should not be empty (except maybe the last one) + for (int i = 1; i < lines.length - 1; i++) { + if (lines[i].trim().isEmpty()) { + return false; + } + } + } + + return true; } public String encryptExtraVarsKey(String extraVars) throws Exception { From e63534402baeae6b40ae1a96c9699cfe25b8be01 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Tue, 6 Jan 2026 16:26:40 -0300 Subject: [PATCH 09/46] clean debug messages --- .../ansible/ansible/AnsibleRunner.java | 66 ++++++++++--------- .../ansible/AnsibleRunnerContextBuilder.java | 27 +++----- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 57476dd0..0fb6bc43 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -433,52 +433,62 @@ public int run() throws Exception { }); // Build YAML content using helper method - System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords and " + hostUsers.size() + " users"); String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers); - System.err.println("DEBUG: YAML content built successfully, length: " + yamlContent.length()); + + if(debug){ + System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords and " + hostUsers.size() + " users"); + System.err.println("DEBUG: YAML content built successfully, length: " + yamlContent.length()); + } try { // Create group_vars directory structure File inventoryFile = new File(inventory); - System.err.println("DEBUG: inventoryFile: " + inventoryFile.getAbsolutePath()); - System.err.println("DEBUG: inventory file exists: " + inventoryFile.exists()); - File inventoryParentDir = inventoryFile.getParentFile(); - System.err.println("DEBUG: inventoryParentDir: " + (inventoryParentDir != null ? inventoryParentDir.getAbsolutePath() : "null")); + + if(debug) { + System.err.println("DEBUG: inventoryFile: " + inventoryFile.getAbsolutePath()); + System.err.println("DEBUG: inventory file exists: " + inventoryFile.exists()); + System.err.println("DEBUG: inventoryParentDir: " + (inventoryParentDir != null ? inventoryParentDir.getAbsolutePath() : "null")); + } if (inventoryParentDir != null) { groupVarsDir = new File(inventoryParentDir, "group_vars"); - System.err.println("DEBUG: group_vars directory path: " + groupVarsDir.getAbsolutePath()); + if(debug) { + System.err.println("DEBUG: group_vars directory path: " + groupVarsDir.getAbsolutePath()); + } if (!groupVarsDir.exists()) { - System.err.println("DEBUG: Creating group_vars directory"); if (!groupVarsDir.mkdirs()) { throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath()); } - System.err.println("DEBUG: group_vars directory created successfully"); - } else { - System.err.println("DEBUG: group_vars directory already exists"); } // Create all.yaml in group_vars directory tempNodeAuthFile = new File(groupVarsDir, "all.yaml"); - System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); java.nio.file.Files.writeString(tempNodeAuthFile.toPath(), yamlContent); - System.err.println("DEBUG: all.yaml written successfully, file size: " + tempNodeAuthFile.length() + " bytes"); + + if(debug) { + System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); + System.err.println("DEBUG: all.yaml written successfully, file size: " + tempNodeAuthFile.length() + " bytes"); + } } else { // Fallback to temp file if inventory has no parent directory - System.err.println("DEBUG: No parent directory, using temporary file"); tempNodeAuthFile = AnsibleUtil.createTemporaryFile("group_vars", "all.yaml", yamlContent, customTmpDirPath); - System.err.println("DEBUG: Temporary all.yaml created at: " + tempNodeAuthFile.getAbsolutePath()); + + if(debug) { + System.err.println("DEBUG: No parent directory, using temporary file"); + System.err.println("DEBUG: Temporary all.yaml created at: " + tempNodeAuthFile.getAbsolutePath()); + } } - System.err.println("DEBUG: tempNodeAuthFile: " + tempNodeAuthFile.getAbsolutePath()); - System.err.println("DEBUG: tempNodeAuthFile exists: " + tempNodeAuthFile.exists()); - System.err.println("DEBUG: tempNodeAuthFile readable: " + tempNodeAuthFile.canRead()); + if(debug) { + System.err.println("DEBUG: tempNodeAuthFile: " + tempNodeAuthFile.getAbsolutePath()); + System.err.println("DEBUG: tempNodeAuthFile exists: " + tempNodeAuthFile.exists()); + System.err.println("DEBUG: tempNodeAuthFile readable: " + tempNodeAuthFile.canRead()); + } //set extra vars to resolve the host specific authentication - System.err.println("DEBUG: Adding ansible extra vars for host-specific auth"); procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); @@ -875,7 +885,6 @@ public boolean registerKeySshAgent(String keyPath) throws Exception { * @return YAML content string ready to be written to all.yaml */ private String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { - System.err.println("DEBUG: buildGroupVarsYaml called with " + hostPasswords.size() + " hosts"); StringBuilder yamlContent = new StringBuilder(); yamlContent.append("host_passwords:\n"); @@ -883,7 +892,6 @@ private String buildGroupVarsYaml(Map hostPasswords, Map entry : hostPasswords.entrySet()) { String originalKey = entry.getKey(); String escapedKey = escapeYamlKey(originalKey); - System.err.println("DEBUG: Processing host: " + originalKey + " -> escaped: " + escapedKey); yamlContent.append(" ").append(escapedKey).append(": "); String vaultValue = entry.getValue(); @@ -893,13 +901,10 @@ private String buildGroupVarsYaml(Map hostPasswords, Map hostPasswords, Map " + escapedKey + ", user: " + originalValue + " -> " + escapedValue); - yamlContent.append(" ").append(escapedKey).append(": ") .append(escapedValue).append("\n"); } String result = yamlContent.toString(); - System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes):"); - System.err.println("DEBUG: ========== YAML START =========="); - System.err.println(result); - System.err.println("DEBUG: ========== YAML END =========="); + + if(debug){ + System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes):"); + System.err.println("DEBUG: ========== YAML START =========="); + System.err.println(result); + System.err.println("DEBUG: ========== YAML END =========="); + } return result; } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index b646e94b..cc2ad0e2 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -783,8 +783,6 @@ public INodeEntry getNode() { public void cleanupTempFiles() { - System.err.println("DEBUG: cleanupTempFiles called"); - // Clean up individual temp files for (File temp : tempFiles) { if (!getDebug()) { @@ -796,7 +794,9 @@ public void cleanupTempFiles() { // Clean up execution-specific directory (including group_vars) if (executionSpecificDir != null && executionSpecificDir.exists()) { - System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); + if (getDebug()) { + System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); + } if (!getDebug()) { System.err.println("DEBUG: Deleting execution-specific directory recursively"); deleteDirectoryRecursively(executionSpecificDir); @@ -954,8 +954,6 @@ public Map> getNodesAuthenticationMap(){ Map> authenticationNodesMap = new HashMap<>(); - System.err.println("DEBUG: getNodesAuthenticationMap called"); - this.context.getNodes().forEach((node) -> { String keyPath = PropertyResolver.resolveProperty( AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, @@ -966,16 +964,11 @@ public Map> getNodesAuthenticationMap(){ getJobConf() ); - System.err.println("DEBUG: Node " + node.getNodename() + " keyPath: " + keyPath); - Map auth = new HashMap<>(); if(null!=keyPath){ try { String password = getPasswordFromPath(keyPath); - System.err.println("DEBUG: Retrieved password for " + node.getNodename() + ": " + (password != null ? password.substring(0, Math.min(3, password.length())) + "..." : "null")); - System.err.println("DEBUG: Password length: " + (password != null ? password.length() : 0)); - System.err.println("DEBUG: Password bytes: " + (password != null ? java.util.Arrays.toString(password.getBytes("UTF-8")) : "null")); auth.put("ansible_password", password); } catch (ConfigurationException e) { System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); @@ -987,7 +980,6 @@ public Map> getNodesAuthenticationMap(){ } String userName = getSshNodeUser(node); - System.err.println("DEBUG: Node " + node.getNodename() + " userName: " + userName); if(null!=userName){ auth.put("ansible_user",userName ); @@ -996,7 +988,6 @@ public Map> getNodesAuthenticationMap(){ authenticationNodesMap.put(node.getNodename(), auth); }); - System.err.println("DEBUG: authenticationNodesMap has " + authenticationNodesMap.size() + " entries"); return authenticationNodesMap; } @@ -1073,7 +1064,10 @@ private String getExecutionSpecificTmpDir() { // Get execution ID from data context if (context.getDataContext() != null && context.getDataContext().get("job") != null) { executionId = context.getDataContext().get("job").get("execid"); - System.err.println("DEBUG: Execution ID from context: " + executionId); + + if (getDebug()) { + System.err.println("DEBUG: Execution ID from context: " + executionId); + } } // Get base tmp directory @@ -1083,16 +1077,15 @@ private String getExecutionSpecificTmpDir() { if (executionId != null && !executionId.isEmpty()) { executionSpecificDir = new File(baseTmpDir, "ansible-exec-" + executionId); if (!executionSpecificDir.exists()) { - System.err.println("DEBUG: Creating execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + if (getDebug()) { + System.err.println("DEBUG: Creating execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + } executionSpecificDir.mkdirs(); } else { System.err.println("DEBUG: Execution-specific directory already exists: " + executionSpecificDir.getAbsolutePath()); } return executionSpecificDir.getAbsolutePath(); } - - // Fallback to base tmp dir if execution ID is not available - System.err.println("DEBUG: No execution ID found, falling back to base tmp dir: " + baseTmpDir); return baseTmpDir; } } From d6635a35052640cbca5a80e0c26eab6fe78e0757 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 6 Jan 2026 16:35:00 -0300 Subject: [PATCH 10/46] removed ANSIBLE_GENERATE_INVENTORY_NODES_AUTH to only use plugin group configuration --- .../ansible/AnsibleRunnerContextBuilder.java | 32 ++++++------------- .../AnsiblePlaybookInlineWorkflowStep.java | 2 -- .../plugin/AnsiblePlaybookWorkflowStep.java | 2 -- 3 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index cc2ad0e2..48183627 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -1018,31 +1018,17 @@ public List getListNodesKeyPath(){ public Boolean generateInventoryNodesAuth() { - Boolean generateInventoryNodesAuth = null; - String sgenerateInventoryNodesAuth = PropertyResolver.resolveProperty( - AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, - null, - getFrameworkProject(), - getFramework(), - getNode(), - getJobConf() - ); - - if (null != sgenerateInventoryNodesAuth) { - generateInventoryNodesAuth = Boolean.parseBoolean(sgenerateInventoryNodesAuth); - } - - // Fallback to plugin group configuration if not set - if (generateInventoryNodesAuth == null || !generateInventoryNodesAuth) { - if (this.pluginGroup != null && this.pluginGroup.getGenerateInventoryNodesAuth() != null && this.pluginGroup.getGenerateInventoryNodesAuth()) { - this.context.getExecutionLogger().log( - 4, "plugin group set getGenerateInventoryNodesAuth: " + this.pluginGroup.getGenerateInventoryNodesAuth() - ); - generateInventoryNodesAuth = this.pluginGroup.getGenerateInventoryNodesAuth(); - } + // Only use plugin group configuration + if (this.pluginGroup != null && this.pluginGroup.getGenerateInventoryNodesAuth() != null) { + Boolean generateInventoryNodesAuth = this.pluginGroup.getGenerateInventoryNodesAuth(); + this.context.getExecutionLogger().log( + 4, "Using plugin group configuration for generateInventoryNodesAuth: " + generateInventoryNodesAuth + ); + return generateInventoryNodesAuth; } - return generateInventoryNodesAuth; + // Default to false if not configured + return false; } /** diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 3b12cabd..eddce682 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -70,8 +70,6 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes // Project and framework level mappings for inventory generation builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); DESC = builder.build(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 710d1377..796b95c9 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -68,8 +68,6 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab // Project and framework level mappings for inventory generation builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); DESC = builder.build(); } From 0b6c7dd4143d72a3f205f7954ae5e366106b0ac9 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 6 Jan 2026 17:08:25 -0300 Subject: [PATCH 11/46] reverted back to node exec and project config file --- .../ansible/AnsibleRunnerContextBuilder.java | 60 ++++++++++++------- .../AnsiblePlaybookInlineWorkflowStep.java | 22 +++++++ .../plugin/AnsiblePlaybookWorkflowStep.java | 22 +++++++ .../ansible/plugin/AnsiblePluginGroup.java | 28 --------- 4 files changed, 81 insertions(+), 51 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 48183627..4d5bca7b 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -612,16 +612,6 @@ public Boolean generateInventory() { generateInventory = Boolean.parseBoolean(sgenerateInventory); } - // Fallback to plugin group configuration if not set - if (generateInventory == null || !generateInventory) { - if (this.pluginGroup != null && this.pluginGroup.getGenerateInventory() != null && this.pluginGroup.getGenerateInventory()) { - this.context.getExecutionLogger().log( - 4, "plugin group set getGenerateInventory: " + this.pluginGroup.getGenerateInventory() - ); - generateInventory = this.pluginGroup.getGenerateInventory(); - } - } - return generateInventory; } @@ -798,14 +788,14 @@ public void cleanupTempFiles() { System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); } if (!getDebug()) { - System.err.println("DEBUG: Deleting execution-specific directory recursively"); deleteDirectoryRecursively(executionSpecificDir); - System.err.println("DEBUG: Execution-specific directory deleted"); } else { System.err.println("DEBUG: Skipping cleanup due to debug mode"); } } else { - System.err.println("DEBUG: No execution-specific directory to cleanup"); + if (getDebug()) { + System.err.println("DEBUG: No execution-specific directory to cleanup"); + } } } @@ -1018,17 +1008,39 @@ public List getListNodesKeyPath(){ public Boolean generateInventoryNodesAuth() { - // Only use plugin group configuration - if (this.pluginGroup != null && this.pluginGroup.getGenerateInventoryNodesAuth() != null) { - Boolean generateInventoryNodesAuth = this.pluginGroup.getGenerateInventoryNodesAuth(); - this.context.getExecutionLogger().log( - 4, "Using plugin group configuration for generateInventoryNodesAuth: " + generateInventoryNodesAuth - ); - return generateInventoryNodesAuth; + Boolean generateInventoryNodesAuth = null; + + if(getDebug()) { + System.err.println("DEBUG: Resolving property ANSIBLE_GENERATE_INVENTORY_NODES_AUTH"); + System.err.println("DEBUG: Property key: " + AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + System.err.println("DEBUG: Framework project: " + getFrameworkProject()); } - // Default to false if not configured - return false; + String sgenerateInventoryNodesAuth = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, + null, + getFrameworkProject(), + getFramework(), + getNode(), + getJobConf() + ); + + if(getDebug()) { + System.err.println("DEBUG: PropertyResolver returned: " + sgenerateInventoryNodesAuth); + } + + if (null != sgenerateInventoryNodesAuth) { + generateInventoryNodesAuth = Boolean.parseBoolean(sgenerateInventoryNodesAuth); + if(getDebug()) { + System.err.println("DEBUG: Parsed to boolean: " + generateInventoryNodesAuth); + } + } else { + if(getDebug()) { + System.err.println("DEBUG: Property not found, returning null"); + } + } + + return generateInventoryNodesAuth; } /** @@ -1068,7 +1080,9 @@ private String getExecutionSpecificTmpDir() { } executionSpecificDir.mkdirs(); } else { - System.err.println("DEBUG: Execution-specific directory already exists: " + executionSpecificDir.getAbsolutePath()); + if (getDebug()) { + System.err.println("DEBUG: Execution-specific directory already exists: " + executionSpecificDir.getAbsolutePath()); + } } return executionSpecificDir.getAbsolutePath(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index eddce682..a5e64e19 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -70,6 +70,8 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes // Project and framework level mappings for inventory generation builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); DESC = builder.build(); } @@ -107,11 +109,31 @@ public void executeStep(PluginStepContext context, Map configura runner = AnsibleRunner.buildAnsibleRunner(contextBuilder); Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); + + boolean isDebug = context.getDataContext().get("job").get("loglevel").equals("DEBUG"); + + if(isDebug) { + System.err.println("DEBUG: generateInventoryNodesAuth returned: " + generateInventoryNodeAuth); + } + if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ + if(isDebug) { + System.err.println("DEBUG: Node auth is enabled, getting authentication map"); + } Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); + if(isDebug) { + System.err.println("DEBUG: Retrieved " + (nodesAuth != null ? nodesAuth.size() : 0) + " node authentications"); + } if (nodesAuth != null && !nodesAuth.isEmpty()) { runner.setAddNodeAuthToInventory(true); runner.setNodesAuthentication(nodesAuth); + if(isDebug) { + System.err.println("DEBUG: Set node authentication on runner"); + } + } + } else { + if(isDebug) { + System.err.println("DEBUG: Node auth is NOT enabled"); } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 796b95c9..596c1734 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -68,6 +68,8 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab // Project and framework level mappings for inventory generation builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); + builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); DESC = builder.build(); } @@ -104,11 +106,31 @@ public void executeStep(PluginStepContext context, Map configura runner = AnsibleRunner.buildAnsibleRunner(contextBuilder); Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); + + boolean isDebug = context.getDataContext().get("job").get("loglevel").equals("DEBUG"); + + if(isDebug) { + System.err.println("DEBUG: generateInventoryNodesAuth returned: " + generateInventoryNodeAuth); + } + if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ + if(isDebug) { + System.err.println("DEBUG: Node auth is enabled, getting authentication map"); + } Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); + if(isDebug) { + System.err.println("DEBUG: Retrieved " + (nodesAuth != null ? nodesAuth.size() : 0) + " node authentications"); + } if (nodesAuth != null && !nodesAuth.isEmpty()) { runner.setAddNodeAuthToInventory(true); runner.setNodesAuthentication(nodesAuth); + if(isDebug) { + System.err.println("DEBUG: Set node authentication on runner"); + } + } + } else { + if(isDebug) { + System.err.println("DEBUG: Node auth is NOT enabled"); } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java index 064092fa..743665f0 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePluginGroup.java @@ -39,22 +39,6 @@ public void setEncryptExtraVars(Boolean encryptExtraVars) { this.encryptExtraVars = encryptExtraVars; } - public Boolean getGenerateInventory() { - return generateInventory; - } - - public void setGenerateInventory(Boolean generateInventory) { - this.generateInventory = generateInventory; - } - - public Boolean getGenerateInventoryNodesAuth() { - return generateInventoryNodesAuth; - } - - public void setGenerateInventoryNodesAuth(Boolean generateInventoryNodesAuth) { - this.generateInventoryNodesAuth = generateInventoryNodesAuth; - } - @PluginProperty( title = "Ansible config file path", description = "Set ansible config file path." @@ -73,18 +57,6 @@ public void setGenerateInventoryNodesAuth(Boolean generateInventoryNodesAuth) { ) Boolean encryptExtraVars; - @PluginProperty( - title = "Generate inventory", - description = "Generate an Ansible inventory from the targeted nodes." - ) - Boolean generateInventory; - - @PluginProperty( - title = "Generate inventory with node authentication", - description = "Add node-specific authentication credentials to the generated inventory via group_vars." - ) - Boolean generateInventoryNodesAuth; - @Override public Description getDescription() { DescriptionBuilder builder = DescriptionBuilder.builder(); From 736f0d69a3ee267f1e9b806def8f94a4d3e0140d Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Wed, 7 Jan 2026 12:55:27 -0300 Subject: [PATCH 12/46] remove flag generate inventory at workflow step level --- .../ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java | 7 ------- .../ansible/plugin/AnsiblePlaybookWorkflowStep.java | 6 ------ 2 files changed, 13 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index a5e64e19..e5e626c0 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -48,7 +48,6 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); - builder.property(GENERATE_INVENTORY_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -67,12 +66,6 @@ public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDes builder.property(BECOME_PASSWORD_STORAGE_PROP); builder.property(DISABLE_LIMIT_PROP); - // Project and framework level mappings for inventory generation - builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - DESC = builder.build(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 596c1734..061dff28 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -46,7 +46,6 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); builder.property(INVENTORY_INLINE_PROP); - builder.property(GENERATE_INVENTORY_PROP); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -65,11 +64,6 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(BECOME_PASSWORD_STORAGE_PROP); builder.property(DISABLE_LIMIT_PROP); - // Project and framework level mappings for inventory generation - builder.mapping(ANSIBLE_GENERATE_INVENTORY, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY); - builder.mapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, PROJ_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - builder.frameworkMapping(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, FWK_PROP_PREFIX + ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); DESC = builder.build(); } From 5ab995440c7933a11b96db1e89794ae3c0f161c8 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Wed, 7 Jan 2026 12:57:53 -0300 Subject: [PATCH 13/46] clean messages --- .../ansible/ansible/AnsibleRunnerContextBuilder.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 4d5bca7b..2c6850f3 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -777,8 +777,8 @@ public void cleanupTempFiles() { for (File temp : tempFiles) { if (!getDebug()) { System.err.println("DEBUG: Deleting temp file: " + temp.getAbsolutePath()); - temp.delete(); } + temp.delete(); } tempFiles.clear(); @@ -787,15 +787,7 @@ public void cleanupTempFiles() { if (getDebug()) { System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); } - if (!getDebug()) { - deleteDirectoryRecursively(executionSpecificDir); - } else { - System.err.println("DEBUG: Skipping cleanup due to debug mode"); - } - } else { - if (getDebug()) { - System.err.println("DEBUG: No execution-specific directory to cleanup"); - } + deleteDirectoryRecursively(executionSpecificDir); } } From 0a77dab9e53a29b809ee228dc982620e927c4d64 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Wed, 7 Jan 2026 19:01:16 -0300 Subject: [PATCH 14/46] fix debug message --- .../plugins/ansible/ansible/AnsibleRunnerContextBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 2c6850f3..83caae7f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -775,7 +775,7 @@ public INodeEntry getNode() { public void cleanupTempFiles() { // Clean up individual temp files for (File temp : tempFiles) { - if (!getDebug()) { + if (getDebug()) { System.err.println("DEBUG: Deleting temp file: " + temp.getAbsolutePath()); } temp.delete(); From 9fe662a33ab6201c8ffc42be733660239e0f148f Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 8 Jan 2026 10:57:44 -0300 Subject: [PATCH 15/46] Added unit tests --- .../ansible/ansible/AnsibleRunner.java | 20 +-- .../ansible/AnsibleRunnerContextBuilder.java | 2 +- .../ansible/ansible/AnsibleRunnerSpec.groovy | 165 ++++++++++++++++++ 3 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 0fb6bc43..16f7beee 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -884,7 +884,7 @@ public boolean registerKeySshAgent(String keyPath) throws Exception { * @param hostUsers Map of host names to usernames * @return YAML content string ready to be written to all.yaml */ - private String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { + String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { StringBuilder yamlContent = new StringBuilder(); yamlContent.append("host_passwords:\n"); @@ -950,13 +950,13 @@ private String buildGroupVarsYaml(Map hostPasswords, Map'\"%@`].*") || + String escapeYamlKey(String key) { + // YAML special characters that require quoting (including backslash) + if (key.matches(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*") || key.startsWith("-") || key.startsWith("?") || key.matches("^[0-9].*")) { - // Escape any existing quotes and wrap in quotes + // Escape any existing backslashes and quotes, then wrap in quotes return "\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } return key; @@ -969,13 +969,13 @@ private String escapeYamlKey(String key) { * @param value The value to escape * @return Escaped value safe for YAML */ - private String escapeYamlValue(String value) { - // Check if value needs quoting - if (value.matches(".*[:\\[\\]{}#&*!|>'\"%@`].*") || + String escapeYamlValue(String value) { + // Check if value needs quoting (including backslash) + if (value.matches(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*") || value.startsWith("-") || value.startsWith("?") || value.trim().isEmpty()) { - // Escape any existing quotes and wrap in quotes + // Escape any existing backslashes and quotes, then wrap in quotes return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } return value; @@ -988,7 +988,7 @@ private String escapeYamlValue(String value) { * @param vaultValue The vault value to validate * @return true if valid vault format, false otherwise */ - private boolean isValidVaultFormat(String vaultValue) { + boolean isValidVaultFormat(String vaultValue) { if (vaultValue == null || vaultValue.trim().isEmpty()) { return false; } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 83caae7f..d640b8ac 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -1042,7 +1042,7 @@ public Boolean generateInventoryNodesAuth() { * * @return The path to the execution-specific directory */ - private String getExecutionSpecificTmpDir() { + String getExecutionSpecificTmpDir() { // Return cached directory if already created if (executionSpecificDir != null) { System.err.println("DEBUG: Using cached execution-specific directory: " + executionSpecificDir.getAbsolutePath()); diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index fd25854d..9ffec5f1 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -597,4 +597,169 @@ class AnsibleRunnerSpec extends Specification{ } + def "escapeYamlKey: should quote keys with special characters"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + runner.escapeYamlKey(key) == expectedResult + + where: + key || expectedResult + "simple-key" || "simple-key" + "key:with:colons" || "\"key:with:colons\"" + "key[brackets]" || "\"key[brackets]\"" + "key{braces}" || "\"key{braces}\"" + "key#hash" || "\"key#hash\"" + "key&ersand" || "\"key&ersand\"" + "key*asterisk" || "\"key*asterisk\"" + "key!exclamation" || "\"key!exclamation\"" + "key|pipe" || "\"key|pipe\"" + "-starts-with-dash" || "\"-starts-with-dash\"" + "?starts-with-q" || "\"?starts-with-q\"" + "123numeric" || "\"123numeric\"" + "key with spaces" || "key with spaces" // spaces are handled differently + } + + def "escapeYamlValue: should quote values with special characters"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + runner.escapeYamlValue(value) == expectedResult + + where: + value || expectedResult + "simple-value" || "simple-value" + "value:colon" || "\"value:colon\"" + "value[bracket]" || "\"value[bracket]\"" + " " || "\" \"" // empty/whitespace should be quoted + "value@at" || "\"value@at\"" + "-starts-dash" || "\"-starts-dash\"" + } + + def "escapeYamlKey: should escape backslashes and quotes"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + runner.escapeYamlKey('key"with"quotes') == '"key\\"with\\"quotes"' + runner.escapeYamlKey('key\\backslash') == '"key\\\\backslash"' + } + + def "isValidVaultFormat: should validate vault format correctly"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + runner.isValidVaultFormat(vaultValue) == expectedResult + + where: + vaultValue || expectedResult + "!vault |\n encryptedcontent" || true + "!vault |\n line1\n line2" || true + "!vault\n content" || true // without pipe + "not a vault" || false + null || false + "" || false + "!vault" || true // minimal valid + "!vault |\n" || false // no content + } + + def "buildGroupVarsYaml: should create valid YAML with host passwords and users"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + def hostPasswords = [ + "web-server-1": "!vault |\n encrypted1", + "db-server-1": "!vault |\n encrypted2" + ] + def hostUsers = [ + "web-server-1": "webadmin", + "db-server-1": "dbadmin" + ] + + when: + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + + then: + yaml.contains("host_passwords:") + yaml.contains("web-server-1: !vault |") + yaml.contains(" encrypted1") + yaml.contains("db-server-1: !vault |") + yaml.contains(" encrypted2") + yaml.contains("host_users:") + yaml.contains("web-server-1: webadmin") + yaml.contains("db-server-1: dbadmin") + } + + def "buildGroupVarsYaml: should escape special characters in node names"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + def hostPasswords = [ + "web:server:1": "!vault |\n encrypted" + ] + def hostUsers = [ + "web:server:1": "admin" + ] + + when: + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + + then: + yaml.contains('"web:server:1": !vault |') + yaml.contains('"web:server:1": admin') + } + + def "buildGroupVarsYaml: should throw exception for invalid vault format"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + def hostPasswords = [ + "web-server-1": "not-a-vault-value" + ] + def hostUsers = [ + "web-server-1": "admin" + ] + + when: + runner.buildGroupVarsYaml(hostPasswords, hostUsers) + + then: + def e = thrown(RuntimeException) + e.message.contains("Invalid vault format for host: web-server-1") + } + + def "buildGroupVarsYaml: should handle empty host lists"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + def hostPasswords = [:] + def hostUsers = [:] + + when: + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + + then: + yaml.contains("host_passwords:") + yaml.contains("host_users:") + } + } From 22d8b74ada07ae5d78b2b4dac4372df4a3ecec86 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Thu, 8 Jan 2026 12:14:45 -0300 Subject: [PATCH 16/46] fix issue with password with especial character --- .../ansible/ansible/AnsibleRunner.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 16f7beee..c9f068cf 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -552,7 +552,11 @@ public int run() throws Exception { } if (sshUsePassword) { - String extraVarsPassword = "ansible_password: " + sshPass; + // Escape special characters in the password to prevent YAML parsing errors + String escapedPassword = escapePasswordForYaml(sshPass); + + // Wrap in quotes to preserve special characters + String extraVarsPassword = "ansible_password: \"" + escapedPassword + "\""; String finalextraVarsPassword = extraVarsPassword; if(useAnsibleVault){ @@ -571,7 +575,11 @@ public int run() throws Exception { procArgs.add("--become"); if (becomePassword != null && !becomePassword.isEmpty()) { - String extraVarsPassword = "ansible_become_password: " + becomePassword; + // Escape special characters in the password to prevent YAML parsing errors + String escapedPassword = escapePasswordForYaml(becomePassword); + + // Wrap in quotes to preserve special characters + String extraVarsPassword = "ansible_become_password: \"" + escapedPassword + "\""; String finalextraVarsPassword = extraVarsPassword; if (useAnsibleVault) { @@ -981,6 +989,22 @@ String escapeYamlValue(String value) { return value; } + /** + * Escapes a password value for safe use in YAML. + * Always wraps the password in quotes and escapes special characters to prevent YAML parsing errors. + * + * @param password The password to escape + * @return Escaped and quoted password safe for YAML + */ + String escapePasswordForYaml(String password) { + // Escape special characters in the password to prevent YAML parsing errors + return password + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + /** * Validates that a string is in proper Ansible vault format. * Expected format: "!vault |\n" followed by vault-encrypted content From d6fd35a6f638e028cb6b014ddd57726d9ed253a6 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 8 Jan 2026 13:01:49 -0300 Subject: [PATCH 17/46] Added unit tests for passwords with special characters --- .../ansible/ansible/AnsibleRunnerSpec.groovy | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index 9ffec5f1..9214adec 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -762,4 +762,244 @@ class AnsibleRunnerSpec extends Specification{ yaml.contains("host_users:") } + def "escapePasswordForYaml: should escape special characters"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + runner.escapePasswordForYaml(password) == expectedResult + + where: + password || expectedResult + "simple" || "simple" + "p@ssword" || "p@ssword" + "pass:word" || "pass:word" + "pass#word" || "pass#word" + 'pass"word' || 'pass\\"word' + 'pass\\word' || 'pass\\\\word' + 'pass\nword' || 'pass\\nword' + 'pass\rword' || 'pass\\rword' + 'pass"\\word' || 'pass\\"\\\\word' + "p@ss:w#rd!" || "p@ss:w#rd!" + '"\\' || '\\"\\\\' + "" || "" + } + + def "escapePasswordForYaml: should handle complex passwords"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + when: + def result = runner.escapePasswordForYaml('My"P@ss\\word\n2024') + + then: + result == 'My\\"P@ss\\\\word\\n2024' + } + + def "ssh password with special characters should be escaped and quoted"() { + given: + String playbook = "test" + String password = 'p@ss"word:123' + + def runnerBuilder = AnsibleRunner.playbookInline(playbook) + runnerBuilder.sshPass(password) + runnerBuilder.sshUser("user") + runnerBuilder.sshUsePassword(true) + + def process = Mock(Process) { + waitFor() >> 0 + getInputStream() >> new ByteArrayInputStream("".getBytes()) + getOutputStream() >> new ByteArrayOutputStream() + getErrorStream() >> new ByteArrayInputStream("".getBytes()) + } + + def processExecutor = Mock(ProcessExecutor) { + run() >> process + } + + List capturedArgs = null + ProcessExecutor.ProcessExecutorBuilder processBuilder = Mock(ProcessExecutor.ProcessExecutorBuilder) + + // Configure fluent builder responses + processBuilder.build() >> processExecutor + processBuilder.procArgs(_ as List) >> { List args -> + capturedArgs = new ArrayList(args) + return processBuilder + } + processBuilder.environmentVariables(_ as Map) >> { Map e -> return processBuilder } + processBuilder.baseDirectory(_ as File) >> { File f -> return processBuilder } + processBuilder.stdinVariables(_ as List) >> { List v -> return processBuilder } + processBuilder.promptStdinLogFile(_ as File) >> { File f -> return processBuilder } + processBuilder.debug(_ as boolean) >> { boolean d -> return processBuilder } + + def ansibleVault = Mock(AnsibleVault) { + checkAnsibleVault() >> true + getVaultPasswordScriptFile() >> new File("vault-script-client.py") + } + + runnerBuilder.processExecutorBuilder(processBuilder) + runnerBuilder.ansibleVault(ansibleVault) + + when: + AnsibleRunner runner = runnerBuilder.build() + runner.setCustomTmpDirPath("/tmp") + def result = runner.run() + + then: + result == 0 + 1 * ansibleVault.encryptVariable(_, _) >> "!vault | value" + capturedArgs != null + // Flatten and verify --extra-vars argument exists + def flatArgs = capturedArgs.flatten().collect { it?.toString() } + flatArgs.any { it.contains("--extra-vars") } + } + + def "become password with special characters should be escaped and quoted"() { + given: + String playbook = "test" + String becomePass = 'admin"pass\\123' + + def runnerBuilder = AnsibleRunner.playbookInline(playbook) + runnerBuilder.become(true) + runnerBuilder.becomeUser("root") + runnerBuilder.becomePassword(becomePass) + runnerBuilder.becomeMethod("sudo") + + def process = Mock(Process) { + waitFor() >> 0 + getInputStream() >> new ByteArrayInputStream("".getBytes()) + getOutputStream() >> new ByteArrayOutputStream() + getErrorStream() >> new ByteArrayInputStream("".getBytes()) + } + + def processExecutor = Mock(ProcessExecutor) { + run() >> process + } + + List capturedArgs = null + ProcessExecutor.ProcessExecutorBuilder processBuilder = Mock(ProcessExecutor.ProcessExecutorBuilder) + + // Configure fluent builder responses + processBuilder.build() >> processExecutor + processBuilder.procArgs(_ as List) >> { List args -> + capturedArgs = new ArrayList(args) + return processBuilder + } + processBuilder.environmentVariables(_ as Map) >> { Map e -> return processBuilder } + processBuilder.baseDirectory(_ as File) >> { File f -> return processBuilder } + processBuilder.stdinVariables(_ as List) >> { List v -> return processBuilder } + processBuilder.promptStdinLogFile(_ as File) >> { File f -> return processBuilder } + processBuilder.debug(_ as boolean) >> { boolean d -> return processBuilder } + + def ansibleVault = Mock(AnsibleVault) { + checkAnsibleVault() >> true + getVaultPasswordScriptFile() >> new File("vault-script-client.py") + } + + runnerBuilder.processExecutorBuilder(processBuilder) + runnerBuilder.ansibleVault(ansibleVault) + + when: + AnsibleRunner runner = runnerBuilder.build() + runner.setCustomTmpDirPath("/tmp") + def result = runner.run() + + then: + result == 0 + 1 * ansibleVault.encryptVariable(_, _) >> "!vault | value" + capturedArgs != null + // Flatten and verify arguments + def flatArgs = capturedArgs.flatten().collect { it?.toString() } + flatArgs.contains("--become") + flatArgs.any { it.contains("--extra-vars") } + } + + def "escapePasswordForYaml: should preserve passwords without special chars"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + when: + def result = runner.escapePasswordForYaml("simplePassword123") + + then: + result == "simplePassword123" + } + + def "escapePasswordForYaml: should handle multiple escape sequences"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + when: + // Password with quote, backslash, and newline + def result = runner.escapePasswordForYaml('test"\\pass\n') + + then: + // Each special char should be escaped + result == 'test\\"\\\\pass\\n' + } + + def "both ssh and become passwords with special chars should work together"() { + given: + String playbook = "test" + String sshPass = 'ssh"pass:123' + String becomePass = 'sudo\\pass#456' + + def runnerBuilder = AnsibleRunner.playbookInline(playbook) + runnerBuilder.sshPass(sshPass) + runnerBuilder.sshUser("user") + runnerBuilder.sshUsePassword(true) + runnerBuilder.become(true) + runnerBuilder.becomeUser("root") + runnerBuilder.becomePassword(becomePass) + runnerBuilder.becomeMethod("sudo") + + def process = Mock(Process) { + waitFor() >> 0 + getInputStream() >> new ByteArrayInputStream("".getBytes()) + getOutputStream() >> new ByteArrayOutputStream() + getErrorStream() >> new ByteArrayInputStream("".getBytes()) + } + + def processExecutor = Mock(ProcessExecutor) { + run() >> process + } + + ProcessExecutor.ProcessExecutorBuilder processBuilder = Mock(ProcessExecutor.ProcessExecutorBuilder) + + // Configure fluent builder responses + processBuilder.build() >> processExecutor + processBuilder.procArgs(_ as List) >> { List args -> return processBuilder } + processBuilder.environmentVariables(_ as Map) >> { Map e -> return processBuilder } + processBuilder.baseDirectory(_ as File) >> { File f -> return processBuilder } + processBuilder.stdinVariables(_ as List) >> { List v -> return processBuilder } + processBuilder.promptStdinLogFile(_ as File) >> { File f -> return processBuilder } + processBuilder.debug(_ as boolean) >> { boolean d -> return processBuilder } + + def ansibleVault = Mock(AnsibleVault) { + checkAnsibleVault() >> true + getVaultPasswordScriptFile() >> new File("vault-script-client.py") + } + + runnerBuilder.processExecutorBuilder(processBuilder) + runnerBuilder.ansibleVault(ansibleVault) + + when: + AnsibleRunner runner = runnerBuilder.build() + runner.setCustomTmpDirPath("/tmp") + def result = runner.run() + + then: + result == 0 + // Both passwords should be encrypted + 2 * ansibleVault.encryptVariable(_, _) >> "!vault | value" + } + } From 3298d3bd4f7c4c4abfd00069dea9adda851bd9e4 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Thu, 8 Jan 2026 13:37:03 -0300 Subject: [PATCH 18/46] clean AnsiblePlaybookInlineWorkflowNodeStep --- .../plugin/AnsiblePlaybookInlineWorkflowNodeStep.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java index 5827ccc6..a9a1d40c 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java @@ -43,8 +43,6 @@ public class AnsiblePlaybookInlineWorkflowNodeStep implements NodeStepPlugin, An builder.property(PLAYBOOK_INLINE_PROP); builder.property(EXTRA_VARS_PROP); builder.property(CONFIG_ENCRYPT_EXTRA_VARS); - builder.property(GENERATE_INVENTORY_PROP); - builder.property(GENERATE_INVENTORY_NODES_AUTH); builder.property(VAULT_KEY_FILE_PROP); builder.property(VAULT_KEY_STORAGE_PROP); builder.property(EXTRA_ATTRS_PROP); @@ -123,13 +121,7 @@ public void executeNodeStep( @Override public List listSecretsPathWorkflowNodeStep(ExecutionContext context, INodeEntry node, Map configuration) { AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(node, context, context.getFramework(), configuration); - List secretPaths = AnsibleUtil.getSecretsPath(builder); - List secretPathsNodes = builder.getListNodesKeyPath(); - - if(secretPathsNodes != null && !secretPathsNodes.isEmpty()){ - secretPaths.addAll(secretPathsNodes); - } - return secretPaths; + return AnsibleUtil.getSecretsPath(builder); } @Override From b514ed2287c997e549de4c012764ec806195c18f Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 8 Jan 2026 23:13:48 -0300 Subject: [PATCH 19/46] Added Multiple node auth functional tests, added missing README files --- functional-test/README.md | 181 ++++++++++++++++++ functional-test/build.gradle | 9 +- .../functional/MultiNodeAuthSpec.groovy | 173 +++++++++++++++++ .../docker/ansible-multi-node-auth/README.md | 57 ++++++ .../ansible-multi-node-auth/ansible.cfg | 8 + .../ansible-multi-node-auth/inventory.ini | 7 + .../ansible-multi-node-auth/resources.xml | 41 ++++ .../ansible-multi-node-auth/test-playbook.yml | 39 ++++ .../test/resources/docker/docker-compose.yml | 25 +++ .../files/acls/node-acl.aclpolicy | 16 ++ .../files/etc/project.properties | 28 +++ .../jobs/job-ansible-playbook-test.xml | 35 ++++ .../jobs/job-multi-node-ping.xml | 36 ++++ 13 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 functional-test/README.md create mode 100644 functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy create mode 100644 functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md create mode 100644 functional-test/src/test/resources/docker/ansible-multi-node-auth/ansible.cfg create mode 100644 functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini create mode 100644 functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml create mode 100644 functional-test/src/test/resources/docker/ansible-multi-node-auth/test-playbook.yml create mode 100644 functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/acls/node-acl.aclpolicy create mode 100644 functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/etc/project.properties create mode 100644 functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-ansible-playbook-test.xml create mode 100644 functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml diff --git a/functional-test/README.md b/functional-test/README.md new file mode 100644 index 00000000..8cdb771c --- /dev/null +++ b/functional-test/README.md @@ -0,0 +1,181 @@ +# Functional Tests for Ansible Plugin + +This directory contains functional tests for the Rundeck Ansible plugin using Testcontainers and Docker. + +## Prerequisites + +- Docker (Docker Desktop or Rancher Desktop) +- Java 11 or later +- Gradle 7.2 or later + +## Docker Configuration + +The functional tests use Testcontainers to spin up Docker containers for Rundeck and SSH nodes. Depending on your Docker setup, you may need to configure the Docker socket path. + +### Option 1: Using ~/.testcontainers.properties (Recommended) + +Create a file at `~/.testcontainers.properties` with the following content: + +**For Rancher Desktop on macOS:** +```properties +docker.host=unix:///Users//.rd/docker.sock +``` + +**For Docker Desktop on macOS/Linux:** +```properties +docker.host=unix:///var/run/docker.sock +``` + +**For Windows with Docker Desktop:** +```properties +docker.host=tcp://localhost:2375 +``` + +### Option 2: Modify build.gradle + +Edit `functional-test/build.gradle` and update the `docker.host` paths on lines 52-53: + +```gradle +systemProperty('docker.host', "unix:///path/to/your/docker.sock") +environment 'DOCKER_HOST', 'unix:///path/to/your/docker.sock' +``` + +## Running the Tests + +### Run All Functional Tests + +```bash +./gradlew :functional-test:functionalTest +``` + +### Run Specific Test Suite + +```bash +# Multi-node authentication tests +./gradlew :functional-test:functionalTest --tests "*MultiNodeAuthSpec*" + +# Basic integration tests +./gradlew :functional-test:functionalTest --tests "*BasicIntegrationSpec*" + +# Plugin group tests +./gradlew :functional-test:functionalTest --tests "*PluginGroupIntegrationSpec*" +``` + +## Test Suites + +### MultiNodeAuthSpec +Tests the multi-node authentication feature where each node can have its own password stored in Rundeck's key storage. + +**Tests:** +- `test ansible playbook with multi-node authentication` - Verifies Ansible playbooks execute across multiple nodes with per-node credentials +- `test multi-node authentication with different passwords` - Tests script execution on multiple nodes with different passwords +- `test nodes are accessible with different credentials` - Validates node registration and discovery +- `test passwords with special characters are properly escaped` - Verifies YAML escaping for special characters in passwords + +**Test Environment:** +- 3 SSH nodes (ssh-node, ssh-node-2, ssh-node-3) +- Each node has a different password, including special characters +- Tests both WorkflowStep (Ansible playbooks) and NodeStep (scripts) execution + +### BasicIntegrationSpec +Basic integration tests for the Ansible plugin functionality. + +### PluginGroupIntegrationSpec +Tests for plugin group configuration and execution. + +## Test Structure + +``` +functional-test/ +├── build.gradle # Test configuration +├── README.md # This file +└── src/ + └── test/ + ├── groovy/functional/ # Test specifications + │ ├── MultiNodeAuthSpec.groovy + │ ├── BasicIntegrationSpec.groovy + │ └── PluginGroupIntegrationSpec.groovy + └── resources/ + ├── docker/ # Docker compose and configs + │ ├── docker-compose.yml + │ ├── ansible-multi-node-auth/ # Multi-node test configs + │ ├── keys/ # SSH keys for tests + │ ├── node/ # SSH node Docker configs + │ └── rundeck/ # Rundeck Docker configs + └── project-import/ # Rundeck project definitions + └── ansible-multi-node-auth/ + └── rundeck-ansible-multi-node-auth/ + ├── files/etc/project.properties + └── jobs/*.xml +``` + +## Troubleshooting + +### Tests Fail with "Could not find Docker socket" + +**Problem:** Testcontainers cannot locate the Docker socket. + +**Solution:** +1. Verify Docker is running: `docker ps` +2. Check your Docker socket path: + - Rancher Desktop: `ls -la ~/.rd/docker.sock` + - Docker Desktop: `ls -la /var/run/docker.sock` +3. Update `~/.testcontainers.properties` or `build.gradle` with the correct path + +### Tests Timeout or Hang + +**Problem:** Tests take too long or appear to hang. + +**Solution:** +1. Check Docker container status: `docker ps -a` +2. Check Docker logs: `docker logs ` +3. Increase test timeout in build.gradle if needed +4. Ensure sufficient Docker resources (memory/CPU) + +### Port Conflicts + +**Problem:** Tests fail with "port already in use" errors. + +**Solution:** +1. Check for running containers: `docker ps` +2. Stop conflicting containers: `docker stop ` +3. Clean up: `docker-compose down` in the docker directory + +### Platform Mismatch Warnings (Apple Silicon) + +**Problem:** Warnings about platform mismatch (linux/amd64 vs linux/arm64). + +**Solution:** These warnings are expected on Apple Silicon Macs and can be safely ignored. Docker will use Rosetta 2 for emulation. + +## Test Reports + +After running tests, view the HTML report at: +``` +functional-test/build/reports/tests/functionalTest/index.html +``` + +## Adding New Tests + +1. Create a new Spock specification in `src/test/groovy/functional/` +2. Extend `BaseTestConfiguration` for common test utilities +3. Add any required Docker configs to `src/test/resources/docker/` +4. Add project imports to `src/test/resources/project-import/` +5. Follow existing test patterns for consistency + +## Multi-Node Authentication Feature + +The multi-node authentication feature allows running Ansible playbooks across multiple nodes where each node has its own password stored in Rundeck's key storage. + +**How it works:** +1. Enable at project level: `project.ansible-generate-inventory-nodes-auth=true` +2. Store per-node passwords in Key Storage with paths specified in node attributes +3. Plugin generates `group_vars/all.yaml` with vault-encrypted passwords +4. Ansible uses host-specific credentials from group_vars + +**Requirements:** +- Must use Ansible Playbook **Workflow Steps** (not Node Steps) +- Node attributes must include `ansible-ssh-password-storage-path` for each node +- Passwords are automatically encrypted using Ansible Vault +- Supports special characters with proper YAML escaping + +See `MultiNodeAuthSpec.groovy` for comprehensive test examples. diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 681b7af5..188c4e88 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -42,7 +42,14 @@ dependencies { tasks.register('functionalTest', Test) { useJUnitPlatform() - systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.1.1") + systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") + + // Docker configuration for Testcontainers + // For Rancher Desktop on macOS: Use /Users//.rd/docker.sock + // For Docker Desktop on macOS: Use /var/run/docker.sock + // For Linux: Use /var/run/docker.sock + // You can also configure this via ~/.testcontainers.properties (see functional-test/README.md) + description = "Run Ansible integration tests" } diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy new file mode 100644 index 00000000..94c0d181 --- /dev/null +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -0,0 +1,173 @@ +package functional + +import functional.base.BaseTestConfiguration +import functional.util.TestUtil +import org.rundeck.client.api.model.JobRun +import org.testcontainers.spock.Testcontainers + +@Testcontainers +class MultiNodeAuthSpec extends BaseTestConfiguration { + + static String PROJ_NAME = 'ansible-multi-node-auth' + static String NODE1_NAME = "ssh-node" + static String NODE2_NAME = "ssh-node-2" + static String NODE3_NAME = "ssh-node-3" + + // Test passwords for each node (matching docker-compose.yml) + static String NODE1_PASSWORD = "testpassword123" + static String NODE2_PASSWORD = "password2_special!@#" + static String NODE3_PASSWORD = 'password3"quote\'test' + + def setupSpec() { + startCompose() + configureMultiNodeAuthProject() + } + + def configureMultiNodeAuthProject() { + // Store passwords for each node in Rundeck key storage + okhttp3.RequestBody requestBody = okhttp3.RequestBody.create( + NODE1_PASSWORD.getBytes(), + org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD + ) + client.apiCall { api -> + api.createKeyStorage("project/$PROJ_NAME/ssh-node.pass", requestBody) + } + + requestBody = okhttp3.RequestBody.create( + NODE2_PASSWORD.getBytes(), + org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD + ) + client.apiCall { api -> + api.createKeyStorage("project/$PROJ_NAME/ssh-node-2.pass", requestBody) + } + + requestBody = okhttp3.RequestBody.create( + NODE3_PASSWORD.getBytes(), + org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD + ) + client.apiCall { api -> + api.createKeyStorage("project/$PROJ_NAME/ssh-node-3.pass", requestBody) + } + + // Create project + def projList = client.apiCall { api -> api.listProjects() } + if (!projList*.name.contains(PROJ_NAME)) { + client.apiCall { api -> + api.createProject(new org.rundeck.client.api.model.ProjectItem(name: PROJ_NAME)) + } + } + + // Import project configuration + File projectFile = TestUtil.createArchiveJarFile( + PROJ_NAME, + new File("src/test/resources/project-import/$PROJ_NAME") + ) + okhttp3.RequestBody body = okhttp3.RequestBody.create( + projectFile, + org.rundeck.client.util.Client.MEDIA_TYPE_ZIP + ) + client.apiCall { api -> + api.importProjectArchive( + PROJ_NAME, + "preserve", + true, true, true, true, true, true, true, + [:], + body + ) + } + + // Wait for nodes to be available + waitForNodeAvailability(PROJ_NAME, NODE1_NAME) + waitForNodeAvailability(PROJ_NAME, NODE2_NAME) + waitForNodeAvailability(PROJ_NAME, NODE3_NAME) + } + + def "test multi-node authentication with different passwords"() { + when: + def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" // multi-node-ping-test job + + JobRun request = new JobRun() + request.loglevel = 'INFO' + + def result = client.apiCall { api -> api.runJob(jobId, request) } + def executionId = result.id + + def executionState = waitForJob(executionId) + def logs = getLogs(executionId) + + then: + // Job succeeded - this verifies that multi-node authentication worked + executionState != null + executionState.getExecutionState() == "SUCCEEDED" + + // Verify all three nodes were in the execution context + executionState.targetNodes.contains("ssh-node") + executionState.targetNodes.contains("ssh-node-2") + executionState.targetNodes.contains("ssh-node-3") + } + + def "test ansible playbook with multi-node authentication"() { + when: + def jobId = "b2c3d4e5-f6a7-8901-bcde-f12345678901" // ansible-playbook-multi-node-test job + + JobRun request = new JobRun() + request.loglevel = 'DEBUG' + + def result = client.apiCall { api -> api.runJob(jobId, request) } + def executionId = result.id + + def executionState = waitForJob(executionId) + def logs = getLogs(executionId) + + then: + // Job succeeded - this verifies the playbook ran successfully with multi-node authentication + executionState != null + executionState.getExecutionState() == "SUCCEEDED" + + // Verify all three nodes were targeted by the playbook execution + executionState.targetNodes.contains("ssh-node") + executionState.targetNodes.contains("ssh-node-2") + executionState.targetNodes.contains("ssh-node-3") + } + + def "test nodes are accessible with different credentials"() { + when: + // List all nodes in the project + def nodes = client.apiCall { api -> api.listNodes(PROJ_NAME, ".*") } + + then: + // Verify all three nodes are registered + nodes != null + nodes.size() >= 3 + nodes.get(NODE1_NAME) != null + nodes.get(NODE2_NAME) != null + nodes.get(NODE3_NAME) != null + } + + def "test passwords with special characters are properly escaped"() { + when: + def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + + JobRun request = new JobRun() + request.loglevel = 'DEBUG' + request.filter = "name: ssh-node-2 || name: ssh-node-3" // Only test nodes with special chars in passwords + + def result = client.apiCall { api -> api.runJob(jobId, request) } + def executionId = result.id + + def executionState = waitForJob(executionId) + def logs = getLogs(executionId) + + then: + // Job succeeded even with special characters in passwords - verifies password escaping works + executionState != null + executionState.getExecutionState() == "SUCCEEDED" + + // Verify nodes with special character passwords were targeted + executionState.targetNodes.contains("ssh-node-2") + executionState.targetNodes.contains("ssh-node-3") + + // No YAML parsing errors - verifies special characters were properly escaped + !logs.any { it.log.toLowerCase().contains("yaml") && it.log.toLowerCase().contains("error") } + } +} diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md new file mode 100644 index 00000000..384af8bf --- /dev/null +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md @@ -0,0 +1,57 @@ +# Multi-Node Authentication Functional Test + +This test verifies the multi-node authentication feature where each node can have its own password stored in Rundeck's key storage. + +## Test Setup + +### Nodes +- **ssh-node**: Standard password (`testpassword123`) +- **ssh-node-2**: Password with special characters (`password2_special!@#`) +- **ssh-node-3**: Password with quotes (`password3"quote'test`) + +### Files +- `resources.xml`: Defines three nodes with node-specific password storage paths +- `inventory.ini`: Ansible inventory file for the test nodes +- `ansible.cfg`: Ansible configuration +- `test-playbook.yml`: Test playbook for verification + +## What's Being Tested + +1. **Multi-node authentication with different passwords per node** + - Each node has a different password stored in Rundeck key storage + - The plugin generates `group_vars/all.yaml` with vault-encrypted passwords + - Each node authenticates with its specific credentials + +2. **Password escaping for special characters** + - Node 2 has special characters: `!`, `@`, `#` + - Node 3 has quotes: `"` and `'` + - Tests verify YAML escaping works correctly + +3. **Project-level configuration** + - `project.ansible-generate-inventory-nodes-auth=true` enables the feature + - Configuration is at project level, not workflow step level + +## Jobs + +- **multi-node-ping-test**: Simple script that runs on all nodes +- **ansible-playbook-multi-node-test**: Ansible playbook that verifies authentication on each node + +## Expected Behavior + +1. Plugin reads node attributes to get password storage paths for each node +2. Plugin retrieves passwords from Rundeck key storage +3. Plugin escapes passwords to prevent YAML parsing errors +4. Plugin encrypts passwords using Ansible Vault +5. Plugin generates `group_vars/all.yaml` with: + ```yaml + host_passwords: + ssh-node: !vault | ... + ssh-node-2: !vault | ... + ssh-node-3: !vault | ... + host_users: + ssh-node: rundeck + ssh-node-2: rundeck + ssh-node-3: rundeck + ``` +6. Ansible uses host-specific credentials from group_vars +7. Each node authenticates successfully with its own password diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/ansible.cfg b/functional-test/src/test/resources/docker/ansible-multi-node-auth/ansible.cfg new file mode 100644 index 00000000..7d115df1 --- /dev/null +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/ansible.cfg @@ -0,0 +1,8 @@ +[defaults] +host_key_checking = False +timeout = 30 +gathering = smart +fact_caching = jsonfile +fact_caching_connection = /tmp/ansible_facts +fact_caching_timeout = 3600 +stdout_callback = yaml diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini b/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini new file mode 100644 index 00000000..422d1a9a --- /dev/null +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini @@ -0,0 +1,7 @@ +[test_nodes] +ssh-node ansible_host=ssh-node ansible_port=22 +ssh-node-2 ansible_host=ssh-node-2 ansible_port=22 +ssh-node-3 ansible_host=ssh-node-3 ansible_port=22 + +[test_nodes:vars] +ansible_connection=ssh diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml b/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml new file mode 100644 index 00000000..c38837c4 --- /dev/null +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/test-playbook.yml b/functional-test/src/test/resources/docker/ansible-multi-node-auth/test-playbook.yml new file mode 100644 index 00000000..c8b26fc7 --- /dev/null +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/test-playbook.yml @@ -0,0 +1,39 @@ +--- +- name: Multi-Node Authentication Test Playbook + hosts: all + gather_facts: yes + tasks: + - name: Ping all nodes + ping: + + - name: Get hostname + command: hostname + register: node_hostname + + - name: Display connection info + debug: + msg: "Successfully connected to {{ node_hostname.stdout }} as {{ ansible_user_id }}" + + - name: Create test file + file: + path: "/tmp/multi_node_test_{{ inventory_hostname }}.txt" + state: touch + mode: '0644' + + - name: Write test content + copy: + content: "Multi-node authentication test passed on {{ inventory_hostname }}\n" + dest: "/tmp/multi_node_test_{{ inventory_hostname }}.txt" + + - name: Verify file exists + stat: + path: "/tmp/multi_node_test_{{ inventory_hostname }}.txt" + register: test_file_stat + + - name: Assert test file was created + assert: + that: + - test_file_stat.stat.exists + - test_file_stat.stat.isreg + success_msg: "Test file created successfully on {{ inventory_hostname }}" + fail_msg: "Failed to create test file on {{ inventory_hostname }}" diff --git a/functional-test/src/test/resources/docker/docker-compose.yml b/functional-test/src/test/resources/docker/docker-compose.yml index fedf4ce7..1ab6722f 100644 --- a/functional-test/src/test/resources/docker/docker-compose.yml +++ b/functional-test/src/test/resources/docker/docker-compose.yml @@ -13,6 +13,30 @@ services: volumes: - ./keys:/configuration:rw + ssh-node-2: + build: + context: node + environment: + NODE_USER_PASSWORD: password2_special!@# + networks: + - rundeck + ports: + - "2223:22" + volumes: + - ./keys:/configuration:rw + + ssh-node-3: + build: + context: node + environment: + NODE_USER_PASSWORD: password3"quote'test + networks: + - rundeck + ports: + - "2224:22" + volumes: + - ./keys:/configuration:rw + rundeck: build: context: rundeck @@ -37,6 +61,7 @@ services: - ./ansible-yaml-parsing:/home/rundeck/ansible-yaml-parsing:rw - ./ansible-child-groups:/home/rundeck/ansible-child-groups:rw - ./ansible-max-aliases:/home/rundeck/ansible-max-aliases:rw + - ./ansible-multi-node-auth:/home/rundeck/ansible-multi-node-auth:rw volumes: rundeck-data: diff --git a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/acls/node-acl.aclpolicy b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/acls/node-acl.aclpolicy new file mode 100644 index 00000000..58f944e1 --- /dev/null +++ b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/acls/node-acl.aclpolicy @@ -0,0 +1,16 @@ +description: Node access for project +context: + project: '.*' +for: + resource: + - equals: + kind: node + allow: [read,create,update,refresh] + adhoc: + - allow: [read,run,runAs,kill,killAs] + job: + - allow: [create,read,update,delete,run,runAs,kill,killAs] + node: + - allow: [read,run] +by: + username: admin diff --git a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/etc/project.properties b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/etc/project.properties new file mode 100644 index 00000000..95badb25 --- /dev/null +++ b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/files/etc/project.properties @@ -0,0 +1,28 @@ +#Multi-Node Authentication Test Project +project.name=ansible-multi-node-auth +project.description=Test project for multi-node authentication with per-node credentials +project.disable.executions=false +project.disable.schedule=false +project.execution.history.cleanup.enabled=false +project.jobs.gui.groupExpandLevel=1 +project.nodeCache.enabled=true +project.nodeCache.firstLoadSynch=true +project.output.allowUnsanitized=false + +# Enable multi-node authentication at project level +project.ansible-generate-inventory-nodes-auth=true + +# Ansible configuration +project.ansible-config-file-path=/home/rundeck/ansible-multi-node-auth/ansible.cfg +project.ansible-executable=/bin/bash + +# Resource source configuration - use file-based resources with node-specific attributes +resources.source.1.config.file=/home/rundeck/ansible-multi-node-auth/resources.xml +resources.source.1.config.generateFileAutomatically=false +resources.source.1.config.includeServerNode=false +resources.source.1.config.requireFileExists=true +resources.source.1.type=file + +# Default node executor and file copier +service.FileCopier.default.provider=com.batix.rundeck.plugins.AnsibleFileCopier +service.NodeExecutor.default.provider=com.batix.rundeck.plugins.AnsibleNodeExecutor diff --git a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-ansible-playbook-test.xml b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-ansible-playbook-test.xml new file mode 100644 index 00000000..d065e6ac --- /dev/null +++ b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-ansible-playbook-test.xml @@ -0,0 +1,35 @@ + + + nodes + Test multi-node authentication using Ansible playbook + + true + false + ascending + false + 1 + + true + b2c3d4e5-f6a7-8901-bcde-f12345678901 + INFO + ansible-playbook-multi-node-test + false + + .* + + true + + true + + + + + + + + + + + b2c3d4e5-f6a7-8901-bcde-f12345678901 + + diff --git a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml new file mode 100644 index 00000000..3a159b54 --- /dev/null +++ b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml @@ -0,0 +1,36 @@ + + + nodes + Test multi-node authentication with different passwords + + true + true + ascending + false + 3 + + true + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + INFO + multi-node-ping-test + false + + name: ssh-node.* + + true + + true + + + + + + + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + From 24d84ccbf5049c2deea79eff3036370e4be1dba8 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 9 Jan 2026 18:06:06 -0300 Subject: [PATCH 20/46] remove unnecesary logs --- .../plugins/ansible/ansible/AnsibleRunner.java | 5 +---- .../ansible/ansible/AnsibleRunnerContextBuilder.java | 12 +++++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index c9f068cf..f395f67e 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -942,10 +942,7 @@ String buildGroupVarsYaml(Map hostPasswords, Map String result = yamlContent.toString(); if(debug){ - System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes):"); - System.err.println("DEBUG: ========== YAML START =========="); - System.err.println(result); - System.err.println("DEBUG: ========== YAML END =========="); + System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes)"); } return result; diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index d640b8ac..4978fbf8 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -953,10 +953,14 @@ public Map> getNodesAuthenticationMap(){ String password = getPasswordFromPath(keyPath); auth.put("ansible_password", password); } catch (ConfigurationException e) { - System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); + if (getDebug()) { + System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); + } throw new RuntimeException(e); } catch (Exception e2) { - System.err.println("DEBUG: Unexpected error: " + e2.getMessage()); + if (getDebug()) { + System.err.println("DEBUG: Unexpected error: " + e2.getMessage()); + } throw new RuntimeException(e2); } @@ -1045,7 +1049,9 @@ public Boolean generateInventoryNodesAuth() { String getExecutionSpecificTmpDir() { // Return cached directory if already created if (executionSpecificDir != null) { - System.err.println("DEBUG: Using cached execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + if (getDebug()) { + System.err.println("DEBUG: Using cached execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + } return executionSpecificDir.getAbsolutePath(); } From 35ffd42b111e396b1a7296ee18a533867b6198a6 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Tue, 13 Jan 2026 11:05:28 -0300 Subject: [PATCH 21/46] improve workflow step node authentication type --- .../ansible/ansible/AnsibleRunner.java | 46 ++++++++++++- .../ansible/AnsibleRunnerContextBuilder.java | 66 ++++++++++++------- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index f395f67e..87bd2419 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -411,11 +411,32 @@ public int run() throws Exception { if(addNodeAuthToInventory != null && addNodeAuthToInventory && nodesAuthentication != null && !nodesAuthentication.isEmpty()) { + /* + Group vars file structure: + + host_users: + host1: user1 + host2: user2 + + host_passwords: + host1: enc(password1) + host2: enc(password1) + + host_private_keys: + host1: /path/to/private_key1 + host2: /path/to/private_key2 + */ + Map hostUsers = new LinkedHashMap<>(); Map hostPasswords = new LinkedHashMap<>(); + Map hostKeys = new LinkedHashMap<>(); + nodesAuthentication.forEach((nodeName, authValues) -> { String user = authValues.get("ansible_user"); String password = authValues.get("ansible_password"); + String privateKey = authValues.get("ansible_ssh_private_key"); + + if (user != null) { hostUsers.put(nodeName, user); } @@ -430,6 +451,26 @@ public int run() throws Exception { } hostPasswords.put(nodeName, encryptedPassword); } + + if(privateKey != null){ + //create temporary file for private key + File tempHostPkFile; + try { + tempHostPkFile = AnsibleUtil.createTemporaryFile("","id_rsa_node_"+nodeName, privateKey,customTmpDirPath); + + // Only the owner can read and write + Set perms = new HashSet(); + perms.add(PosixFilePermission.OWNER_READ); + perms.add(PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(tempHostPkFile.toPath(), perms); + + hostKeys.put(nodeName, tempHostPkFile.getAbsolutePath()); + + tempHostPkFile.deleteOnExit(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } }); // Build YAML content using helper method @@ -490,7 +531,10 @@ public int run() throws Exception { //set extra vars to resolve the host specific authentication procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); - procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); + if(!hostPasswords.isEmpty()){ + procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); + } + procArgs.add("-e ansible_ssh_private_key_file=\"{{ host_private_keys[inventory_hostname] }}\""); } catch (IOException e) { System.err.println("ERROR: Failed to write all.yaml: " + e.getMessage()); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 4978fbf8..1a03c6e6 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -101,12 +101,16 @@ public String getPrivateKeyfilePath() { } public String getPrivateKeyStoragePath() { + return getPrivateKeyStoragePath(getNode()); + } + + public String getPrivateKeyStoragePath(INodeEntry node) { String path = PropertyResolver.resolveProperty( AnsibleDescribable.ANSIBLE_SSH_KEYPATH_STORAGE_PATH, null, getFrameworkProject(), getFramework(), - getNode(), + node, getJobConf() ); //expand properties in path @@ -139,9 +143,9 @@ public String getPasswordStoragePath() { return path; } - public String getSshPrivateKey() throws ConfigurationException { + public String getSshPrivateKey(node) throws ConfigurationException { //look for storage option - String storagePath = getPrivateKeyStoragePath(); + String storagePath = getPrivateKeyStoragePath(node); if (null != storagePath) { Path path = PathUtil.asPath(storagePath); @@ -266,12 +270,17 @@ public String getSshNodeUser(INodeEntry node) { public AuthenticationType getSshAuthenticationType() { + return getSshAuthenticationType(getNode()); + } + + + public AuthenticationType getSshAuthenticationType(INodeEntry node) { String authType = PropertyResolver.resolveProperty( AnsibleDescribable.ANSIBLE_SSH_AUTH_TYPE, null, getFrameworkProject(), getFramework(), - getNode(), + node, getJobConf() ); @@ -932,39 +941,46 @@ public Map getListOptions(){ return options; } + /* + Returns a map of node names to their respective authentication details. + Each entry in the outer map corresponds to a node, with the key being the node name + and the value being another map containing authentication parameters such as + + "ansible_ssh_private_key" or "ansible_password". + [ "node1": { "ansible_ssh_private_key": "...", "ansible_user": "..." }, + "node2": { "ansible_password": "...", "ansible_user": "..." } + ] + + */ public Map> getNodesAuthenticationMap(){ Map> authenticationNodesMap = new HashMap<>(); this.context.getNodes().forEach((node) -> { - String keyPath = PropertyResolver.resolveProperty( - AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, - null, - getFrameworkProject(), - getFramework(), - node, - getJobConf() - ); - Map auth = new HashMap<>(); - - if(null!=keyPath){ + final AuthenticationType authType = getSshAuthenticationType(node); + if (AnsibleDescribable.AuthenticationType.privateKey == authType) { + final String privateKey; try { - String password = getPasswordFromPath(keyPath); - auth.put("ansible_password", password); + privateKey = getSshPrivateKey(node); } catch (ConfigurationException e) { - if (getDebug()) { - System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); - } throw new RuntimeException(e); - } catch (Exception e2) { - if (getDebug()) { - System.err.println("DEBUG: Unexpected error: " + e2.getMessage()); + } + if (privateKey != null) { + auth.put("ansible_ssh_private_key", privateKey); + } + } else if (AnsibleDescribable.AuthenticationType.password == authType) { + try { + String password = getSshPassword(node); + if(null!=password){ + auth.put("ansible_password", password); } - throw new RuntimeException(e2); + } catch (ConfigurationException e) { + System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); + throw new RuntimeException(e); } - } + String userName = getSshNodeUser(node); if(null!=userName){ From 80a329678c0681e4df40502c8c33963d25558399 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Tue, 13 Jan 2026 22:54:46 -0300 Subject: [PATCH 22/46] Refactored code, added private key and functoinal test --- functional-test/build.gradle | 2 +- .../functional/MultiNodeAuthSpec.groovy | 163 ++++++++++-------- .../ansible-multi-node-auth/inventory.ini | 1 + .../ansible-multi-node-auth/resources.xml | 13 ++ .../test/resources/docker/docker-compose.yml | 12 ++ .../ansible/ansible/AnsibleRunner.java | 56 ++++-- .../ansible/AnsibleRunnerContextBuilder.java | 73 ++++---- .../ansible/ansible/AnsibleRunnerSpec.groovy | 10 +- 8 files changed, 212 insertions(+), 118 deletions(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 188c4e88..46cd047c 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -42,7 +42,7 @@ dependencies { tasks.register('functionalTest', Test) { useJUnitPlatform() - systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") + systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.1.1") // Docker configuration for Testcontainers // For Rancher Desktop on macOS: Use /Users//.rd/docker.sock diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index 94c0d181..0b4b7b0f 100644 --- a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -12,12 +12,16 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { static String NODE1_NAME = "ssh-node" static String NODE2_NAME = "ssh-node-2" static String NODE3_NAME = "ssh-node-3" + static String NODE4_NAME = "ssh-node-4" // Test passwords for each node (matching docker-compose.yml) static String NODE1_PASSWORD = "testpassword123" static String NODE2_PASSWORD = "password2_special!@#" static String NODE3_PASSWORD = 'password3"quote\'test' + // Private key for node 4 + static String NODE4_PRIVATE_KEY_PATH = "src/test/resources/docker/keys/id_rsa" + def setupSpec() { startCompose() configureMultiNodeAuthProject() @@ -25,29 +29,12 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { def configureMultiNodeAuthProject() { // Store passwords for each node in Rundeck key storage - okhttp3.RequestBody requestBody = okhttp3.RequestBody.create( - NODE1_PASSWORD.getBytes(), - org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD - ) - client.apiCall { api -> - api.createKeyStorage("project/$PROJ_NAME/ssh-node.pass", requestBody) - } + storePasswordInKeyStorage("ssh-node.pass", NODE1_PASSWORD) + storePasswordInKeyStorage("ssh-node-2.pass", NODE2_PASSWORD) + storePasswordInKeyStorage("ssh-node-3.pass", NODE3_PASSWORD) - requestBody = okhttp3.RequestBody.create( - NODE2_PASSWORD.getBytes(), - org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD - ) - client.apiCall { api -> - api.createKeyStorage("project/$PROJ_NAME/ssh-node-2.pass", requestBody) - } - - requestBody = okhttp3.RequestBody.create( - NODE3_PASSWORD.getBytes(), - org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD - ) - client.apiCall { api -> - api.createKeyStorage("project/$PROJ_NAME/ssh-node-3.pass", requestBody) - } + // Store private key for node 4 in Rundeck key storage + storePrivateKeyInKeyStorage("ssh-node-4.key", NODE4_PRIVATE_KEY_PATH) // Create project def projList = client.apiCall { api -> api.listProjects() } @@ -80,54 +67,82 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { waitForNodeAvailability(PROJ_NAME, NODE1_NAME) waitForNodeAvailability(PROJ_NAME, NODE2_NAME) waitForNodeAvailability(PROJ_NAME, NODE3_NAME) + waitForNodeAvailability(PROJ_NAME, NODE4_NAME) } - def "test multi-node authentication with different passwords"() { - when: - def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" // multi-node-ping-test job + // Helper method to store password in Rundeck key storage + private void storePasswordInKeyStorage(String keyPath, String password) { + okhttp3.RequestBody requestBody = okhttp3.RequestBody.create( + password.getBytes(), + org.rundeck.client.util.Client.MEDIA_TYPE_X_RUNDECK_PASSWORD + ) + client.apiCall { api -> + api.createKeyStorage("project/$PROJ_NAME/$keyPath", requestBody) + } + } + + // Helper method to store private key in Rundeck key storage + private void storePrivateKeyInKeyStorage(String keyPath, String privateKeyFilePath) { + File privateKeyFile = new File(privateKeyFilePath) + okhttp3.RequestBody requestBody = okhttp3.RequestBody.create( + privateKeyFile, + org.rundeck.client.util.Client.MEDIA_TYPE_OCTET_STREAM + ) + client.apiCall { api -> + api.createKeyStorage("project/$PROJ_NAME/$keyPath", requestBody) + } + } + // Helper method to execute job and get execution state + private Map executeJobAndWait(String jobId, String loglevel = 'INFO', String filter = null) { JobRun request = new JobRun() - request.loglevel = 'INFO' + request.loglevel = loglevel + if (filter) { + request.filter = filter + } def result = client.apiCall { api -> api.runJob(jobId, request) } def executionId = result.id - def executionState = waitForJob(executionId) def logs = getLogs(executionId) - then: - // Job succeeded - this verifies that multi-node authentication worked - executionState != null - executionState.getExecutionState() == "SUCCEEDED" - - // Verify all three nodes were in the execution context - executionState.targetNodes.contains("ssh-node") - executionState.targetNodes.contains("ssh-node-2") - executionState.targetNodes.contains("ssh-node-3") + return [executionState: executionState, logs: logs, executionId: executionId] } - def "test ansible playbook with multi-node authentication"() { + // Helper method to verify all four nodes are targeted + private void verifyAllNodesTargeted(def targetNodes) { + assert targetNodes.contains("ssh-node") + assert targetNodes.contains("ssh-node-2") + assert targetNodes.contains("ssh-node-3") + assert targetNodes.contains("ssh-node-4") + } + + def "test multi-node authentication with different passwords"() { when: - def jobId = "b2c3d4e5-f6a7-8901-bcde-f12345678901" // ansible-playbook-multi-node-test job + def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" // multi-node-ping-test job + def result = executeJobAndWait(jobId, 'INFO') - JobRun request = new JobRun() - request.loglevel = 'DEBUG' + then: + // Job succeeded - this verifies that multi-node authentication worked (passwords + private keys) + result.executionState != null + result.executionState.getExecutionState() == "SUCCEEDED" - def result = client.apiCall { api -> api.runJob(jobId, request) } - def executionId = result.id + // Verify all four nodes were in the execution context (3 with passwords, 1 with private key) + verifyAllNodesTargeted(result.executionState.targetNodes) + } - def executionState = waitForJob(executionId) - def logs = getLogs(executionId) + def "test ansible playbook with multi-node authentication"() { + when: + def jobId = "b2c3d4e5-f6a7-8901-bcde-f12345678901" // ansible-playbook-multi-node-test job + def result = executeJobAndWait(jobId, 'DEBUG') then: - // Job succeeded - this verifies the playbook ran successfully with multi-node authentication - executionState != null - executionState.getExecutionState() == "SUCCEEDED" - - // Verify all three nodes were targeted by the playbook execution - executionState.targetNodes.contains("ssh-node") - executionState.targetNodes.contains("ssh-node-2") - executionState.targetNodes.contains("ssh-node-3") + // Job succeeded - this verifies the playbook ran successfully with multi-node authentication (mixed auth types) + result.executionState != null + result.executionState.getExecutionState() == "SUCCEEDED" + + // Verify all four nodes were targeted by the playbook execution (3 passwords + 1 private key) + verifyAllNodesTargeted(result.executionState.targetNodes) } def "test nodes are accessible with different credentials"() { @@ -136,38 +151,48 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { def nodes = client.apiCall { api -> api.listNodes(PROJ_NAME, ".*") } then: - // Verify all three nodes are registered + // Verify all four nodes are registered (3 with password auth, 1 with private key auth) nodes != null - nodes.size() >= 3 + nodes.size() >= 4 nodes.get(NODE1_NAME) != null nodes.get(NODE2_NAME) != null nodes.get(NODE3_NAME) != null + nodes.get(NODE4_NAME) != null } def "test passwords with special characters are properly escaped"() { when: def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" - - JobRun request = new JobRun() - request.loglevel = 'DEBUG' - request.filter = "name: ssh-node-2 || name: ssh-node-3" // Only test nodes with special chars in passwords - - def result = client.apiCall { api -> api.runJob(jobId, request) } - def executionId = result.id - - def executionState = waitForJob(executionId) - def logs = getLogs(executionId) + def result = executeJobAndWait(jobId, 'DEBUG', "name: ssh-node-2 || name: ssh-node-3") then: // Job succeeded even with special characters in passwords - verifies password escaping works - executionState != null - executionState.getExecutionState() == "SUCCEEDED" + result.executionState != null + result.executionState.getExecutionState() == "SUCCEEDED" // Verify nodes with special character passwords were targeted - executionState.targetNodes.contains("ssh-node-2") - executionState.targetNodes.contains("ssh-node-3") + result.executionState.targetNodes.contains("ssh-node-2") + result.executionState.targetNodes.contains("ssh-node-3") // No YAML parsing errors - verifies special characters were properly escaped - !logs.any { it.log.toLowerCase().contains("yaml") && it.log.toLowerCase().contains("error") } + !result.logs.any { it.log.toLowerCase().contains("yaml") && it.log.toLowerCase().contains("error") } + } + + def "test private key authentication works in isolation"() { + when: + def jobId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" // multi-node-ping-test job + def result = executeJobAndWait(jobId, 'DEBUG', "name: ssh-node-4") + + then: + // Job succeeded with ONLY private key authentication (no passwords) + result.executionState != null + result.executionState.getExecutionState() == "SUCCEEDED" + + // Verify only the private key node was targeted + result.executionState.targetNodes.contains("ssh-node-4") + result.executionState.targetNodes.size() == 1 + + // Verify no password-related errors in logs + !result.logs.any { it.log.toLowerCase().contains("password") && it.log.toLowerCase().contains("error") } } } diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini b/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini index 422d1a9a..c305c424 100644 --- a/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/inventory.ini @@ -2,6 +2,7 @@ ssh-node ansible_host=ssh-node ansible_port=22 ssh-node-2 ansible_host=ssh-node-2 ansible_port=22 ssh-node-3 ansible_host=ssh-node-3 ansible_port=22 +ssh-node-4 ansible_host=ssh-node-4 ansible_port=22 [test_nodes:vars] ansible_connection=ssh diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml b/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml index c38837c4..974b60fe 100644 --- a/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/resources.xml @@ -38,4 +38,17 @@ ansible-ssh-auth-type="password" ansible-ssh-password-storage-path="keys/project/ansible-multi-node-auth/ssh-node-3.pass" ansible-ssh-user="rundeck"/> + + diff --git a/functional-test/src/test/resources/docker/docker-compose.yml b/functional-test/src/test/resources/docker/docker-compose.yml index 1ab6722f..05bf8951 100644 --- a/functional-test/src/test/resources/docker/docker-compose.yml +++ b/functional-test/src/test/resources/docker/docker-compose.yml @@ -37,6 +37,18 @@ services: volumes: - ./keys:/configuration:rw + ssh-node-4: + build: + context: node + environment: + NODE_USER_PASSWORD: unused_password + networks: + - rundeck + ports: + - "2225:22" + volumes: + - ./keys:/configuration:rw + rundeck: build: context: rundeck diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 87bd2419..05ed1666 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -474,10 +474,10 @@ public int run() throws Exception { }); // Build YAML content using helper method - String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers); + String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers, hostKeys); if(debug){ - System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords and " + hostUsers.size() + " users"); + System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords, " + hostUsers.size() + " users, and " + hostKeys.size() + " private keys"); System.err.println("DEBUG: YAML content built successfully, length: " + yamlContent.length()); } @@ -928,20 +928,24 @@ public boolean registerKeySshAgent(String keyPath) throws Exception { /** - * Builds YAML content for Ansible group_vars with vault-encrypted passwords and host users. + * Builds YAML content for Ansible group_vars with vault-encrypted passwords, host users, and private keys. * This method manually constructs the YAML because Jackson cannot properly handle Ansible's * custom !vault tag, which would result in an extra | character. * * @param hostPasswords Map of host names to vault-encrypted password strings * @param hostUsers Map of host names to usernames + * @param hostKeys Map of host names to private key file paths * @return YAML content string ready to be written to all.yaml */ - String buildGroupVarsYaml(Map hostPasswords, Map hostUsers) { + String buildGroupVarsYaml(Map hostPasswords, Map hostUsers, Map hostKeys) { StringBuilder yamlContent = new StringBuilder(); - yamlContent.append("host_passwords:\n"); - for (Map.Entry entry : hostPasswords.entrySet()) { + // Only add host_passwords section if there are passwords + if (!hostPasswords.isEmpty()) { + yamlContent.append("host_passwords:\n"); + + for (Map.Entry entry : hostPasswords.entrySet()) { String originalKey = entry.getKey(); String escapedKey = escapeYamlKey(originalKey); @@ -971,18 +975,16 @@ String buildGroupVarsYaml(Map hostPasswords, Map System.err.println("DEBUG: Single line vault value for host: " + originalKey); yamlContent.append(vaultValue).append("\n"); } + } } - yamlContent.append("\nhost_users:\n"); - for (Map.Entry entry : hostUsers.entrySet()) { - String originalKey = entry.getKey(); - String originalValue = entry.getValue(); - String escapedKey = escapeYamlKey(originalKey); - String escapedValue = escapeYamlValue(originalValue); - yamlContent.append(" ").append(escapedKey).append(": ") - .append(escapedValue).append("\n"); + // Add host_private_keys section if there are private keys + if (!hostKeys.isEmpty()) { + appendYamlMapSection(yamlContent, "host_private_keys", hostKeys, true); } + appendYamlMapSection(yamlContent, "host_users", hostUsers, true); + String result = yamlContent.toString(); if(debug){ @@ -992,6 +994,32 @@ String buildGroupVarsYaml(Map hostPasswords, Map return result; } + /** + * Helper method to append a simple YAML map section with key-value pairs. + * Extracted to reduce duplication between host_private_keys and host_users sections. + * + * @param yamlContent The StringBuilder to append to + * @param sectionName The name of the YAML section (e.g., "host_users", "host_private_keys") + * @param entries The map of key-value pairs to add to the section + * @param addLeadingNewline Whether to add a newline before the section name + */ + private void appendYamlMapSection(StringBuilder yamlContent, String sectionName, + Map entries, boolean addLeadingNewline) { + if (addLeadingNewline) { + yamlContent.append("\n"); + } + yamlContent.append(sectionName).append(":\n"); + + for (Map.Entry entry : entries.entrySet()) { + String originalKey = entry.getKey(); + String originalValue = entry.getValue(); + String escapedKey = escapeYamlKey(originalKey); + String escapedValue = escapeYamlValue(originalValue); + yamlContent.append(" ").append(escapedKey).append(": ") + .append(escapedValue).append("\n"); + } + } + /** * Escapes YAML special characters in keys. * If the key contains special characters, wraps it in quotes. diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 1a03c6e6..675ed45c 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -105,19 +105,10 @@ public String getPrivateKeyStoragePath() { } public String getPrivateKeyStoragePath(INodeEntry node) { - String path = PropertyResolver.resolveProperty( + return resolveAndExpandStoragePath( AnsibleDescribable.ANSIBLE_SSH_KEYPATH_STORAGE_PATH, - null, - getFrameworkProject(), - getFramework(), - node, - getJobConf() + node ); - //expand properties in path - if (path != null && path.contains("${")) { - path = DataContextUtils.replaceDataReferencesInString(path, context.getDataContext()); - } - return path; } public byte[] getPrivateKeyStorageDataBytes() throws IOException { @@ -126,13 +117,27 @@ public byte[] getPrivateKeyStorageDataBytes() throws IOException { } public String getPasswordStoragePath() { + return getPasswordStoragePath(getNode()); + } - String path = PropertyResolver.resolveProperty( + public String getPasswordStoragePath(INodeEntry node) { + return resolveAndExpandStoragePath( AnsibleDescribable.ANSIBLE_SSH_PASSWORD_STORAGE_PATH, + node + ); + } + + /** + * Helper method to resolve a storage path property and expand any data references. + * Extracted to reduce duplication between password and private key storage path resolution. + */ + private String resolveAndExpandStoragePath(String propertyName, INodeEntry node) { + String path = PropertyResolver.resolveProperty( + propertyName, null, getFrameworkProject(), getFramework(), - getNode(), + node, getJobConf() ); @@ -143,22 +148,16 @@ public String getPasswordStoragePath() { return path; } - public String getSshPrivateKey(node) throws ConfigurationException { + public String getSshPrivateKey() throws ConfigurationException { + return getSshPrivateKey(getNode()); + } + + public String getSshPrivateKey(INodeEntry node) throws ConfigurationException { //look for storage option String storagePath = getPrivateKeyStoragePath(node); if (null != storagePath) { - Path path = PathUtil.asPath(storagePath); - try { - ResourceMeta contents = context.getStorageTree().getResource(path) - .getContents(); - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - contents.writeContent(byteArrayOutputStream); - return byteArrayOutputStream.toString(); - } catch (StorageException | IOException e) { - throw new ConfigurationException("Failed to read the ssh private key for " + - "storage path: " + storagePath + ": " + e.getMessage()); - } + return readFromStoragePath(storagePath, "ssh private key"); } else { //else look up option value final String path = getPrivateKeyfilePath(); @@ -176,6 +175,10 @@ public String getSshPrivateKey(node) throws ConfigurationException { } public String getSshPassword() throws ConfigurationException { + return getSshPassword(getNode()); + } + + public String getSshPassword(INodeEntry node) throws ConfigurationException { //look for option values first //typically jobs use secure options to dynamically setup the ssh password @@ -184,7 +187,7 @@ public String getSshPassword() throws ConfigurationException { AnsibleDescribable.DEFAULT_ANSIBLE_SSH_PASSWORD_OPTION, getFrameworkProject(), getFramework(), - getNode(), + node, getJobConf() ); String sshPassword = PropertyResolver.evaluateSecureOption(passwordOption, getContext()); @@ -194,7 +197,7 @@ public String getSshPassword() throws ConfigurationException { return sshPassword; } else { //look for storage option - String storagePath = getPasswordStoragePath(); + String storagePath = getPasswordStoragePath(node); if (null != storagePath) { //look up storage value @@ -207,7 +210,19 @@ public String getSshPassword() throws ConfigurationException { } public String getPasswordFromPath(String storagePath) throws ConfigurationException { - //look up storage value + return readFromStoragePath(storagePath, "ssh password"); + } + + /** + * Helper method to read content from Rundeck storage path. + * Extracted to reduce duplication between password and private key reading. + * + * @param storagePath The storage path to read from + * @param resourceType Description of resource type for error messages (e.g., "ssh password", "ssh private key") + * @return The content as a UTF-8 string + * @throws ConfigurationException if reading fails + */ + private String readFromStoragePath(String storagePath, String resourceType) throws ConfigurationException { Path path = PathUtil.asPath(storagePath); try { ResourceMeta contents = context.getStorageTree().getResource(path) @@ -216,7 +231,7 @@ public String getPasswordFromPath(String storagePath) throws ConfigurationExcept contents.writeContent(byteArrayOutputStream); return byteArrayOutputStream.toString("UTF-8"); } catch (StorageException | IOException e) { - throw new ConfigurationException("Failed to read the ssh password for " + + throw new ConfigurationException("Failed to read the " + resourceType + " for " + "storage path: " + storagePath + ": " + e.getMessage()); } } diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index 9214adec..b80e6cba 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -690,7 +690,7 @@ class AnsibleRunnerSpec extends Specification{ ] when: - def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers, [:]) then: yaml.contains("host_passwords:") @@ -717,7 +717,7 @@ class AnsibleRunnerSpec extends Specification{ ] when: - def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers, [:]) then: yaml.contains('"web:server:1": !vault |') @@ -738,7 +738,7 @@ class AnsibleRunnerSpec extends Specification{ ] when: - runner.buildGroupVarsYaml(hostPasswords, hostUsers) + runner.buildGroupVarsYaml(hostPasswords, hostUsers, [:]) then: def e = thrown(RuntimeException) @@ -755,10 +755,10 @@ class AnsibleRunnerSpec extends Specification{ def hostUsers = [:] when: - def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers) + def yaml = runner.buildGroupVarsYaml(hostPasswords, hostUsers, [:]) then: - yaml.contains("host_passwords:") + !yaml.contains("host_passwords:") // Should not appear when empty yaml.contains("host_users:") } From 84d816ac578973d029847616b2b369d796c041e3 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Wed, 14 Jan 2026 12:42:17 -0300 Subject: [PATCH 23/46] changed error messages, changed debug logs --- functional-test/build.gradle | 2 +- .../ansible/ansible/AnsibleRunner.java | 6 ++- .../ansible/AnsibleRunnerContextBuilder.java | 42 +++++++++++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 46cd047c..188c4e88 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -42,7 +42,7 @@ dependencies { tasks.register('functionalTest', Test) { useJUnitPlatform() - systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.1.1") + systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") // Docker configuration for Testcontainers // For Rancher Desktop on macOS: Use /Users//.rd/docker.sock diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 05ed1666..d1f6f95f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -446,7 +446,8 @@ public int run() throws Exception { try { encryptedPassword = ansibleVault.encryptVariable("ansible_password", password); } catch (Exception e) { - throw new RuntimeException(e); + throw new RuntimeException("Failed to encrypt password for node '" + + nodeName + "' using Ansible Vault: " + e.getMessage(), e); } } hostPasswords.put(nodeName, encryptedPassword); @@ -468,7 +469,8 @@ public int run() throws Exception { tempHostPkFile.deleteOnExit(); } catch (IOException e) { - throw new RuntimeException(e); + throw new RuntimeException("Failed to create temporary private key file for node '" + + nodeName + "': " + e.getMessage(), e); } } }); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 675ed45c..bc8b7e4f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -974,12 +974,27 @@ public Map> getNodesAuthenticationMap(){ this.context.getNodes().forEach((node) -> { Map auth = new HashMap<>(); final AuthenticationType authType = getSshAuthenticationType(node); + + if (getDebug()) { + System.err.println("DEBUG: Processing authentication for node '" + node.getNodename() + + "' with auth type: " + authType); + } + if (AnsibleDescribable.AuthenticationType.privateKey == authType) { final String privateKey; try { privateKey = getSshPrivateKey(node); + if (getDebug()) { + System.err.println("DEBUG: Successfully retrieved private key for node '" + + node.getNodename() + "'"); + } } catch (ConfigurationException e) { - throw new RuntimeException(e); + if (getDebug()) { + System.err.println("DEBUG: Error retrieving private key for node '" + + node.getNodename() + "': " + e.getMessage()); + } + throw new RuntimeException("Failed to retrieve private key for node '" + + node.getNodename() + "': " + e.getMessage(), e); } if (privateKey != null) { auth.put("ansible_ssh_private_key", privateKey); @@ -989,10 +1004,18 @@ public Map> getNodesAuthenticationMap(){ String password = getSshPassword(node); if(null!=password){ auth.put("ansible_password", password); + if (getDebug()) { + System.err.println("DEBUG: Successfully retrieved password for node '" + + node.getNodename() + "'"); + } } } catch (ConfigurationException e) { - System.err.println("DEBUG: Error retrieving password for " + node.getNodename() + ": " + e.getMessage()); - throw new RuntimeException(e); + if (getDebug()) { + System.err.println("DEBUG: Error retrieving password for node '" + + node.getNodename() + "': " + e.getMessage()); + } + throw new RuntimeException("Failed to retrieve password for node '" + + node.getNodename() + "': " + e.getMessage(), e); } } @@ -1002,6 +1025,19 @@ public Map> getNodesAuthenticationMap(){ auth.put("ansible_user",userName ); } + // Validate that node has at least one authentication method configured + boolean hasPassword = auth.containsKey("ansible_password"); + boolean hasPrivateKey = auth.containsKey("ansible_ssh_private_key"); + + if (!hasPassword && !hasPrivateKey) { + context.getExecutionLogger().log(2, "WARNING: Node '" + node.getNodename() + + "' has no password or private key configured. Authentication may fail."); + if (getDebug()) { + System.err.println("DEBUG: Node '" + node.getNodename() + + "' has no credentials configured (only username: " + (auth.containsKey("ansible_user") ? "yes" : "no") + ")"); + } + } + authenticationNodesMap.put(node.getNodename(), auth); }); From 684aa512b42d970c969163c0bd1e9cb56a579777 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Wed, 14 Jan 2026 13:14:03 -0300 Subject: [PATCH 24/46] added node name sanitization and unit test --- .../ansible/ansible/AnsibleRunner.java | 7 ++- .../ansible/ansible/AnsibleRunnerSpec.groovy | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index d1f6f95f..c8250574 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -457,7 +457,12 @@ public int run() throws Exception { //create temporary file for private key File tempHostPkFile; try { - tempHostPkFile = AnsibleUtil.createTemporaryFile("","id_rsa_node_"+nodeName, privateKey,customTmpDirPath); + // Sanitize node name for filesystem use (replace unsafe characters with underscores) + String safeNodeName = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_"); + if(debug && !nodeName.equals(safeNodeName)) { + System.err.println("DEBUG: Sanitized node name '" + nodeName + "' to '" + safeNodeName + "' for temp file"); + } + tempHostPkFile = AnsibleUtil.createTemporaryFile("","id_rsa_node_"+safeNodeName, privateKey,customTmpDirPath); // Only the owner can read and write Set perms = new HashSet(); diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index b80e6cba..06fd77c4 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -1002,4 +1002,61 @@ class AnsibleRunnerSpec extends Specification{ 2 * ansibleVault.encryptVariable(_, _) >> "!vault | value" } + def "node name sanitization: should sanitize node names with forward slashes for temp files"() { + given: + // Test case from real issue: node name with forward slashes + String nodeName = "/docker-runner-ansible-ssh-node-b-3" + String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") + + expect: + sanitized == "_docker-runner-ansible-ssh-node-b-3" + // Verify sanitized name is filesystem-safe + !sanitized.contains("/") + } + + def "node name sanitization: should sanitize various special characters"() { + expect: + nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") == expected + + where: + nodeName || expected + "simple-node" || "simple-node" + "node.with.dots" || "node.with.dots" + "node_with_underscores" || "node_with_underscores" + "/docker-runner-ansible" || "_docker-runner-ansible" + "node:with:colons" || "node_with_colons" + "node with spaces" || "node_with_spaces" + "node\\with\\backslashes" || "node_with_backslashes" + "node*with?wildcards" || "node_with_wildcards" + "node|with|pipes" || "node_with_pipes" + "nodebrackets" || "node_with_brackets" + "node\"with'quotes" || "node_with_quotes" + 'node@with#special$chars%' || "node_with_special_chars_" + "/path/to/node-123" || "_path_to_node-123" + } + + def "node name sanitization: should preserve safe alphanumeric and allowed characters"() { + given: + String safeName = "my-node_123.server-A" + String sanitized = safeName.replaceAll("[^a-zA-Z0-9._-]", "_") + + expect: + sanitized == safeName // Should not be changed + } + + def "node name sanitization: should handle empty and edge case node names"() { + expect: + nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") == expected + + where: + nodeName || expected + "" || "" + "123" || "123" + "..." || "..." + "---" || "---" + "___" || "___" + "a" || "a" + "/////" || "_____" + } + } From 6a46aed89ca46905f120c87d830ba44ac2d459cd Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Wed, 14 Jan 2026 15:43:22 -0300 Subject: [PATCH 25/46] send private keys to runner --- .../ansible/ansible/AnsibleRunner.java | 5 ++++- .../ansible/AnsibleRunnerContextBuilder.java | 19 +++++++++++++++++++ .../ansible/plugin/AnsibleNodeExecutor.java | 8 +------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index c8250574..6119922d 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -541,7 +541,10 @@ public int run() throws Exception { if(!hostPasswords.isEmpty()){ procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); } - procArgs.add("-e ansible_ssh_private_key_file=\"{{ host_private_keys[inventory_hostname] }}\""); + + if(!hostKeys.isEmpty()){ + procArgs.add("-e ansible_ssh_private_key_file=\"{{ host_private_keys[inventory_hostname] }}\""); + } } catch (IOException e) { System.err.println("ERROR: Failed to write all.yaml: " + e.getMessage()); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index bc8b7e4f..c5d832d9 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -1047,6 +1047,10 @@ public Map> getNodesAuthenticationMap(){ public List getListNodesKeyPath(){ + if(!generateInventoryNodesAuth()) { + return new ArrayList<>(); + } + List secretPaths = new ArrayList<>(); this.context.getNodes().forEach((node) -> { @@ -1064,6 +1068,21 @@ public List getListNodesKeyPath(){ secretPaths.add(keyPath); } } + + String privateKeyPath = PropertyResolver.resolveProperty( + AnsibleDescribable.ANSIBLE_SSH_KEYPATH_STORAGE_PATH, + null, + getFrameworkProject(), + getFramework(), + node, + getJobConf() + ); + + if(null!=privateKeyPath){ + if(!secretPaths.contains(privateKeyPath)){ + secretPaths.add(privateKeyPath); + } + } }); return secretPaths; diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java index e75ae84e..3b1b7165 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java @@ -200,13 +200,7 @@ public List listSecretsPath(ExecutionContext context, INodeEntry node) { jobConf.put(AnsibleDescribable.ANSIBLE_LIMIT,node.getNodename()); AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(node, context, context.getFramework(), jobConf); - List secretPaths = AnsibleUtil.getSecretsPath(builder); - List secretPathsNodes = builder.getListNodesKeyPath(); - - if(secretPathsNodes != null && !secretPathsNodes.isEmpty()){ - secretPaths.addAll(secretPathsNodes); - } - return secretPaths; + return AnsibleUtil.getSecretsPath(builder); } } From 85a41366da413e2e7a4b746d14fe12c91192c0b8 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 15 Jan 2026 08:13:38 -0300 Subject: [PATCH 26/46] addressed copilot comments --- .../ansible/ansible/AnsibleDescribable.java | 4 +- .../ansible/ansible/AnsibleRunner.java | 128 +++++++++++------- .../ansible/AnsibleRunnerContextBuilder.java | 66 ++++++--- .../plugin/AnsibleModuleWorkflowStep.java | 8 +- ...AnsiblePlaybookInlineWorkflowNodeStep.java | 8 +- .../AnsiblePlaybookInlineWorkflowStep.java | 32 ++--- .../AnsiblePlaybookWorflowNodeStep.java | 8 +- .../plugin/AnsiblePlaybookWorkflowStep.java | 32 ++--- .../ansible/ansible/AnsibleRunnerSpec.groovy | 6 +- 9 files changed, 185 insertions(+), 107 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java index 5b8696b1..4b268e5c 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java @@ -237,8 +237,8 @@ public static String[] getValues() { static final Property GENERATE_INVENTORY_NODES_AUTH = PropertyBuilder.builder() .booleanType(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH) .required(false) - .title("Generate inventory, pass node authentication from rundeck nodes") - .description("Pass authentication from rundeck nodes.") + .title("Workflow Step: Generate inventory, pass node authentication from rundeck nodes") + .description("Pass authentication from rundeck nodes. Only applies to workflow steps.") .build(); public static Property EXECUTABLE_PROP = PropertyUtil.freeSelect( diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 6119922d..0e70334e 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import lombok.Builder; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import java.io.*; @@ -17,6 +18,7 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.*; +@Slf4j @Builder @Data public class AnsibleRunner { @@ -831,7 +833,13 @@ public int run() throws Exception { } if (groupVarsDir != null && groupVarsDir.exists()) { - if (!groupVarsDir.delete()) { + log.debug("Attempting to delete group_vars directory: {}", groupVarsDir.getAbsolutePath()); + try { + deleteTempDirectory(groupVarsDir.toPath()); + log.debug("Successfully deleted group_vars directory: {}", groupVarsDir.getAbsolutePath()); + } catch (IOException e) { + log.warn("Failed to delete group_vars directory: {}. Marking for deletion on JVM exit. Error: {}", + groupVarsDir.getAbsolutePath(), e.getMessage()); groupVarsDir.deleteOnExit(); } } @@ -956,35 +964,35 @@ String buildGroupVarsYaml(Map hostPasswords, Map yamlContent.append("host_passwords:\n"); for (Map.Entry entry : hostPasswords.entrySet()) { - String originalKey = entry.getKey(); - String escapedKey = escapeYamlKey(originalKey); + String originalKey = entry.getKey(); + String escapedKey = escapeYamlKey(originalKey); - yamlContent.append(" ").append(escapedKey).append(": "); - String vaultValue = entry.getValue(); + yamlContent.append(" ").append(escapedKey).append(": "); + String vaultValue = entry.getValue(); - // Validate vault format - if (!isValidVaultFormat(vaultValue)) { - System.err.println("ERROR: Invalid vault format for host: " + originalKey); - throw new RuntimeException("Invalid vault format for host: " + originalKey); - } - // The vault value already contains "!vault |\n" followed by the encrypted content - // We just need to split and indent properly (4 spaces for vault content lines) - if (vaultValue.contains("\n")) { - String[] lines = vaultValue.split("\n", -1); - for (int i = 0; i < lines.length; i++) { - if (i == 0) { - // First line: "!vault |" - yamlContent.append(lines[i]).append("\n"); - } else if (i < lines.length - 1 || !lines[i].isEmpty()) { - // Vault content: indent with 4 spaces - yamlContent.append(" ").append(lines[i]).append("\n"); + // Validate vault format + if (!isValidVaultFormat(vaultValue)) { + System.err.println("ERROR: Invalid vault format for host: " + originalKey); + throw new RuntimeException("Invalid vault format for host: " + originalKey); + } + // The vault value already contains "!vault |\n" followed by the encrypted content + // We just need to split and indent properly (4 spaces for vault content lines) + if (vaultValue.contains("\n")) { + String[] lines = vaultValue.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (i == 0) { + // First line: "!vault |" + yamlContent.append(lines[i]).append("\n"); + } else if (i < lines.length - 1 || !lines[i].isEmpty()) { + // Vault content: indent with 4 spaces + yamlContent.append(" ").append(lines[i]).append("\n"); + } } + } else { + // Single line value (shouldn't happen with vault, but handle it) + System.err.println("DEBUG: Single line vault value for host: " + originalKey); + yamlContent.append(vaultValue).append("\n"); } - } else { - // Single line value (shouldn't happen with vault, but handle it) - System.err.println("DEBUG: Single line vault value for host: " + originalKey); - yamlContent.append(vaultValue).append("\n"); - } } } @@ -993,7 +1001,10 @@ String buildGroupVarsYaml(Map hostPasswords, Map appendYamlMapSection(yamlContent, "host_private_keys", hostKeys, true); } - appendYamlMapSection(yamlContent, "host_users", hostUsers, true); + // Add host_users section if there are users + if (!hostUsers.isEmpty()) { + appendYamlMapSection(yamlContent, "host_users", hostUsers, true); + } String result = yamlContent.toString(); @@ -1043,7 +1054,10 @@ String escapeYamlKey(String key) { key.startsWith("-") || key.startsWith("?") || key.matches("^[0-9].*")) { - // Escape any existing backslashes and quotes, then wrap in quotes + // IMPORTANT: Order of replacements matters! Must escape backslashes FIRST, then quotes. + // If we escaped quotes first, the backslash replacement would double-escape them. + // Example: value="a\"b" -> replace quotes: "a\\"b" -> replace backslash: "a\\\\"b" (WRONG!) + // Correct: value="a\"b" -> replace backslash: "a\\"b" -> replace quotes: "a\\\"b" (RIGHT) return "\"" + key.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } return key; @@ -1062,7 +1076,10 @@ String escapeYamlValue(String value) { value.startsWith("-") || value.startsWith("?") || value.trim().isEmpty()) { - // Escape any existing backslashes and quotes, then wrap in quotes + // IMPORTANT: Order of replacements matters! Must escape backslashes FIRST, then quotes. + // If we escaped quotes first, the backslash replacement would double-escape them. + // Example: value="a\"b" -> replace quotes: "a\\"b" -> replace backslash: "a\\\\"b" (WRONG!) + // Correct: value="a\"b" -> replace backslash: "a\\"b" -> replace quotes: "a\\\"b" (RIGHT) return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } return value; @@ -1088,8 +1105,12 @@ String escapePasswordForYaml(String password) { * Validates that a string is in proper Ansible vault format. * Expected format: "!vault |\n" followed by vault-encrypted content * + * Real Ansible Vault values are ALWAYS multiline with encrypted content. + * This validation rejects single-line vault values and vault values without content + * to prevent authentication failures. + * * @param vaultValue The vault value to validate - * @return true if valid vault format, false otherwise + * @return true if valid vault format with content, false otherwise */ boolean isValidVaultFormat(String vaultValue) { if (vaultValue == null || vaultValue.trim().isEmpty()) { @@ -1102,25 +1123,40 @@ boolean isValidVaultFormat(String vaultValue) { return false; } - // For multiline vault values, should contain the pipe character and newline - if (vaultValue.contains("\n")) { - // Check if it follows the "!vault |" format - String firstLine = vaultValue.split("\n", 2)[0]; - if (!firstLine.trim().matches("!vault\\s*\\|?")) { - return false; - } + // Ansible Vault values must be multiline - reject single-line values + if (!vaultValue.contains("\n")) { + return false; + } - // Should have encrypted content after the first line - String[] lines = vaultValue.split("\n"); - if (lines.length < 2) { - return false; + // Check if it follows the "!vault |" or "!vault" format + String firstLine = vaultValue.split("\n", 2)[0]; + if (!firstLine.trim().matches("!vault\\s*\\|?")) { + return false; + } + + // Must have encrypted content after the first line + String[] lines = vaultValue.split("\n"); + if (lines.length < 2) { + return false; + } + + // At least one content line must be non-empty (excluding the last line which may be empty) + boolean hasContent = false; + for (int i = 1; i < lines.length; i++) { + if (!lines[i].trim().isEmpty()) { + hasContent = true; + break; } + } - // Vault content lines should not be empty (except maybe the last one) - for (int i = 1; i < lines.length - 1; i++) { - if (lines[i].trim().isEmpty()) { - return false; - } + if (!hasContent) { + return false; + } + + // Vault content lines should not be empty (except the last one) + for (int i = 1; i < lines.length - 1; i++) { + if (lines[i].trim().isEmpty()) { + return false; } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index c5d832d9..3faf2304 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -24,6 +24,7 @@ import com.rundeck.plugins.ansible.plugin.AnsiblePluginGroup; import com.rundeck.plugins.ansible.util.AnsibleUtil; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.rundeck.storage.api.Path; import java.util.*; @@ -31,6 +32,7 @@ import org.rundeck.storage.api.PathUtil; import org.rundeck.storage.api.StorageException; +@Slf4j @Getter public class AnsibleRunnerContextBuilder { @@ -219,10 +221,14 @@ public String getPasswordFromPath(String storagePath) throws ConfigurationExcept * * @param storagePath The storage path to read from * @param resourceType Description of resource type for error messages (e.g., "ssh password", "ssh private key") - * @return The content as a UTF-8 string + * @return The content as a UTF-8 string, or null if storagePath is null * @throws ConfigurationException if reading fails */ private String readFromStoragePath(String storagePath, String resourceType) throws ConfigurationException { + if (storagePath == null) { + return null; + } + Path path = PathUtil.asPath(storagePath); try { ResourceMeta contents = context.getStorageTree().getResource(path) @@ -799,19 +805,25 @@ public INodeEntry getNode() { public void cleanupTempFiles() { // Clean up individual temp files for (File temp : tempFiles) { - if (getDebug()) { - System.err.println("DEBUG: Deleting temp file: " + temp.getAbsolutePath()); + log.debug("Attempting to delete temp file: {}", temp.getAbsolutePath()); + if (!temp.delete()) { + log.warn("Failed to delete temp file: {}. Marking for deletion on JVM exit.", temp.getAbsolutePath()); + // Fallback: schedule for deletion on JVM exit + temp.deleteOnExit(); + } else { + log.debug("Successfully deleted temp file: {}", temp.getAbsolutePath()); } - temp.delete(); } tempFiles.clear(); // Clean up execution-specific directory (including group_vars) if (executionSpecificDir != null && executionSpecificDir.exists()) { - if (getDebug()) { - System.err.println("DEBUG: Execution-specific directory exists: " + executionSpecificDir.getAbsolutePath()); + log.debug("Cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + if (!deleteDirectoryRecursively(executionSpecificDir)) { + log.warn("Failed to completely delete execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + } else { + log.debug("Successfully deleted execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); } - deleteDirectoryRecursively(executionSpecificDir); } } @@ -819,23 +831,37 @@ public void cleanupTempFiles() { * Recursively deletes a directory and all its contents. * * @param directory The directory to delete + * @return true if the directory and all its contents were successfully deleted, false otherwise */ - private void deleteDirectoryRecursively(File directory) { + private boolean deleteDirectoryRecursively(File directory) { if (directory == null || !directory.exists()) { - return; + return true; } + boolean success = true; File[] files = directory.listFiles(); if (files != null) { for (File file : files) { if (file.isDirectory()) { - deleteDirectoryRecursively(file); + if (!deleteDirectoryRecursively(file)) { + success = false; + log.warn("Failed to delete subdirectory: {}", file.getAbsolutePath()); + } } else { - file.delete(); + if (!file.delete()) { + success = false; + log.warn("Failed to delete file: {}", file.getAbsolutePath()); + } } } } - directory.delete(); + + if (!directory.delete()) { + success = false; + log.warn("Failed to delete directory: {}", directory.getAbsolutePath()); + } + + return success; } public Boolean getUseSshAgent() { @@ -1159,14 +1185,18 @@ String getExecutionSpecificTmpDir() { if (executionId != null && !executionId.isEmpty()) { executionSpecificDir = new File(baseTmpDir, "ansible-exec-" + executionId); if (!executionSpecificDir.exists()) { - if (getDebug()) { - System.err.println("DEBUG: Creating execution-specific directory: " + executionSpecificDir.getAbsolutePath()); + log.debug("Creating execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + // Check return value to handle race conditions and creation failures + boolean created = executionSpecificDir.mkdirs(); + if (!created && !executionSpecificDir.exists()) { + // Failed to create and directory still doesn't exist - this is an error + String errorMsg = "Failed to create execution-specific directory: " + executionSpecificDir.getAbsolutePath(); + log.error(errorMsg); + throw new IllegalStateException(errorMsg); } - executionSpecificDir.mkdirs(); + log.debug("Successfully created execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); } else { - if (getDebug()) { - System.err.println("DEBUG: Execution-specific directory already exists: " + executionSpecificDir.getAbsolutePath()); - } + log.debug("Execution-specific directory already exists: {}", executionSpecificDir.getAbsolutePath()); } return executionSpecificDir.getAbsolutePath(); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java index 247b6b3c..706f4369 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java @@ -72,7 +72,13 @@ public void executeStep(PluginStepContext context, Map configura } // set log level - if (context.getDataContext().get("job").get("loglevel").equals("DEBUG")) { + String loglevel = null; + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + loglevel = context.getDataContext().get("job").get("loglevel"); + } + + if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); } else { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "False"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java index a9a1d40c..c95a6e2f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java @@ -79,7 +79,13 @@ public void executeNodeStep( configuration.put(AnsibleDescribable.ANSIBLE_LIMIT,entry.getNodename()); // set log level - if (context.getDataContext().get("job").get("loglevel").equals("DEBUG")) { + String loglevel = null; + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + loglevel = context.getDataContext().get("job").get("loglevel"); + } + + if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"True"); } else { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"False"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index e5e626c0..63bc9dc8 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -16,12 +16,14 @@ import com.dtolabs.rundeck.plugins.step.StepPlugin; import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; import com.rundeck.plugins.ansible.util.AnsibleUtil; +import lombok.extern.slf4j.Slf4j; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +@Slf4j @Plugin(name = AnsiblePlaybookInlineWorkflowStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) public class AnsiblePlaybookInlineWorkflowStep implements StepPlugin, AnsibleDescribable, ProxyRunnerPlugin, ConfiguredBy { @@ -85,7 +87,13 @@ public void executeStep(PluginStepContext context, Map configura configuration.put(AnsibleDescribable.ANSIBLE_LIMIT, limit); } // set log level - if (context.getDataContext().get("job").get("loglevel").equals("DEBUG")) { + String loglevel = null; + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + loglevel = context.getDataContext().get("job").get("loglevel"); + } + + if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); } else { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "False"); @@ -103,31 +111,19 @@ public void executeStep(PluginStepContext context, Map configura Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); - boolean isDebug = context.getDataContext().get("job").get("loglevel").equals("DEBUG"); - - if(isDebug) { - System.err.println("DEBUG: generateInventoryNodesAuth returned: " + generateInventoryNodeAuth); - } + log.debug("generateInventoryNodesAuth returned: {}", generateInventoryNodeAuth); if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ - if(isDebug) { - System.err.println("DEBUG: Node auth is enabled, getting authentication map"); - } + log.debug("Node auth is enabled, getting authentication map"); Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); - if(isDebug) { - System.err.println("DEBUG: Retrieved " + (nodesAuth != null ? nodesAuth.size() : 0) + " node authentications"); - } + log.debug("Retrieved {} node authentications", (nodesAuth != null ? nodesAuth.size() : 0)); if (nodesAuth != null && !nodesAuth.isEmpty()) { runner.setAddNodeAuthToInventory(true); runner.setNodesAuthentication(nodesAuth); - if(isDebug) { - System.err.println("DEBUG: Set node authentication on runner"); - } + log.debug("Set node authentication on runner"); } } else { - if(isDebug) { - System.err.println("DEBUG: Node auth is NOT enabled"); - } + log.debug("Node auth is NOT enabled"); } runner.setCustomTmpDirPath(AnsibleUtil.getCustomTmpPathDir(contextBuilder.getFramework())); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java index 0a604110..f44fa2ef 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java @@ -76,7 +76,13 @@ public void executeNodeStep( configuration.put(AnsibleDescribable.ANSIBLE_LIMIT,entry.getNodename()); // set log level - if (context.getDataContext().get("job").get("loglevel").equals("DEBUG")) { + String loglevel = null; + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + loglevel = context.getDataContext().get("job").get("loglevel"); + } + + if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"True"); } else { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"False"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index 061dff28..c0d294ba 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -16,11 +16,13 @@ import com.dtolabs.rundeck.plugins.step.StepPlugin; import com.dtolabs.rundeck.plugins.util.DescriptionBuilder; import com.rundeck.plugins.ansible.util.AnsibleUtil; +import lombok.extern.slf4j.Slf4j; import java.util.HashMap; import java.util.List; import java.util.Map; +@Slf4j @Plugin(name = AnsiblePlaybookWorkflowStep.SERVICE_PROVIDER_NAME, service = ServiceNameConstants.WorkflowStep) public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribable, ProxyRunnerPlugin, ConfiguredBy { @@ -84,7 +86,13 @@ public void executeStep(PluginStepContext context, Map configura configuration.put(AnsibleDescribable.ANSIBLE_LIMIT, limit); } // set log level - if (context.getDataContext().get("job").get("loglevel").equals("DEBUG")) { + String loglevel = null; + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + loglevel = context.getDataContext().get("job").get("loglevel"); + } + + if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); } else { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "False"); @@ -101,31 +109,19 @@ public void executeStep(PluginStepContext context, Map configura Boolean generateInventoryNodeAuth = contextBuilder.generateInventoryNodesAuth(); - boolean isDebug = context.getDataContext().get("job").get("loglevel").equals("DEBUG"); - - if(isDebug) { - System.err.println("DEBUG: generateInventoryNodesAuth returned: " + generateInventoryNodeAuth); - } + log.debug("generateInventoryNodesAuth returned: {}", generateInventoryNodeAuth); if(generateInventoryNodeAuth != null && generateInventoryNodeAuth){ - if(isDebug) { - System.err.println("DEBUG: Node auth is enabled, getting authentication map"); - } + log.debug("Node auth is enabled, getting authentication map"); Map> nodesAuth = contextBuilder.getNodesAuthenticationMap(); - if(isDebug) { - System.err.println("DEBUG: Retrieved " + (nodesAuth != null ? nodesAuth.size() : 0) + " node authentications"); - } + log.debug("Retrieved {} node authentications", (nodesAuth != null ? nodesAuth.size() : 0)); if (nodesAuth != null && !nodesAuth.isEmpty()) { runner.setAddNodeAuthToInventory(true); runner.setNodesAuthentication(nodesAuth); - if(isDebug) { - System.err.println("DEBUG: Set node authentication on runner"); - } + log.debug("Set node authentication on runner"); } } else { - if(isDebug) { - System.err.println("DEBUG: Node auth is NOT enabled"); - } + log.debug("Node auth is NOT enabled"); } runner.setCustomTmpDirPath(AnsibleUtil.getCustomTmpPathDir(contextBuilder.getFramework())); diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index 06fd77c4..6581448b 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -670,7 +670,7 @@ class AnsibleRunnerSpec extends Specification{ "not a vault" || false null || false "" || false - "!vault" || true // minimal valid + "!vault" || false // single-line without content - invalid "!vault |\n" || false // no content } @@ -759,7 +759,9 @@ class AnsibleRunnerSpec extends Specification{ then: !yaml.contains("host_passwords:") // Should not appear when empty - yaml.contains("host_users:") + !yaml.contains("host_users:") // Should not appear when empty + !yaml.contains("host_private_keys:") // Should not appear when empty + yaml.isEmpty() || yaml.trim().isEmpty() // Should produce empty or whitespace-only output } def "escapePasswordForYaml: should escape special characters"() { From 6d8c5486bf5433f173b514463e314f6c7569e674 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 15 Jan 2026 11:02:13 -0300 Subject: [PATCH 27/46] Copilot comments --- functional-test/README.md | 2 +- .../ansible/ansible/AnsibleRunner.java | 16 +++++++--- .../ansible/AnsibleRunnerContextBuilder.java | 31 ++++++++++++------- .../ansible/ansible/AnsibleRunnerSpec.groovy | 2 +- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/functional-test/README.md b/functional-test/README.md index 8cdb771c..bedac1f1 100644 --- a/functional-test/README.md +++ b/functional-test/README.md @@ -173,7 +173,7 @@ The multi-node authentication feature allows running Ansible playbooks across mu 4. Ansible uses host-specific credentials from group_vars **Requirements:** -- Must use Ansible Playbook **Workflow Steps** (not Node Steps) +- Must use Ansible Playbook **Workflow Steps** (not Node Steps); the multi-node authentication logic and inventory generation are only implemented for the Workflow Step variant of the plugin and are not executed for Node Steps, so enabling `project.ansible-generate-inventory-nodes-auth` while using Node Steps will not apply per-node credentials. This limitation exists because Node Steps run independently on each target node and do not share the global inventory context that the multi-node authentication feature relies on. - Node attributes must include `ansible-ssh-password-storage-path` for each node - Passwords are automatically encrypted using Ansible Vault - Supports special characters with proper YAML escaping diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 0e70334e..f27f72b3 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -516,7 +516,11 @@ public int run() throws Exception { // Create all.yaml in group_vars directory tempNodeAuthFile = new File(groupVarsDir, "all.yaml"); - java.nio.file.Files.writeString(tempNodeAuthFile.toPath(), yamlContent); + java.nio.file.Files.writeString( + tempNodeAuthFile.toPath(), + yamlContent, + java.nio.charset.StandardCharsets.UTF_8 + ); if(debug) { System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); @@ -539,13 +543,15 @@ public int run() throws Exception { } //set extra vars to resolve the host specific authentication - procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); + if (!hostUsers.isEmpty()) { + procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); + } if(!hostPasswords.isEmpty()){ - procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] }}\""); + procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] | default(omit) }}\""); } if(!hostKeys.isEmpty()){ - procArgs.add("-e ansible_ssh_private_key_file=\"{{ host_private_keys[inventory_hostname] }}\""); + procArgs.add("-e ansible_ssh_private_key_file=\"{{ host_private_keys[inventory_hostname] | default(omit) }}\""); } } catch (IOException e) { @@ -990,7 +996,7 @@ String buildGroupVarsYaml(Map hostPasswords, Map } } else { // Single line value (shouldn't happen with vault, but handle it) - System.err.println("DEBUG: Single line vault value for host: " + originalKey); + log.debug("Single line vault value for host: {}", originalKey); yamlContent.append(vaultValue).append("\n"); } } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 3faf2304..f1f4803b 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -235,7 +235,7 @@ private String readFromStoragePath(String storagePath, String resourceType) thro .getContents(); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); contents.writeContent(byteArrayOutputStream); - return byteArrayOutputStream.toString("UTF-8"); + return byteArrayOutputStream.toString(java.nio.charset.StandardCharsets.UTF_8); } catch (StorageException | IOException e) { throw new ConfigurationException("Failed to read the " + resourceType + " for " + "storage path: " + storagePath + ": " + e.getMessage()); @@ -805,19 +805,21 @@ public INodeEntry getNode() { public void cleanupTempFiles() { // Clean up individual temp files for (File temp : tempFiles) { - log.debug("Attempting to delete temp file: {}", temp.getAbsolutePath()); - if (!temp.delete()) { - log.warn("Failed to delete temp file: {}. Marking for deletion on JVM exit.", temp.getAbsolutePath()); - // Fallback: schedule for deletion on JVM exit - temp.deleteOnExit(); - } else { - log.debug("Successfully deleted temp file: {}", temp.getAbsolutePath()); + if (!getDebug()) { + log.debug("Attempting to delete temp file: {}", temp.getAbsolutePath()); + if (!temp.delete()) { + log.warn("Failed to delete temp file: {}. Marking for deletion on JVM exit.", temp.getAbsolutePath()); + // Fallback: schedule for deletion on JVM exit + temp.deleteOnExit(); + } else { + log.debug("Successfully deleted temp file: {}", temp.getAbsolutePath()); + } } } tempFiles.clear(); // Clean up execution-specific directory (including group_vars) - if (executionSpecificDir != null && executionSpecificDir.exists()) { + if (!getDebug() && executionSpecificDir != null && executionSpecificDir.exists()) { log.debug("Cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); if (!deleteDirectoryRecursively(executionSpecificDir)) { log.warn("Failed to completely delete execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); @@ -1011,8 +1013,8 @@ public Map> getNodesAuthenticationMap(){ try { privateKey = getSshPrivateKey(node); if (getDebug()) { - System.err.println("DEBUG: Successfully retrieved private key for node '" + - node.getNodename() + "'"); + System.err.println("DEBUG: Retrieved private key for node '" + + node.getNodename() + "': " + (privateKey != null ? ("yes, length=" + privateKey.length()) : "null")); } } catch (ConfigurationException e) { if (getDebug()) { @@ -1024,6 +1026,13 @@ public Map> getNodesAuthenticationMap(){ } if (privateKey != null) { auth.put("ansible_ssh_private_key", privateKey); + if (getDebug()) { + System.err.println("DEBUG: Added private key to auth map for node '" + node.getNodename() + "'"); + } + } else { + if (getDebug()) { + System.err.println("DEBUG: Private key is null for node '" + node.getNodename() + "', not adding to auth map"); + } } } else if (AnsibleDescribable.AuthenticationType.password == authType) { try { diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index 6581448b..c66a84e7 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -620,7 +620,7 @@ class AnsibleRunnerSpec extends Specification{ "-starts-with-dash" || "\"-starts-with-dash\"" "?starts-with-q" || "\"?starts-with-q\"" "123numeric" || "\"123numeric\"" - "key with spaces" || "key with spaces" // spaces are handled differently + "key with spaces" || "key with spaces" // spaces are valid in unquoted YAML keys, so we intentionally do not quote them } def "escapeYamlValue: should quote values with special characters"() { From 65ab770f600f04a7c5e26d04c2ff25c980f4b037 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Thu, 15 Jan 2026 19:18:07 -0300 Subject: [PATCH 28/46] enable again the send node keys to runner in node-executor --- .../plugins/ansible/plugin/AnsibleNodeExecutor.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java index 3b1b7165..e75ae84e 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleNodeExecutor.java @@ -200,7 +200,13 @@ public List listSecretsPath(ExecutionContext context, INodeEntry node) { jobConf.put(AnsibleDescribable.ANSIBLE_LIMIT,node.getNodename()); AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(node, context, context.getFramework(), jobConf); - return AnsibleUtil.getSecretsPath(builder); + List secretPaths = AnsibleUtil.getSecretsPath(builder); + List secretPathsNodes = builder.getListNodesKeyPath(); + + if(secretPathsNodes != null && !secretPathsNodes.isEmpty()){ + secretPaths.addAll(secretPathsNodes); + } + return secretPaths; } } From f224449f818d5f600f75f5f9b87f7502d9b3e17c Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 15 Jan 2026 19:20:12 -0300 Subject: [PATCH 29/46] copilot comments --- .../test/resources/docker/docker-compose.yml | 2 +- .../ansible/ansible/AnsibleDescribable.java | 4 +- .../ansible/ansible/AnsibleRunner.java | 15 +++---- .../ansible/AnsibleRunnerContextBuilder.java | 39 ++++++++++++------- .../AnsiblePlaybookInlineWorkflowStep.java | 3 +- .../plugin/AnsiblePlaybookWorkflowStep.java | 3 +- .../ansible/ansible/AnsibleRunnerSpec.groovy | 2 +- 7 files changed, 40 insertions(+), 28 deletions(-) diff --git a/functional-test/src/test/resources/docker/docker-compose.yml b/functional-test/src/test/resources/docker/docker-compose.yml index 05bf8951..f8d16f37 100644 --- a/functional-test/src/test/resources/docker/docker-compose.yml +++ b/functional-test/src/test/resources/docker/docker-compose.yml @@ -29,7 +29,7 @@ services: build: context: node environment: - NODE_USER_PASSWORD: password3"quote'test + NODE_USER_PASSWORD: 'password3"quote''test' networks: - rundeck ports: diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java index 4b268e5c..646fb7be 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java @@ -237,8 +237,8 @@ public static String[] getValues() { static final Property GENERATE_INVENTORY_NODES_AUTH = PropertyBuilder.builder() .booleanType(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH) .required(false) - .title("Workflow Step: Generate inventory, pass node authentication from rundeck nodes") - .description("Pass authentication from rundeck nodes. Only applies to workflow steps.") + .title("Workflow Step: Generate Inventory and Pass Node Authentication from Rundeck Nodes") + .description("Pass authentication credentials from Rundeck nodes. IMPORTANT: Only applies to Ansible Playbook Workflow Steps. This feature does NOT work with Node Steps because they run independently per node and do not share the global inventory context.") .build(); public static Property EXECUTABLE_PROP = PropertyUtil.freeSelect( diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index f27f72b3..5fa0ccff 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -466,10 +466,9 @@ public int run() throws Exception { } tempHostPkFile = AnsibleUtil.createTemporaryFile("","id_rsa_node_"+safeNodeName, privateKey,customTmpDirPath); - // Only the owner can read and write + // Only the owner can read (SSH private key best practice: 0400) Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); - perms.add(PosixFilePermission.OWNER_WRITE); Files.setPosixFilePermissions(tempHostPkFile.toPath(), perms); hostKeys.put(nodeName, tempHostPkFile.getAbsolutePath()); @@ -544,7 +543,7 @@ public int run() throws Exception { //set extra vars to resolve the host specific authentication if (!hostUsers.isEmpty()) { - procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] }}\""); + procArgs.add("-e ansible_user=\"{{ host_users[inventory_hostname] | default(omit) }}\""); } if(!hostPasswords.isEmpty()){ procArgs.add("-e ansible_password=\"{{ host_passwords[inventory_hostname] | default(omit) }}\""); @@ -597,10 +596,9 @@ public int run() throws Exception { String privateKeyData = sshPrivateKey.replaceAll("\r\n", "\n"); tempPkFile = AnsibleUtil.createTemporaryFile("","id_rsa", privateKeyData,customTmpDirPath); - // Only the owner can read and write + // Only the owner can read (SSH private key best practice: 0400) Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); - perms.add(PosixFilePermission.OWNER_WRITE); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); if (sshUseAgent) { @@ -838,6 +836,9 @@ public int run() throws Exception { tempNodeAuthFile.deleteOnExit(); } + // Clean up group_vars directory if it was created alongside user-provided inventory + // Note: When inventory is generated/inline, group_vars is inside executionSpecificDir + // and is cleaned up by AnsibleRunnerContextBuilder.cleanupTempFiles() if (groupVarsDir != null && groupVarsDir.exists()) { log.debug("Attempting to delete group_vars directory: {}", groupVarsDir.getAbsolutePath()); try { @@ -1134,9 +1135,9 @@ boolean isValidVaultFormat(String vaultValue) { return false; } - // Check if it follows the "!vault |" or "!vault" format + // Check if it follows the "!vault |" format (pipe is required by Ansible) String firstLine = vaultValue.split("\n", 2)[0]; - if (!firstLine.trim().matches("!vault\\s*\\|?")) { + if (!firstLine.trim().matches("!vault\\s*\\|")) { return false; } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index f1f4803b..33ffd494 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -216,9 +216,14 @@ public String getPasswordFromPath(String storagePath) throws ConfigurationExcept } /** - * Helper method to read content from Rundeck storage path. + * Helper method to read text-based content from Rundeck storage path. * Extracted to reduce duplication between password and private key reading. * + * IMPORTANT: This method converts storage content to UTF-8 string and is intended + * ONLY for text-based secrets (passwords, SSH private keys in PEM format, etc.). + * Do NOT use this method for binary data as UTF-8 conversion will cause data corruption. + * For binary data, use loadStoragePathData() which returns byte[] instead. + * * @param storagePath The storage path to read from * @param resourceType Description of resource type for error messages (e.g., "ssh password", "ssh private key") * @return The content as a UTF-8 string, or null if storagePath is null @@ -818,7 +823,8 @@ public void cleanupTempFiles() { } tempFiles.clear(); - // Clean up execution-specific directory (including group_vars) + // Clean up execution-specific directory (including group_vars when inventory was generated/inline) + // Note: group_vars created alongside user-provided inventory files is cleaned up by AnsibleRunner if (!getDebug() && executionSpecificDir != null && executionSpecificDir.exists()) { log.debug("Cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); if (!deleteDirectoryRecursively(executionSpecificDir)) { @@ -984,16 +990,19 @@ public Map getListOptions(){ return options; } - /* - Returns a map of node names to their respective authentication details. - Each entry in the outer map corresponds to a node, with the key being the node name - and the value being another map containing authentication parameters such as - - "ansible_ssh_private_key" or "ansible_password". - [ "node1": { "ansible_ssh_private_key": "...", "ansible_user": "..." }, - "node2": { "ansible_password": "...", "ansible_user": "..." } - ] - + /** + * Returns a map of node names to their respective authentication details. + * Each entry in the outer map corresponds to a node, with the key being the node name + * and the value being another map containing authentication parameters such as + * "ansible_ssh_private_key" or "ansible_password". + * + * @return Map of node names to authentication details. Example: + *
+     * {
+     *   "node1": { "ansible_ssh_private_key": "...", "ansible_user": "..." },
+     *   "node2": { "ansible_password": "...", "ansible_user": "..." }
+     * }
+     * 
*/ public Map> getNodesAuthenticationMap(){ @@ -1195,7 +1204,11 @@ String getExecutionSpecificTmpDir() { executionSpecificDir = new File(baseTmpDir, "ansible-exec-" + executionId); if (!executionSpecificDir.exists()) { log.debug("Creating execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); - // Check return value to handle race conditions and creation failures + // Race condition handling: mkdirs() returns false if the directory already exists + // or if creation failed. If another thread/process created the directory between + // our exists() check and mkdirs() call, mkdirs() returns false but the directory + // now exists. We use double-check pattern: only throw if mkdirs() failed AND + // directory still doesn't exist (true creation failure). boolean created = executionSpecificDir.mkdirs(); if (!created && !executionSpecificDir.exists()) { // Failed to create and directory still doesn't exist - this is an error diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 63bc9dc8..01cc5fc9 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -105,7 +105,6 @@ public void executeStep(PluginStepContext context, Map configura configuration, pluginGroup); - try { runner = AnsibleRunner.buildAnsibleRunner(contextBuilder); @@ -162,7 +161,7 @@ public Description getDescription() { @Override public List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { - AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration); + AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration, pluginGroup); return AnsibleUtil.getSecretsPathWorkflowSteps(builder); } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index c0d294ba..f193d869 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -66,7 +66,6 @@ public class AnsiblePlaybookWorkflowStep implements StepPlugin, AnsibleDescribab builder.property(BECOME_PASSWORD_STORAGE_PROP); builder.property(DISABLE_LIMIT_PROP); - DESC = builder.build(); } @@ -156,7 +155,7 @@ public Description getDescription() { @Override public List listSecretsPathWorkflowStep(ExecutionContext context, Map configuration) { - AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration); + AnsibleRunnerContextBuilder builder = new AnsibleRunnerContextBuilder(context, context.getFramework(), context.getNodes(), configuration, pluginGroup); return AnsibleUtil.getSecretsPathWorkflowSteps(builder); } @Override diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index c66a84e7..554a0cd1 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -666,7 +666,7 @@ class AnsibleRunnerSpec extends Specification{ vaultValue || expectedResult "!vault |\n encryptedcontent" || true "!vault |\n line1\n line2" || true - "!vault\n content" || true // without pipe + "!vault\n content" || false // missing pipe - Ansible Vault always requires "|" for literal block style "not a vault" || false null || false "" || false From 19ad6624a48b35ed9c3f9e4a0fa1f878599f861d Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Thu, 15 Jan 2026 22:29:26 -0300 Subject: [PATCH 30/46] copilot comments --- functional-test/build.gradle | 7 +++ .../functional/MultiNodeAuthSpec.groovy | 2 +- .../ansible/ansible/AnsibleDescribable.java | 6 +++ .../ansible/ansible/AnsibleRunner.java | 35 ++++++++++---- .../ansible/AnsibleRunnerContextBuilder.java | 47 ++++++++++--------- .../ansible/ansible/AnsibleRunnerSpec.groovy | 36 +++++++++++--- 6 files changed, 95 insertions(+), 38 deletions(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 188c4e88..3cba6635 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -42,6 +42,13 @@ dependencies { tasks.register('functionalTest', Test) { useJUnitPlatform() + + // Rundeck test image version + // NOTE: Using Rundeck 5.18.0 for functional testing. This version upgrade from the + // previously used 5.1.1 ensures compatibility with newer Rundeck releases and + // validates that the multi-node authentication feature works correctly with + // current Rundeck APIs and behavior. If the plugin's minimum supported Rundeck + // version changes, update both this test image and the plugin documentation. systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") // Docker configuration for Testcontainers diff --git a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy index 0b4b7b0f..a9f4e787 100644 --- a/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy +++ b/functional-test/src/test/groovy/functional/MultiNodeAuthSpec.groovy @@ -17,7 +17,7 @@ class MultiNodeAuthSpec extends BaseTestConfiguration { // Test passwords for each node (matching docker-compose.yml) static String NODE1_PASSWORD = "testpassword123" static String NODE2_PASSWORD = "password2_special!@#" - static String NODE3_PASSWORD = 'password3"quote\'test' + static String NODE3_PASSWORD = 'password3"quote\'test' // Expected password value: password3"quote'test // Private key for node 4 static String NODE4_PRIVATE_KEY_PATH = "src/test/resources/docker/keys/id_rsa" diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java index 646fb7be..26e5e1ae 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java @@ -100,6 +100,12 @@ public static String[] getValues() { public static final String ANSIBLE_INVENTORY_INLINE = "ansible-inventory-inline"; public static final String ANSIBLE_INVENTORY = "ansible-inventory"; public static final String ANSIBLE_GENERATE_INVENTORY = "ansible-generate-inventory"; + /** + * Controls whether node authentication data is included when generating the inventory. + * This is an enhancement of {@link #ANSIBLE_GENERATE_INVENTORY} and only has effect + * when inventory generation is enabled. It is exposed as a separate top-level + * property for configuration/UI clarity. + */ public static final String ANSIBLE_GENERATE_INVENTORY_NODES_AUTH = "ansible-generate-inventory-nodes-auth"; public static final String ANSIBLE_MODULE = "ansible-module"; public static final String ANSIBLE_MODULE_ARGS = "ansible-module-args"; diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 5fa0ccff..4afef2f5 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -461,6 +461,10 @@ public int run() throws Exception { try { // Sanitize node name for filesystem use (replace unsafe characters with underscores) String safeNodeName = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_"); + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (safeNodeName.isEmpty() || safeNodeName.startsWith(".") || safeNodeName.matches("^\\.+$")) { + safeNodeName = "_" + safeNodeName; + } if(debug && !nodeName.equals(safeNodeName)) { System.err.println("DEBUG: Sanitized node name '" + nodeName + "' to '" + safeNodeName + "' for temp file"); } @@ -492,6 +496,11 @@ public int run() throws Exception { try { // Create group_vars directory structure + // IMPORTANT: Following Ansible convention, group_vars must be created in the same + // directory as the inventory file for Ansible to find it. While this means writing + // to user-specified locations, the passwords are vault-encrypted making them safe + // for storage. Administrators should ensure inventory directories have appropriate + // filesystem permissions to prevent unauthorized access. File inventoryFile = new File(inventory); File inventoryParentDir = inventoryFile.getParentFile(); @@ -507,10 +516,10 @@ public int run() throws Exception { System.err.println("DEBUG: group_vars directory path: " + groupVarsDir.getAbsolutePath()); } - if (!groupVarsDir.exists()) { - if (!groupVarsDir.mkdirs()) { - throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath()); - } + try { + Files.createDirectories(groupVarsDir.toPath()); + } catch (IOException e) { + throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath(), e); } // Create all.yaml in group_vars directory @@ -596,7 +605,10 @@ public int run() throws Exception { String privateKeyData = sshPrivateKey.replaceAll("\r\n", "\n"); tempPkFile = AnsibleUtil.createTemporaryFile("","id_rsa", privateKeyData,customTmpDirPath); - // Only the owner can read (SSH private key best practice: 0400) + // Set SSH private key permissions to 0400 (owner read-only). + // This is a security best practice: private keys should never be writable after creation. + // SSH itself will warn or refuse to use keys with overly permissive permissions (e.g., 0600). + // This temporary file is immutable and will be deleted after use. Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); @@ -1093,14 +1105,19 @@ String escapeYamlValue(String value) { } /** - * Escapes a password value for safe use in YAML. - * Always wraps the password in quotes and escapes special characters to prevent YAML parsing errors. + * Escapes a password value for safe use inside double-quoted YAML strings. + * Note: This method does NOT add quotes - the caller must wrap the result in double quotes. + * This differs from escapeYamlKey() and escapeYamlValue() which conditionally add quotes based on content. + * + * Why the different approach: + * - Passwords are passed as Ansible extra vars (e.g., -e 'ansible_password: "..."') and are ALWAYS quoted + * - YAML keys/values in group_vars files should only be quoted when necessary for cleaner output * * @param password The password to escape - * @return Escaped and quoted password safe for YAML + * @return Escaped password safe for use inside double-quoted YAML strings (quotes not included) */ String escapePasswordForYaml(String password) { - // Escape special characters in the password to prevent YAML parsing errors + // Escape special characters for use inside double-quoted strings return password .replace("\\", "\\\\") .replace("\"", "\\\"") diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 33ffd494..36b698a8 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -819,18 +819,27 @@ public void cleanupTempFiles() { } else { log.debug("Successfully deleted temp file: {}", temp.getAbsolutePath()); } + } else { + // In debug mode, temp files are intentionally preserved for inspection + log.debug("Debug mode enabled; not deleting temp file: {}", temp.getAbsolutePath()); } } tempFiles.clear(); // Clean up execution-specific directory (including group_vars when inventory was generated/inline) // Note: group_vars created alongside user-provided inventory files is cleaned up by AnsibleRunner - if (!getDebug() && executionSpecificDir != null && executionSpecificDir.exists()) { - log.debug("Cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); - if (!deleteDirectoryRecursively(executionSpecificDir)) { - log.warn("Failed to completely delete execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + if (executionSpecificDir != null && executionSpecificDir.exists()) { + if (!getDebug()) { + log.debug("Cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + if (!deleteDirectoryRecursively(executionSpecificDir)) { + log.warn("Failed to completely delete execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + } else { + log.debug("Successfully deleted execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + } } else { - log.debug("Successfully deleted execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + // In debug mode, the execution-specific directory is intentionally preserved for troubleshooting. + // Note: These directories will accumulate over time and may require periodic manual cleanup. + log.debug("Debug mode enabled; not cleaning up execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); } } } @@ -1202,23 +1211,17 @@ String getExecutionSpecificTmpDir() { // Create execution-specific directory if (executionId != null && !executionId.isEmpty()) { executionSpecificDir = new File(baseTmpDir, "ansible-exec-" + executionId); - if (!executionSpecificDir.exists()) { - log.debug("Creating execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); - // Race condition handling: mkdirs() returns false if the directory already exists - // or if creation failed. If another thread/process created the directory between - // our exists() check and mkdirs() call, mkdirs() returns false but the directory - // now exists. We use double-check pattern: only throw if mkdirs() failed AND - // directory still doesn't exist (true creation failure). - boolean created = executionSpecificDir.mkdirs(); - if (!created && !executionSpecificDir.exists()) { - // Failed to create and directory still doesn't exist - this is an error - String errorMsg = "Failed to create execution-specific directory: " + executionSpecificDir.getAbsolutePath(); - log.error(errorMsg); - throw new IllegalStateException(errorMsg); - } - log.debug("Successfully created execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); - } else { - log.debug("Execution-specific directory already exists: {}", executionSpecificDir.getAbsolutePath()); + log.debug("Creating execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); + try { + // Use Files.createDirectories() which is idempotent (safe to call if directory exists) + // and handles race conditions properly. Unlike mkdirs(), it doesn't return false + // when the directory already exists - it only throws IOException on actual failure. + Files.createDirectories(executionSpecificDir.toPath()); + log.debug("Successfully ensured execution-specific directory exists: {}", executionSpecificDir.getAbsolutePath()); + } catch (IOException e) { + String errorMsg = "Failed to create execution-specific directory: " + executionSpecificDir.getAbsolutePath(); + log.error(errorMsg, e); + throw new IllegalStateException(errorMsg, e); } return executionSpecificDir.getAbsolutePath(); } diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index 554a0cd1..d3b95bf4 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -620,7 +620,7 @@ class AnsibleRunnerSpec extends Specification{ "-starts-with-dash" || "\"-starts-with-dash\"" "?starts-with-q" || "\"?starts-with-q\"" "123numeric" || "\"123numeric\"" - "key with spaces" || "key with spaces" // spaces are valid in unquoted YAML keys, so we intentionally do not quote them + "key with spaces" || "key with spaces" // internal spaces are valid in unquoted YAML keys; node names with leading/trailing spaces are not supported } def "escapeYamlValue: should quote values with special characters"() { @@ -761,7 +761,7 @@ class AnsibleRunnerSpec extends Specification{ !yaml.contains("host_passwords:") // Should not appear when empty !yaml.contains("host_users:") // Should not appear when empty !yaml.contains("host_private_keys:") // Should not appear when empty - yaml.isEmpty() || yaml.trim().isEmpty() // Should produce empty or whitespace-only output + yaml.isEmpty() // Should produce empty output when all host lists are empty } def "escapePasswordForYaml: should escape special characters"() { @@ -1009,6 +1009,10 @@ class AnsibleRunnerSpec extends Specification{ // Test case from real issue: node name with forward slashes String nodeName = "/docker-runner-ansible-ssh-node-b-3" String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { + sanitized = "_" + sanitized + } expect: sanitized == "_docker-runner-ansible-ssh-node-b-3" @@ -1017,8 +1021,15 @@ class AnsibleRunnerSpec extends Specification{ } def "node name sanitization: should sanitize various special characters"() { + given: + String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { + sanitized = "_" + sanitized + } + expect: - nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") == expected + sanitized == expected where: nodeName || expected @@ -1041,24 +1052,37 @@ class AnsibleRunnerSpec extends Specification{ given: String safeName = "my-node_123.server-A" String sanitized = safeName.replaceAll("[^a-zA-Z0-9._-]", "_") + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { + sanitized = "_" + sanitized + } expect: sanitized == safeName // Should not be changed } def "node name sanitization: should handle empty and edge case node names"() { + given: + String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { + sanitized = "_" + sanitized + } + expect: - nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") == expected + sanitized == expected where: nodeName || expected - "" || "" + "" || "_" // empty -> prepend underscore "123" || "123" - "..." || "..." + "..." || "_..." // all dots -> prepend underscore to avoid filesystem issues "---" || "---" "___" || "___" "a" || "a" "/////" || "_____" + ".hidden" || "_.hidden" // starts with dot -> prepend underscore to prevent hidden files + ".config" || "_.config" // starts with dot -> prepend underscore to prevent hidden files } } From 636cede3f06d11d3d0c049c26ffecc3d270ead19 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 01:05:33 -0300 Subject: [PATCH 31/46] copilot comments --- functional-test/build.gradle | 11 ++-- .../ansible/ansible/AnsibleDescribable.java | 2 +- .../ansible/ansible/AnsibleRunner.java | 56 ++++++++++++++++--- .../ansible/AnsibleRunnerContextBuilder.java | 2 +- .../plugin/AnsibleModuleWorkflowStep.java | 6 +- ...AnsiblePlaybookInlineWorkflowNodeStep.java | 6 +- .../AnsiblePlaybookInlineWorkflowStep.java | 6 +- .../AnsiblePlaybookWorflowNodeStep.java | 6 +- .../plugin/AnsiblePlaybookWorkflowStep.java | 6 +- .../plugins/ansible/util/AnsibleUtil.java | 26 +++++++++ .../ansible/ansible/AnsibleRunnerSpec.groovy | 28 +++------- 11 files changed, 95 insertions(+), 60 deletions(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 3cba6635..419b4f6b 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -44,11 +44,12 @@ tasks.register('functionalTest', Test) { useJUnitPlatform() // Rundeck test image version - // NOTE: Using Rundeck 5.18.0 for functional testing. This version upgrade from the - // previously used 5.1.1 ensures compatibility with newer Rundeck releases and - // validates that the multi-node authentication feature works correctly with - // current Rundeck APIs and behavior. If the plugin's minimum supported Rundeck - // version changes, update both this test image and the plugin documentation. + // NOTE: Functional tests run against Rundeck 5.18.0. This is an upgrade from the + // previously used 5.1.1 to validate compatibility with newer Rundeck releases and + // ensure the multi-node authentication feature works correctly with current + // Rundeck APIs and behavior. The plugin's minimum supported Rundeck version is + // defined in the main README/changelog; updating that minimum version should be + // accompanied by updating this test image tag accordingly. systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") // Docker configuration for Testcontainers diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java index 26e5e1ae..7fff5b83 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleDescribable.java @@ -244,7 +244,7 @@ public static String[] getValues() { .booleanType(ANSIBLE_GENERATE_INVENTORY_NODES_AUTH) .required(false) .title("Workflow Step: Generate Inventory and Pass Node Authentication from Rundeck Nodes") - .description("Pass authentication credentials from Rundeck nodes. IMPORTANT: Only applies to Ansible Playbook Workflow Steps. This feature does NOT work with Node Steps because they run independently per node and do not share the global inventory context.") + .description("Pass authentication credentials from Rundeck nodes for Ansible Playbook Workflow Steps only (not supported for Node Steps). See the Ansible plugin documentation for details.") .build(); public static Property EXECUTABLE_PROP = PropertyUtil.freeSelect( diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 4afef2f5..14ec8a0f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -301,6 +301,7 @@ public static AnsibleRunner buildAnsibleRunner(AnsibleRunnerContextBuilder conte File vaultPromptFile; File tempNodeAuthFile; File groupVarsDir; + List tempNodePrivateKeyFiles; String customTmpDirPath; @@ -433,6 +434,9 @@ public int run() throws Exception { Map hostPasswords = new LinkedHashMap<>(); Map hostKeys = new LinkedHashMap<>(); + // Track node-specific private key files for explicit cleanup + tempNodePrivateKeyFiles = new ArrayList<>(); + nodesAuthentication.forEach((nodeName, authValues) -> { String user = authValues.get("ansible_user"); String password = authValues.get("ansible_password"); @@ -459,12 +463,8 @@ public int run() throws Exception { //create temporary file for private key File tempHostPkFile; try { - // Sanitize node name for filesystem use (replace unsafe characters with underscores) - String safeNodeName = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_"); - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (safeNodeName.isEmpty() || safeNodeName.startsWith(".") || safeNodeName.matches("^\\.+$")) { - safeNodeName = "_" + safeNodeName; - } + // Sanitize node name for filesystem use + String safeNodeName = sanitizeNodeNameForFilesystem(nodeName); if(debug && !nodeName.equals(safeNodeName)) { System.err.println("DEBUG: Sanitized node name '" + nodeName + "' to '" + safeNodeName + "' for temp file"); } @@ -477,7 +477,8 @@ public int run() throws Exception { hostKeys.put(nodeName, tempHostPkFile.getAbsolutePath()); - tempHostPkFile.deleteOnExit(); + // Track for explicit cleanup to minimize exposure time of sensitive credentials + tempNodePrivateKeyFiles.add(tempHostPkFile); } catch (IOException e) { throw new RuntimeException("Failed to create temporary private key file for node '" + nodeName + "': " + e.getMessage(), e); @@ -517,6 +518,9 @@ public int run() throws Exception { } try { + // Use Files.createDirectories() which is idempotent (safe to call if directory exists) + // and handles race conditions properly. Unlike mkdirs(), it doesn't return false + // when the directory already exists - it only throws IOException on actual failure. Files.createDirectories(groupVarsDir.toPath()); } catch (IOException e) { throw new RuntimeException("Failed to create group_vars directory at: " + groupVarsDir.getAbsolutePath(), e); @@ -530,6 +534,12 @@ public int run() throws Exception { java.nio.charset.StandardCharsets.UTF_8 ); + // Alert administrators that sensitive files are being created in their inventory directory + log.warn("Writing vault-encrypted authentication data to user-provided inventory location: {}. " + + "Administrators should ensure this directory has appropriate filesystem permissions. " + + "This file will be cleaned up after execution, but may persist if cleanup fails.", + tempNodeAuthFile.getAbsolutePath()); + if(debug) { System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); System.err.println("DEBUG: all.yaml written successfully, file size: " + tempNodeAuthFile.length() + " bytes"); @@ -608,7 +618,9 @@ public int run() throws Exception { // Set SSH private key permissions to 0400 (owner read-only). // This is a security best practice: private keys should never be writable after creation. // SSH itself will warn or refuse to use keys with overly permissive permissions (e.g., 0600). - // This temporary file is immutable and will be deleted after use. + // Write permission is unnecessary since this temporary file is created once, read by SSH, + // and never modified. The file will be deleted after use. This matches the permissions + // used for node-specific private keys (lines 469-472). Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); @@ -848,6 +860,15 @@ public int run() throws Exception { tempNodeAuthFile.deleteOnExit(); } + // Clean up node-specific private key files to minimize exposure time of sensitive credentials + if (tempNodePrivateKeyFiles != null) { + for (File nodeKeyFile : tempNodePrivateKeyFiles) { + if (nodeKeyFile != null && !nodeKeyFile.delete()) { + nodeKeyFile.deleteOnExit(); + } + } + } + // Clean up group_vars directory if it was created alongside user-provided inventory // Note: When inventory is generated/inline, group_vars is inside executionSpecificDir // and is cleaned up by AnsibleRunnerContextBuilder.cleanupTempFiles() @@ -1246,6 +1267,25 @@ public String encryptExtraVarsKey(String extraVars) throws Exception { return stringBuilder.toString(); } + /** + * Sanitizes a node name to be safe for use in filesystem paths. + * Replaces unsafe characters with underscores and prevents hidden files and problematic names. + * + * @param nodeName The original node name + * @return A sanitized node name safe for use in file paths + */ + String sanitizeNodeNameForFilesystem(String nodeName) { + // Replace unsafe characters with underscores + String safeNodeName = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_"); + + // Prevent hidden files (starting with .) and problematic names (empty or all dots) + if (safeNodeName.isEmpty() || safeNodeName.startsWith(".") || safeNodeName.matches("^\\.+$")) { + safeNodeName = "_" + safeNodeName; + } + + return safeNodeName; + } + } diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 36b698a8..f39c335f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -165,7 +165,7 @@ public String getSshPrivateKey(INodeEntry node) throws ConfigurationException { final String path = getPrivateKeyfilePath(); if (path != null) { try { - return new String(Files.readAllBytes(Paths.get(path))); + return new String(Files.readAllBytes(Paths.get(path)), java.nio.charset.StandardCharsets.UTF_8); } catch (IOException e) { throw new ConfigurationException("Failed to read the ssh private key from path " + path + ": " + e.getMessage()); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java index 706f4369..18acc085 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsibleModuleWorkflowStep.java @@ -72,11 +72,7 @@ public void executeStep(PluginStepContext context, Map configura } // set log level - String loglevel = null; - if (context.getDataContext() != null && - context.getDataContext().get("job") != null) { - loglevel = context.getDataContext().get("job").get("loglevel"); - } + String loglevel = AnsibleUtil.getJobLogLevel(context); if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java index c95a6e2f..b633cf91 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowNodeStep.java @@ -79,11 +79,7 @@ public void executeNodeStep( configuration.put(AnsibleDescribable.ANSIBLE_LIMIT,entry.getNodename()); // set log level - String loglevel = null; - if (context.getDataContext() != null && - context.getDataContext().get("job") != null) { - loglevel = context.getDataContext().get("job").get("loglevel"); - } + String loglevel = AnsibleUtil.getJobLogLevel(context); if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"True"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java index 01cc5fc9..96c1ab9f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookInlineWorkflowStep.java @@ -87,11 +87,7 @@ public void executeStep(PluginStepContext context, Map configura configuration.put(AnsibleDescribable.ANSIBLE_LIMIT, limit); } // set log level - String loglevel = null; - if (context.getDataContext() != null && - context.getDataContext().get("job") != null) { - loglevel = context.getDataContext().get("job").get("loglevel"); - } + String loglevel = AnsibleUtil.getJobLogLevel(context); if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java index f44fa2ef..6a5dea27 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorflowNodeStep.java @@ -76,11 +76,7 @@ public void executeNodeStep( configuration.put(AnsibleDescribable.ANSIBLE_LIMIT,entry.getNodename()); // set log level - String loglevel = null; - if (context.getDataContext() != null && - context.getDataContext().get("job") != null) { - loglevel = context.getDataContext().get("job").get("loglevel"); - } + String loglevel = AnsibleUtil.getJobLogLevel(context); if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG,"True"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java index f193d869..4495c9e6 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/plugin/AnsiblePlaybookWorkflowStep.java @@ -85,11 +85,7 @@ public void executeStep(PluginStepContext context, Map configura configuration.put(AnsibleDescribable.ANSIBLE_LIMIT, limit); } // set log level - String loglevel = null; - if (context.getDataContext() != null && - context.getDataContext().get("job") != null) { - loglevel = context.getDataContext().get("job").get("loglevel"); - } + String loglevel = AnsibleUtil.getJobLogLevel(context); if ("DEBUG".equals(loglevel)) { configuration.put(AnsibleDescribable.ANSIBLE_DEBUG, "True"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java b/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java index 9be0accd..60f4baf7 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/util/AnsibleUtil.java @@ -140,5 +140,31 @@ public static String getCustomTmpPathDir(Framework framework){ return customTmpDir; } + /** + * Safely retrieves the loglevel from the execution context's data context. + * Returns null if the loglevel cannot be retrieved (e.g., if data context or job is null). + * + * @param context The execution context + * @return The loglevel string, or null if not available + */ + public static String getJobLogLevel(ExecutionContext context) { + if (context.getDataContext() != null && + context.getDataContext().get("job") != null) { + return context.getDataContext().get("job").get("loglevel"); + } + return null; + } + + /** + * Safely retrieves the loglevel from the plugin step context's data context. + * Returns null if the loglevel cannot be retrieved (e.g., if data context or job is null). + * + * @param context The plugin step context + * @return The loglevel string, or null if not available + */ + public static String getJobLogLevel(PluginStepContext context) { + return getJobLogLevel(context.getExecutionContext()); + } + } diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index d3b95bf4..df6575af 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -1007,12 +1007,9 @@ class AnsibleRunnerSpec extends Specification{ def "node name sanitization: should sanitize node names with forward slashes for temp files"() { given: // Test case from real issue: node name with forward slashes + def runner = AnsibleRunner.playbookInline("test").build() String nodeName = "/docker-runner-ansible-ssh-node-b-3" - String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { - sanitized = "_" + sanitized - } + String sanitized = runner.sanitizeNodeNameForFilesystem(nodeName) expect: sanitized == "_docker-runner-ansible-ssh-node-b-3" @@ -1022,11 +1019,8 @@ class AnsibleRunnerSpec extends Specification{ def "node name sanitization: should sanitize various special characters"() { given: - String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { - sanitized = "_" + sanitized - } + def runner = AnsibleRunner.playbookInline("test").build() + String sanitized = runner.sanitizeNodeNameForFilesystem(nodeName) expect: sanitized == expected @@ -1050,12 +1044,9 @@ class AnsibleRunnerSpec extends Specification{ def "node name sanitization: should preserve safe alphanumeric and allowed characters"() { given: + def runner = AnsibleRunner.playbookInline("test").build() String safeName = "my-node_123.server-A" - String sanitized = safeName.replaceAll("[^a-zA-Z0-9._-]", "_") - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { - sanitized = "_" + sanitized - } + String sanitized = runner.sanitizeNodeNameForFilesystem(safeName) expect: sanitized == safeName // Should not be changed @@ -1063,11 +1054,8 @@ class AnsibleRunnerSpec extends Specification{ def "node name sanitization: should handle empty and edge case node names"() { given: - String sanitized = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_") - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (sanitized.isEmpty() || sanitized.startsWith(".") || sanitized.matches(/^\.\+$/)) { - sanitized = "_" + sanitized - } + def runner = AnsibleRunner.playbookInline("test").build() + String sanitized = runner.sanitizeNodeNameForFilesystem(nodeName) expect: sanitized == expected From 69f0b9dedf3f9af4dca6531ad1a2eeb8f249c9f0 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 09:22:44 -0300 Subject: [PATCH 32/46] Copilot comments --- .../docker/ansible-multi-node-auth/README.md | 17 +++++++++++------ .../plugins/ansible/ansible/AnsibleRunner.java | 4 ++-- .../ansible/AnsibleRunnerContextBuilder.java | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md index 384af8bf..05d5f88e 100644 --- a/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md @@ -1,6 +1,6 @@ # Multi-Node Authentication Functional Test -This test verifies the multi-node authentication feature where each node can have its own password stored in Rundeck's key storage. +This test verifies the multi-node authentication feature where each node can have its own credentials (password or private key) stored in Rundeck's key storage. ## Test Setup @@ -8,18 +8,20 @@ This test verifies the multi-node authentication feature where each node can hav - **ssh-node**: Standard password (`testpassword123`) - **ssh-node-2**: Password with special characters (`password2_special!@#`) - **ssh-node-3**: Password with quotes (`password3"quote'test`) +- **ssh-node-4**: Private key authentication (uses SSH key from key storage) ### Files -- `resources.xml`: Defines three nodes with node-specific password storage paths +- `resources.xml`: Defines four nodes with node-specific authentication (three with password storage paths, one with private key storage path) - `inventory.ini`: Ansible inventory file for the test nodes - `ansible.cfg`: Ansible configuration - `test-playbook.yml`: Test playbook for verification ## What's Being Tested -1. **Multi-node authentication with different passwords per node** - - Each node has a different password stored in Rundeck key storage - - The plugin generates `group_vars/all.yaml` with vault-encrypted passwords +1. **Multi-node authentication with different credentials per node** + - Three nodes have different passwords stored in Rundeck key storage + - One node uses private key authentication stored in Rundeck key storage + - The plugin generates `group_vars/all.yaml` with vault-encrypted passwords and private key paths - Each node authenticates with its specific credentials 2. **Password escaping for special characters** @@ -52,6 +54,9 @@ This test verifies the multi-node authentication feature where each node can hav ssh-node: rundeck ssh-node-2: rundeck ssh-node-3: rundeck + ssh-node-4: rundeck + host_private_keys: + ssh-node-4: /path/to/temporary/key ``` 6. Ansible uses host-specific credentials from group_vars -7. Each node authenticates successfully with its own password +7. Each node authenticates successfully with its own credentials (password or private key) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 14ec8a0f..fa48a965 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -423,7 +423,7 @@ public int run() throws Exception { host_passwords: host1: enc(password1) - host2: enc(password1) + host2: enc(password2) host_private_keys: host1: /path/to/private_key1 @@ -620,7 +620,7 @@ public int run() throws Exception { // SSH itself will warn or refuse to use keys with overly permissive permissions (e.g., 0600). // Write permission is unnecessary since this temporary file is created once, read by SSH, // and never modified. The file will be deleted after use. This matches the permissions - // used for node-specific private keys (lines 469-472). + // used for node-specific private keys (lines 474-476). Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index f39c335f..895ac862 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -1075,7 +1075,7 @@ public Map> getNodesAuthenticationMap(){ String userName = getSshNodeUser(node); if(null!=userName){ - auth.put("ansible_user",userName ); + auth.put("ansible_user", userName); } // Validate that node has at least one authentication method configured From f40645c62e154f7827763c2a4a064dd8d83cb779 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 09:39:57 -0300 Subject: [PATCH 33/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/rundeck/plugins/ansible/ansible/AnsibleRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index fa48a965..908dffac 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -1030,7 +1030,7 @@ String buildGroupVarsYaml(Map hostPasswords, Map } } else { // Single line value (shouldn't happen with vault, but handle it) - log.debug("Single line vault value for host: {}", originalKey); + log.warn("Single line vault value for host: {}", originalKey); yamlContent.append(vaultValue).append("\n"); } } From 46e56dbf5438ca3d583e9cfe42bcaec9ef4e82cf Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 09:40:41 -0300 Subject: [PATCH 34/46] Update functional-test/build.gradle Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- functional-test/build.gradle | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/functional-test/build.gradle b/functional-test/build.gradle index 419b4f6b..4ae22bf2 100644 --- a/functional-test/build.gradle +++ b/functional-test/build.gradle @@ -44,12 +44,10 @@ tasks.register('functionalTest', Test) { useJUnitPlatform() // Rundeck test image version - // NOTE: Functional tests run against Rundeck 5.18.0. This is an upgrade from the - // previously used 5.1.1 to validate compatibility with newer Rundeck releases and - // ensure the multi-node authentication feature works correctly with current - // Rundeck APIs and behavior. The plugin's minimum supported Rundeck version is - // defined in the main README/changelog; updating that minimum version should be - // accompanied by updating this test image tag accordingly. + // Minimum supported Rundeck version for this plugin: 5.1.1 (see main README/changelog). + // Functional tests intentionally run against a newer version, Rundeck 5.18.0, to validate + // compatibility with current releases. When raising the minimum supported version, update + // this test image tag in tandem. systemProperty('RUNDECK_TEST_IMAGE', "rundeck/rundeck:5.18.0") // Docker configuration for Testcontainers From 27b2e6bb96f05ffe0a677a085d7320cf89927f21 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 09:40:59 -0300 Subject: [PATCH 35/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/rundeck/plugins/ansible/ansible/AnsibleRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 908dffac..16f782d4 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -535,7 +535,7 @@ public int run() throws Exception { ); // Alert administrators that sensitive files are being created in their inventory directory - log.warn("Writing vault-encrypted authentication data to user-provided inventory location: {}. " + + log.info("Writing vault-encrypted authentication data to user-provided inventory location: {}. " + "Administrators should ensure this directory has appropriate filesystem permissions. " + "This file will be cleaned up after execution, but may persist if cleanup fails.", tempNodeAuthFile.getAbsolutePath()); From 958f407b023d76a6c1a110e4ce366fc24533f083 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 09:41:57 -0300 Subject: [PATCH 36/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/rundeck/plugins/ansible/ansible/AnsibleRunner.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 16f782d4..c6b77910 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -619,8 +619,8 @@ public int run() throws Exception { // This is a security best practice: private keys should never be writable after creation. // SSH itself will warn or refuse to use keys with overly permissive permissions (e.g., 0600). // Write permission is unnecessary since this temporary file is created once, read by SSH, - // and never modified. The file will be deleted after use. This matches the permissions - // used for node-specific private keys (lines 474-476). + // and never modified. The file will be deleted after use. + // Node-specific private keys created elsewhere in this class should use the same 0400 permissions for consistency. Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); From 19045a87f70170572c2d67d87e1c8274b5b0bbb3 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:10:26 -0300 Subject: [PATCH 37/46] copilot comments --- .../ansible/ansible/AnsibleRunner.java | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index c6b77910..aefed428 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -1081,6 +1081,29 @@ private void appendYamlMapSection(StringBuilder yamlContent, String sectionName, } } + /** + * Pattern for detecting YAML special characters that require quoting. + * Includes: : [ ] { } # & * ! | > ' " % @ ` \ + */ + private static final java.util.regex.Pattern YAML_SPECIAL_CHARS_PATTERN = + java.util.regex.Pattern.compile(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*"); + + /** + * Checks if a string needs YAML quoting based on special characters and prefixes. + * + * @param value The string to check + * @param checkNumericPrefix If true, requires quoting if value starts with a digit + * @param checkEmpty If true, requires quoting if value is empty after trimming + * @return true if the string needs to be wrapped in quotes + */ + private boolean needsYamlQuoting(String value, boolean checkNumericPrefix, boolean checkEmpty) { + return YAML_SPECIAL_CHARS_PATTERN.matcher(value).matches() || + value.startsWith("-") || + value.startsWith("?") || + (checkNumericPrefix && value.matches("^[0-9].*")) || + (checkEmpty && value.trim().isEmpty()); + } + /** * Escapes YAML special characters in keys. * If the key contains special characters, wraps it in quotes. @@ -1089,11 +1112,7 @@ private void appendYamlMapSection(StringBuilder yamlContent, String sectionName, * @return Escaped key safe for YAML */ String escapeYamlKey(String key) { - // YAML special characters that require quoting (including backslash) - if (key.matches(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*") || - key.startsWith("-") || - key.startsWith("?") || - key.matches("^[0-9].*")) { + if (needsYamlQuoting(key, true, false)) { // IMPORTANT: Order of replacements matters! Must escape backslashes FIRST, then quotes. // If we escaped quotes first, the backslash replacement would double-escape them. // Example: value="a\"b" -> replace quotes: "a\\"b" -> replace backslash: "a\\\\"b" (WRONG!) @@ -1111,11 +1130,7 @@ String escapeYamlKey(String key) { * @return Escaped value safe for YAML */ String escapeYamlValue(String value) { - // Check if value needs quoting (including backslash) - if (value.matches(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*") || - value.startsWith("-") || - value.startsWith("?") || - value.trim().isEmpty()) { + if (needsYamlQuoting(value, false, true)) { // IMPORTANT: Order of replacements matters! Must escape backslashes FIRST, then quotes. // If we escaped quotes first, the backslash replacement would double-escape them. // Example: value="a\"b" -> replace quotes: "a\\"b" -> replace backslash: "a\\\\"b" (WRONG!) From 93a0f7459441b801d1333262a9bf68f4cc8189a3 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:15:27 -0300 Subject: [PATCH 38/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/rundeck/plugins/ansible/ansible/AnsibleRunner.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index aefed428..163b428a 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -1293,8 +1293,8 @@ String sanitizeNodeNameForFilesystem(String nodeName) { // Replace unsafe characters with underscores String safeNodeName = nodeName.replaceAll("[^a-zA-Z0-9._-]", "_"); - // Prevent hidden files (starting with .) and problematic names (empty or all dots) - if (safeNodeName.isEmpty() || safeNodeName.startsWith(".") || safeNodeName.matches("^\\.+$")) { + // Prevent hidden files (starting with .) and problematic names (empty) + if (safeNodeName.isEmpty() || safeNodeName.startsWith(".")) { safeNodeName = "_" + safeNodeName; } From 01e918fd08ec747750738c72fd8a3c5553dcd6cb Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:40:15 -0300 Subject: [PATCH 39/46] Update functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml index 3a159b54..7fd7aefb 100644 --- a/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml +++ b/functional-test/src/test/resources/project-import/ansible-multi-node-auth/rundeck-ansible-multi-node-auth/jobs/job-multi-node-ping.xml @@ -10,6 +10,7 @@ 3 true + a1b2c3d4-e5f6-7890-abcd-ef1234567890 INFO multi-node-ping-test From bc32a3bb8515568c2e73fd1ad0577ada9dbd63f9 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:41:07 -0300 Subject: [PATCH 40/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/rundeck/plugins/ansible/ansible/AnsibleRunner.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 163b428a..07749ae0 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -573,8 +573,7 @@ public int run() throws Exception { } } catch (IOException e) { - System.err.println("ERROR: Failed to write all.yaml: " + e.getMessage()); - e.printStackTrace(); + log.error("ERROR: Failed to write all.yaml for node auth", e); throw new RuntimeException("Failed to write all.yaml for node auth", e); } } From 6b4e92111f3ca5064e8fa1e80adf1c5171f64244 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:42:36 -0300 Subject: [PATCH 41/46] Update src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../plugins/ansible/ansible/AnsibleRunner.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 07749ae0..81201214 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -614,14 +614,14 @@ public int run() throws Exception { String privateKeyData = sshPrivateKey.replaceAll("\r\n", "\n"); tempPkFile = AnsibleUtil.createTemporaryFile("","id_rsa", privateKeyData,customTmpDirPath); - // Set SSH private key permissions to 0400 (owner read-only). - // This is a security best practice: private keys should never be writable after creation. - // SSH itself will warn or refuse to use keys with overly permissive permissions (e.g., 0600). - // Write permission is unnecessary since this temporary file is created once, read by SSH, - // and never modified. The file will be deleted after use. - // Node-specific private keys created elsewhere in this class should use the same 0400 permissions for consistency. + // Set SSH private key permissions to 0600 (owner read/write). + // This keeps the key private (not accessible by group/world) while preserving backward + // compatibility for workflows that may need to modify or reuse this temporary file. + // SSH itself will warn or refuse to use keys with overly permissive permissions, so we + // restrict access to the file owner only. Set perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); + perms.add(PosixFilePermission.OWNER_WRITE); Files.setPosixFilePermissions(tempPkFile.toPath(), perms); if (sshUseAgent) { From 2784ac963f2d3d1af9f73eb8a3295a3e55684175 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 12:59:03 -0300 Subject: [PATCH 42/46] Copilot comments --- .../ansible/ansible/AnsibleRunner.java | 19 ++- .../ansible/AnsibleRunnerContextBuilder.java | 110 ++++++------------ 2 files changed, 53 insertions(+), 76 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 81201214..335d15e5 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -1082,7 +1082,24 @@ private void appendYamlMapSection(StringBuilder yamlContent, String sectionName, /** * Pattern for detecting YAML special characters that require quoting. - * Includes: : [ ] { } # & * ! | > ' " % @ ` \ + * Matches any string containing one or more of these characters: + *
    + *
  • : - colon (key-value separator)
  • + *
  • [ ] - square brackets (array notation)
  • + *
  • { } - curly braces (object notation)
  • + *
  • # - hash (comment marker)
  • + *
  • & - ampersand (anchor reference)
  • + *
  • * - asterisk (alias reference)
  • + *
  • ! - exclamation (tag indicator)
  • + *
  • | - pipe (literal block scalar)
  • + *
  • > - greater-than (folded block scalar)
  • + *
  • ' " - quotes (string delimiters)
  • + *
  • % - percent (directive indicator)
  • + *
  • @ - at-sign (reserved for future use)
  • + *
  • ` - backtick (reserved for future use)
  • + *
  • \ - backslash (escape character)
  • + *
+ * Regex pattern: .*[:\\[\\]{}#&*!|>'\"%@`\\\\].* */ private static final java.util.regex.Pattern YAML_SPECIAL_CHARS_PATTERN = java.util.regex.Pattern.compile(".*[:\\[\\]{}#&*!|>'\"%@`\\\\].*"); diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 895ac862..40c6e5f5 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -845,7 +845,8 @@ public void cleanupTempFiles() { } /** - * Recursively deletes a directory and all its contents. + * Recursively deletes a directory and all its contents using Java NIO. + * Uses Files.walk() for robust directory traversal and handles errors gracefully. * * @param directory The directory to delete * @return true if the directory and all its contents were successfully deleted, false otherwise @@ -855,30 +856,22 @@ private boolean deleteDirectoryRecursively(File directory) { return true; } - boolean success = true; - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - if (!deleteDirectoryRecursively(file)) { - success = false; - log.warn("Failed to delete subdirectory: {}", file.getAbsolutePath()); - } - } else { - if (!file.delete()) { - success = false; - log.warn("Failed to delete file: {}", file.getAbsolutePath()); - } - } - } - } - - if (!directory.delete()) { - success = false; - log.warn("Failed to delete directory: {}", directory.getAbsolutePath()); + try { + // Walk the file tree in depth-first order (reverse) to delete files before their parent directories + Files.walk(directory.toPath()) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.delete(path); + } catch (java.io.IOException e) { + log.warn("Failed to delete: {}", path.toAbsolutePath(), e); + } + }); + return true; + } catch (java.io.IOException e) { + log.warn("Failed to walk directory tree for deletion: {}", directory.getAbsolutePath(), e); + return false; } - - return success; } public Boolean getUseSshAgent() { @@ -1021,52 +1014,34 @@ public Map> getNodesAuthenticationMap(){ Map auth = new HashMap<>(); final AuthenticationType authType = getSshAuthenticationType(node); - if (getDebug()) { - System.err.println("DEBUG: Processing authentication for node '" + node.getNodename() + - "' with auth type: " + authType); - } + log.debug("Processing authentication for node '{}' with auth type: {}", node.getNodename(), authType); if (AnsibleDescribable.AuthenticationType.privateKey == authType) { final String privateKey; try { privateKey = getSshPrivateKey(node); - if (getDebug()) { - System.err.println("DEBUG: Retrieved private key for node '" + - node.getNodename() + "': " + (privateKey != null ? ("yes, length=" + privateKey.length()) : "null")); - } + log.debug("Retrieved private key for node '{}': {}", node.getNodename(), + (privateKey != null ? ("yes, length=" + privateKey.length()) : "null")); } catch (ConfigurationException e) { - if (getDebug()) { - System.err.println("DEBUG: Error retrieving private key for node '" + - node.getNodename() + "': " + e.getMessage()); - } + log.debug("Error retrieving private key for node '{}': {}", node.getNodename(), e.getMessage()); throw new RuntimeException("Failed to retrieve private key for node '" + node.getNodename() + "': " + e.getMessage(), e); } if (privateKey != null) { auth.put("ansible_ssh_private_key", privateKey); - if (getDebug()) { - System.err.println("DEBUG: Added private key to auth map for node '" + node.getNodename() + "'"); - } + log.debug("Added private key to auth map for node '{}'", node.getNodename()); } else { - if (getDebug()) { - System.err.println("DEBUG: Private key is null for node '" + node.getNodename() + "', not adding to auth map"); - } + log.debug("Private key is null for node '{}', not adding to auth map", node.getNodename()); } } else if (AnsibleDescribable.AuthenticationType.password == authType) { try { String password = getSshPassword(node); if(null!=password){ auth.put("ansible_password", password); - if (getDebug()) { - System.err.println("DEBUG: Successfully retrieved password for node '" + - node.getNodename() + "'"); - } + log.debug("Successfully retrieved password for node '{}'", node.getNodename()); } } catch (ConfigurationException e) { - if (getDebug()) { - System.err.println("DEBUG: Error retrieving password for node '" + - node.getNodename() + "': " + e.getMessage()); - } + log.debug("Error retrieving password for node '{}': {}", node.getNodename(), e.getMessage()); throw new RuntimeException("Failed to retrieve password for node '" + node.getNodename() + "': " + e.getMessage(), e); } @@ -1085,10 +1060,8 @@ public Map> getNodesAuthenticationMap(){ if (!hasPassword && !hasPrivateKey) { context.getExecutionLogger().log(2, "WARNING: Node '" + node.getNodename() + "' has no password or private key configured. Authentication may fail."); - if (getDebug()) { - System.err.println("DEBUG: Node '" + node.getNodename() + - "' has no credentials configured (only username: " + (auth.containsKey("ansible_user") ? "yes" : "no") + ")"); - } + log.debug("Node '{}' has no credentials configured (only username: {})", + node.getNodename(), (auth.containsKey("ansible_user") ? "yes" : "no")); } authenticationNodesMap.put(node.getNodename(), auth); @@ -1145,11 +1118,9 @@ public List getListNodesKeyPath(){ public Boolean generateInventoryNodesAuth() { Boolean generateInventoryNodesAuth = null; - if(getDebug()) { - System.err.println("DEBUG: Resolving property ANSIBLE_GENERATE_INVENTORY_NODES_AUTH"); - System.err.println("DEBUG: Property key: " + AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); - System.err.println("DEBUG: Framework project: " + getFrameworkProject()); - } + log.debug("Resolving property ANSIBLE_GENERATE_INVENTORY_NODES_AUTH"); + log.debug("Property key: {}", AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH); + log.debug("Framework project: {}", getFrameworkProject()); String sgenerateInventoryNodesAuth = PropertyResolver.resolveProperty( AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH, @@ -1160,19 +1131,13 @@ public Boolean generateInventoryNodesAuth() { getJobConf() ); - if(getDebug()) { - System.err.println("DEBUG: PropertyResolver returned: " + sgenerateInventoryNodesAuth); - } + log.debug("PropertyResolver returned: {}", sgenerateInventoryNodesAuth); if (null != sgenerateInventoryNodesAuth) { generateInventoryNodesAuth = Boolean.parseBoolean(sgenerateInventoryNodesAuth); - if(getDebug()) { - System.err.println("DEBUG: Parsed to boolean: " + generateInventoryNodesAuth); - } + log.debug("Parsed to boolean: {}", generateInventoryNodesAuth); } else { - if(getDebug()) { - System.err.println("DEBUG: Property not found, returning null"); - } + log.debug("Property not found, returning null"); } return generateInventoryNodesAuth; @@ -1188,9 +1153,7 @@ public Boolean generateInventoryNodesAuth() { String getExecutionSpecificTmpDir() { // Return cached directory if already created if (executionSpecificDir != null) { - if (getDebug()) { - System.err.println("DEBUG: Using cached execution-specific directory: " + executionSpecificDir.getAbsolutePath()); - } + log.debug("Using cached execution-specific directory: {}", executionSpecificDir.getAbsolutePath()); return executionSpecificDir.getAbsolutePath(); } @@ -1199,10 +1162,7 @@ String getExecutionSpecificTmpDir() { // Get execution ID from data context if (context.getDataContext() != null && context.getDataContext().get("job") != null) { executionId = context.getDataContext().get("job").get("execid"); - - if (getDebug()) { - System.err.println("DEBUG: Execution ID from context: " + executionId); - } + log.debug("Execution ID from context: {}", executionId); } // Get base tmp directory From a4356009a33417c5b2956700a4f7569e2379bd67 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 13:56:29 -0300 Subject: [PATCH 43/46] Copilot comments debug --- .../ansible/ansible/AnsibleRunner.java | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 335d15e5..31f0356a 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -465,8 +465,8 @@ public int run() throws Exception { try { // Sanitize node name for filesystem use String safeNodeName = sanitizeNodeNameForFilesystem(nodeName); - if(debug && !nodeName.equals(safeNodeName)) { - System.err.println("DEBUG: Sanitized node name '" + nodeName + "' to '" + safeNodeName + "' for temp file"); + if(!nodeName.equals(safeNodeName)) { + log.debug("Sanitized node name '{}' to '{}' for temp file", nodeName, safeNodeName); } tempHostPkFile = AnsibleUtil.createTemporaryFile("","id_rsa_node_"+safeNodeName, privateKey,customTmpDirPath); @@ -489,10 +489,8 @@ public int run() throws Exception { // Build YAML content using helper method String yamlContent = buildGroupVarsYaml(hostPasswords, hostUsers, hostKeys); - if(debug){ - System.err.println("DEBUG: Building group_vars YAML with " + hostPasswords.size() + " passwords, " + hostUsers.size() + " users, and " + hostKeys.size() + " private keys"); - System.err.println("DEBUG: YAML content built successfully, length: " + yamlContent.length()); - } + log.debug("Building group_vars YAML with {} passwords, {} users, and {} private keys", hostPasswords.size(), hostUsers.size(), hostKeys.size()); + log.debug("YAML content built successfully, length: {}", yamlContent.length()); try { @@ -505,17 +503,13 @@ public int run() throws Exception { File inventoryFile = new File(inventory); File inventoryParentDir = inventoryFile.getParentFile(); - if(debug) { - System.err.println("DEBUG: inventoryFile: " + inventoryFile.getAbsolutePath()); - System.err.println("DEBUG: inventory file exists: " + inventoryFile.exists()); - System.err.println("DEBUG: inventoryParentDir: " + (inventoryParentDir != null ? inventoryParentDir.getAbsolutePath() : "null")); - } + log.debug("inventoryFile: {}", inventoryFile.getAbsolutePath()); + log.debug("inventory file exists: {}", inventoryFile.exists()); + log.debug("inventoryParentDir: {}", (inventoryParentDir != null ? inventoryParentDir.getAbsolutePath() : "null")); if (inventoryParentDir != null) { groupVarsDir = new File(inventoryParentDir, "group_vars"); - if(debug) { - System.err.println("DEBUG: group_vars directory path: " + groupVarsDir.getAbsolutePath()); - } + log.debug("group_vars directory path: {}", groupVarsDir.getAbsolutePath()); try { // Use Files.createDirectories() which is idempotent (safe to call if directory exists) @@ -540,25 +534,19 @@ public int run() throws Exception { "This file will be cleaned up after execution, but may persist if cleanup fails.", tempNodeAuthFile.getAbsolutePath()); - if(debug) { - System.err.println("DEBUG: Writing all.yaml to: " + tempNodeAuthFile.getAbsolutePath()); - System.err.println("DEBUG: all.yaml written successfully, file size: " + tempNodeAuthFile.length() + " bytes"); - } + log.debug("Writing all.yaml to: {}", tempNodeAuthFile.getAbsolutePath()); + log.debug("all.yaml written successfully, file size: {} bytes", tempNodeAuthFile.length()); } else { // Fallback to temp file if inventory has no parent directory tempNodeAuthFile = AnsibleUtil.createTemporaryFile("group_vars", "all.yaml", yamlContent, customTmpDirPath); - if(debug) { - System.err.println("DEBUG: No parent directory, using temporary file"); - System.err.println("DEBUG: Temporary all.yaml created at: " + tempNodeAuthFile.getAbsolutePath()); - } + log.debug("No parent directory, using temporary file"); + log.debug("Temporary all.yaml created at: {}", tempNodeAuthFile.getAbsolutePath()); } - if(debug) { - System.err.println("DEBUG: tempNodeAuthFile: " + tempNodeAuthFile.getAbsolutePath()); - System.err.println("DEBUG: tempNodeAuthFile exists: " + tempNodeAuthFile.exists()); - System.err.println("DEBUG: tempNodeAuthFile readable: " + tempNodeAuthFile.canRead()); - } + log.debug("tempNodeAuthFile: {}", tempNodeAuthFile.getAbsolutePath()); + log.debug("tempNodeAuthFile exists: {}", tempNodeAuthFile.exists()); + log.debug("tempNodeAuthFile readable: {}", tempNodeAuthFile.canRead()); //set extra vars to resolve the host specific authentication if (!hostUsers.isEmpty()) { @@ -1011,7 +999,7 @@ String buildGroupVarsYaml(Map hostPasswords, Map // Validate vault format if (!isValidVaultFormat(vaultValue)) { - System.err.println("ERROR: Invalid vault format for host: " + originalKey); + log.error("Invalid vault format for host: {}", originalKey); throw new RuntimeException("Invalid vault format for host: " + originalKey); } // The vault value already contains "!vault |\n" followed by the encrypted content @@ -1047,9 +1035,7 @@ String buildGroupVarsYaml(Map hostPasswords, Map String result = yamlContent.toString(); - if(debug){ - System.err.println("DEBUG: Generated YAML content (" + result.length() + " bytes)"); - } + log.debug("Generated YAML content ({} bytes)", result.length()); return result; } From 6bf39e972fbcdf0cd8814e6b3493423eff57a66c Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 14:44:56 -0300 Subject: [PATCH 44/46] Update functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/test/resources/docker/ansible-multi-node-auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md index 05d5f88e..3cff29da 100644 --- a/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md +++ b/functional-test/src/test/resources/docker/ansible-multi-node-auth/README.md @@ -7,7 +7,7 @@ This test verifies the multi-node authentication feature where each node can hav ### Nodes - **ssh-node**: Standard password (`testpassword123`) - **ssh-node-2**: Password with special characters (`password2_special!@#`) -- **ssh-node-3**: Password with quotes (`password3"quote'test`) +- **ssh-node-3**: Password containing both a double quote (") and a single quote ('): password3"quote'test - **ssh-node-4**: Private key authentication (uses SSH key from key storage) ### Files From 65ae4447c59d8cefc9ce2aa40b3088d86eb256a2 Mon Sep 17 00:00:00 2001 From: Eduardo Baltra Date: Fri, 16 Jan 2026 16:15:10 -0300 Subject: [PATCH 45/46] Final Copilot comments --- .../ansible/ansible/AnsibleRunner.java | 22 ++++++++++++++++--- .../ansible/AnsibleRunnerContextBuilder.java | 5 +++-- .../ansible/ansible/AnsibleRunnerSpec.groovy | 18 +++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java index 31f0356a..a74e018f 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunner.java @@ -1286,10 +1286,26 @@ public String encryptExtraVarsKey(String extraVars) throws Exception { /** * Sanitizes a node name to be safe for use in filesystem paths. - * Replaces unsafe characters with underscores and prevents hidden files and problematic names. + *

+ * This method performs the following transformations: + *

    + *
  • Replaces all characters except alphanumeric, dot, underscore, and hyphen with underscores
  • + *
  • Prevents hidden files by prepending underscore if name starts with dot
  • + *
  • Handles empty strings by prepending underscore
  • + *
+ *

+ *

+ * Examples: + *

    + *
  • {@code "node-1"} → {@code "node-1"} (no change)
  • + *
  • {@code "node@host"} → {@code "node_host"} (@ replaced)
  • + *
  • {@code ".hidden"} → {@code "_.hidden"} (prevents hidden file)
  • + *
  • {@code ""} → {@code "_"} (handles empty)
  • + *
+ *

* - * @param nodeName The original node name - * @return A sanitized node name safe for use in file paths + * @param nodeName The original node name to sanitize + * @return A sanitized node name safe for use in file paths, never null */ String sanitizeNodeNameForFilesystem(String nodeName) { // Replace unsafe characters with underscores diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 40c6e5f5..83efa3bc 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -226,8 +226,9 @@ public String getPasswordFromPath(String storagePath) throws ConfigurationExcept * * @param storagePath The storage path to read from * @param resourceType Description of resource type for error messages (e.g., "ssh password", "ssh private key") - * @return The content as a UTF-8 string, or null if storagePath is null - * @throws ConfigurationException if reading fails + * @return The content as a UTF-8 string. Returns null ONLY if storagePath parameter is null (early return). + * After attempting to read content, this method either returns a non-null string or throws an exception. + * @throws ConfigurationException if the storage path cannot be read or does not exist */ private String readFromStoragePath(String storagePath, String resourceType) throws ConfigurationException { if (storagePath == null) { diff --git a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy index df6575af..41d5757c 100644 --- a/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy +++ b/src/test/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerSpec.groovy @@ -623,6 +623,24 @@ class AnsibleRunnerSpec extends Specification{ "key with spaces" || "key with spaces" // internal spaces are valid in unquoted YAML keys; node names with leading/trailing spaces are not supported } + def "escapeYamlKey: node names with leading/trailing spaces are not modified"() { + given: + def builder = AnsibleRunner.playbookInline("test") + builder.customTmpDirPath("/tmp") + def runner = builder.build() + + expect: + // This documents that escapeYamlKey does not normalize leading/trailing spaces. + // Node names with such spaces are considered unsupported at a higher level. + runner.escapeYamlKey(key) == expectedResult + + where: + key || expectedResult + " leading-space" || " leading-space" + "trailing-space " || "trailing-space " + " both-sides " || " both-sides " + } + def "escapeYamlValue: should quote values with special characters"() { given: def builder = AnsibleRunner.playbookInline("test") From 7db271f89a7d5b6f6eb09149690332a4b6853747 Mon Sep 17 00:00:00 2001 From: Luis Toledo Date: Fri, 16 Jan 2026 19:21:01 -0300 Subject: [PATCH 46/46] fix NPE --- .../plugins/ansible/ansible/AnsibleRunnerContextBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java index 83efa3bc..c9f3598c 100644 --- a/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java +++ b/src/main/groovy/com/rundeck/plugins/ansible/ansible/AnsibleRunnerContextBuilder.java @@ -1117,7 +1117,7 @@ public List getListNodesKeyPath(){ public Boolean generateInventoryNodesAuth() { - Boolean generateInventoryNodesAuth = null; + boolean generateInventoryNodesAuth = false; log.debug("Resolving property ANSIBLE_GENERATE_INVENTORY_NODES_AUTH"); log.debug("Property key: {}", AnsibleDescribable.ANSIBLE_GENERATE_INVENTORY_NODES_AUTH);