001/* 002 * Copyright 2025 devteam@scivicslab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac; 019 020import java.io.BufferedReader; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.InputStreamReader; 024import java.util.ArrayList; 025import java.util.HashMap; 026import java.util.HashSet; 027import java.util.List; 028import java.util.Map; 029import java.util.Set; 030import java.util.regex.Pattern; 031 032/** 033 * Parser for Ansible inventory files in INI format. 034 * 035 * <p>This parser supports a subset of Ansible inventory file format. 036 * actor-IaC supports both Ansible-compatible syntax (ansible_*) and 037 * native syntax (actoriac_*).</p> 038 * 039 * <h2>Supported Features</h2> 040 * <ul> 041 * <li>Groups: {@code [groupname]}</li> 042 * <li>Group variables: {@code [groupname:vars]}</li> 043 * <li>Global variables: {@code [all:vars]}</li> 044 * <li>Host-specific variables: {@code hostname key=value}</li> 045 * <li>Comments: lines starting with {@code #} or {@code ;}</li> 046 * </ul> 047 * 048 * <h2>Supported Variables</h2> 049 * <table border="1"> 050 * <caption>Supported inventory variables</caption> 051 * <tr><th>actor-IaC</th><th>Ansible</th><th>Description</th></tr> 052 * <tr><td>actoriac_host</td><td>ansible_host</td><td>Actual hostname/IP to connect</td></tr> 053 * <tr><td>actoriac_user</td><td>ansible_user</td><td>SSH username</td></tr> 054 * <tr><td>actoriac_port</td><td>ansible_port</td><td>SSH port</td></tr> 055 * <tr><td>actoriac_connection</td><td>ansible_connection</td><td>Connection type (ssh/local)</td></tr> 056 * </table> 057 * 058 * <h2>Unsupported Ansible Features</h2> 059 * <p>The following Ansible features are NOT supported and will generate warnings:</p> 060 * <ul> 061 * <li>Children groups: {@code [group:children]}</li> 062 * <li>Range patterns: {@code web[01:50].example.com}</li> 063 * <li>Privilege escalation: {@code ansible_become}, {@code ansible_become_user}</li> 064 * <li>Python interpreter: {@code ansible_python_interpreter}</li> 065 * </ul> 066 * 067 * @author devteam@scivicslab.com 068 */ 069public class InventoryParser { 070 071 /** Pattern to detect Ansible range notation like [01:50] or [a:z] */ 072 private static final Pattern RANGE_PATTERN = Pattern.compile(".*\\[[0-9a-zA-Z]+:[0-9a-zA-Z]+\\].*"); 073 074 /** Set of supported variable suffixes (without prefix) */ 075 private static final Set<String> SUPPORTED_VAR_SUFFIXES = Set.of( 076 "host", "user", "port", "connection" 077 ); 078 079 /** Set of known unsupported Ansible variables that should trigger warnings */ 080 private static final Set<String> UNSUPPORTED_ANSIBLE_VARS = Set.of( 081 "ansible_become", 082 "ansible_become_user", 083 "ansible_become_pass", 084 "ansible_become_method", 085 "ansible_become_flags", 086 "ansible_python_interpreter", 087 "ansible_shell_type", 088 "ansible_shell_executable", 089 "ansible_ssh_private_key_file", 090 "ansible_ssh_common_args", 091 "ansible_ssh_extra_args", 092 "ansible_ssh_pipelining", 093 "ansible_ssh_pass", 094 "ansible_sudo", 095 "ansible_sudo_pass" 096 ); 097 098 /** 099 * Parses an Ansible inventory file. 100 * 101 * <p>This method collects warnings for unsupported Ansible features. 102 * Warnings are stored in the returned {@link ParseResult} and should be 103 * logged by the caller using the appropriate logging mechanism (e.g., 104 * {@link com.scivicslab.actoriac.log.DistributedLogStore}).</p> 105 * 106 * @param input the input stream of the inventory file 107 * @return the parse result containing inventory and any warnings 108 * @throws IOException if reading the file fails 109 */ 110 public static ParseResult parse(InputStream input) throws IOException { 111 Inventory inventory = new Inventory(); 112 List<String> warnings = new ArrayList<>(); 113 Set<String> warnedVars = new HashSet<>(); 114 int lineNumber = 0; 115 116 try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { 117 String currentGroup = null; 118 boolean inVarsSection = false; 119 Map<String, String> currentVars = new HashMap<>(); 120 121 String line; 122 while ((line = reader.readLine()) != null) { 123 lineNumber++; 124 line = line.trim(); 125 126 // Skip empty lines and comments 127 if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) { 128 continue; 129 } 130 131 // Check for group header 132 if (line.startsWith("[") && line.endsWith("]")) { 133 String groupDeclaration = line.substring(1, line.length() - 1); 134 135 // Check for unsupported :children syntax 136 if (groupDeclaration.endsWith(":children")) { 137 warnings.add(String.format( 138 "Line %d: [%s] - ':children' groups are not supported in actor-IaC. " + 139 "Please list hosts directly in each group instead.", 140 lineNumber, groupDeclaration)); 141 currentGroup = null; 142 inVarsSection = false; 143 continue; 144 } 145 146 // Check if it's a vars section 147 if (groupDeclaration.endsWith(":vars")) { 148 inVarsSection = true; 149 currentGroup = groupDeclaration.substring(0, groupDeclaration.length() - 5); 150 currentVars = new HashMap<>(); 151 } else { 152 inVarsSection = false; 153 currentGroup = groupDeclaration; 154 inventory.addGroup(currentGroup); 155 } 156 continue; 157 } 158 159 // Process content based on section type 160 if (inVarsSection) { 161 // Parse variable assignment 162 int equalsIndex = line.indexOf('='); 163 if (equalsIndex > 0) { 164 String key = line.substring(0, equalsIndex).trim(); 165 String value = line.substring(equalsIndex + 1).trim(); 166 167 // Check for unsupported variables 168 checkUnsupportedVariable(key, lineNumber, warnedVars, warnings); 169 170 currentVars.put(key, value); 171 172 // Apply vars to group 173 if ("all".equals(currentGroup)) { 174 inventory.addGlobalVar(key, value); 175 } else if (currentGroup != null) { 176 inventory.addGroupVar(currentGroup, key, value); 177 } 178 } 179 } else if (currentGroup != null) { 180 // Parse host line with optional variables 181 // Format: hostname [key=value key=value ...] 182 String[] tokens = line.split("\\s+"); 183 String hostname = tokens[0]; 184 185 // Check for unsupported range notation 186 if (RANGE_PATTERN.matcher(hostname).matches()) { 187 warnings.add(String.format( 188 "Line %d: '%s' - Range patterns like [01:50] are not supported in actor-IaC. " + 189 "Please list each host individually.", 190 lineNumber, hostname)); 191 continue; 192 } 193 194 inventory.addHost(currentGroup, hostname); 195 196 // Parse host-specific variables 197 for (int i = 1; i < tokens.length; i++) { 198 String token = tokens[i]; 199 int equalsIndex = token.indexOf('='); 200 if (equalsIndex > 0) { 201 String key = token.substring(0, equalsIndex).trim(); 202 String value = token.substring(equalsIndex + 1).trim(); 203 204 // Check for unsupported variables 205 checkUnsupportedVariable(key, lineNumber, warnedVars, warnings); 206 207 inventory.addHostVar(hostname, key, value); 208 } 209 } 210 } 211 } 212 } 213 214 return new ParseResult(inventory, warnings); 215 } 216 217 /** 218 * Checks if a variable is unsupported and adds a warning if so. 219 * 220 * @param key the variable name 221 * @param lineNumber the line number for error reporting 222 * @param warnedVars set of already warned variables (to avoid duplicate warnings) 223 * @param warnings list to add warnings to 224 */ 225 private static void checkUnsupportedVariable(String key, int lineNumber, 226 Set<String> warnedVars, List<String> warnings) { 227 // Check for known unsupported Ansible variables 228 if (UNSUPPORTED_ANSIBLE_VARS.contains(key)) { 229 if (!warnedVars.contains(key)) { 230 warnedVars.add(key); 231 String suggestion = getUnsupportedVarSuggestion(key); 232 warnings.add(String.format( 233 "Line %d: '%s' is not supported in actor-IaC. %s", 234 lineNumber, key, suggestion)); 235 } 236 return; 237 } 238 239 // Check for ansible_* or actoriac_* variables that are not in the supported list 240 if (key.startsWith("ansible_") || key.startsWith("actoriac_")) { 241 String suffix = key.startsWith("ansible_") 242 ? key.substring("ansible_".length()) 243 : key.substring("actoriac_".length()); 244 245 if (!SUPPORTED_VAR_SUFFIXES.contains(suffix) && !warnedVars.contains(key)) { 246 warnedVars.add(key); 247 warnings.add(String.format( 248 "Line %d: '%s' is not a recognized actor-IaC variable. " + 249 "Supported variables are: actoriac_host, actoriac_user, actoriac_port, actoriac_connection " + 250 "(or their ansible_* equivalents).", 251 lineNumber, key)); 252 } 253 } 254 } 255 256 /** 257 * Returns a helpful suggestion for unsupported Ansible variables. 258 */ 259 private static String getUnsupportedVarSuggestion(String key) { 260 if (key.startsWith("ansible_become") || key.equals("ansible_sudo") || key.equals("ansible_sudo_pass")) { 261 return "For privilege escalation, use the SUDO_PASSWORD environment variable and the executeSudoCommand() method in workflows."; 262 } 263 if (key.equals("ansible_python_interpreter")) { 264 return "actor-IaC executes commands directly via SSH without Python. This variable is not needed."; 265 } 266 if (key.startsWith("ansible_ssh_")) { 267 return "SSH configuration should be done via ~/.ssh/config or ssh-agent."; 268 } 269 return "This Ansible feature is not implemented in actor-IaC."; 270 } 271 272 /** 273 * Result of parsing an inventory file. 274 * 275 * <p>Contains the parsed inventory and any warnings generated during parsing. 276 * Warnings should be logged by the caller using the appropriate logging mechanism.</p> 277 */ 278 public static class ParseResult { 279 private final Inventory inventory; 280 private final List<String> warnings; 281 282 /** 283 * Constructs a new ParseResult. 284 * 285 * @param inventory the parsed inventory 286 * @param warnings list of warning messages 287 */ 288 public ParseResult(Inventory inventory, List<String> warnings) { 289 this.inventory = inventory; 290 this.warnings = warnings; 291 } 292 293 /** 294 * Gets the parsed inventory. 295 * 296 * @return the inventory 297 */ 298 public Inventory getInventory() { 299 return inventory; 300 } 301 302 /** 303 * Gets the list of warnings generated during parsing. 304 * 305 * @return list of warning messages (may be empty) 306 */ 307 public List<String> getWarnings() { 308 return warnings; 309 } 310 311 /** 312 * Checks if any warnings were generated. 313 * 314 * @return true if there are warnings 315 */ 316 public boolean hasWarnings() { 317 return !warnings.isEmpty(); 318 } 319 } 320 321 /** 322 * Represents a parsed Ansible inventory. 323 */ 324 public static class Inventory { 325 private final Map<String, List<String>> groups = new HashMap<>(); 326 private final Map<String, String> globalVars = new HashMap<>(); 327 private final Map<String, Map<String, String>> groupVars = new HashMap<>(); 328 private final Map<String, Map<String, String>> hostVars = new HashMap<>(); 329 330 public void addGroup(String groupName) { 331 groups.putIfAbsent(groupName, new ArrayList<>()); 332 } 333 334 public void addHost(String groupName, String hostname) { 335 groups.computeIfAbsent(groupName, k -> new ArrayList<>()).add(hostname); 336 } 337 338 public void addGlobalVar(String key, String value) { 339 globalVars.put(key, value); 340 } 341 342 public void addGroupVar(String groupName, String key, String value) { 343 groupVars.computeIfAbsent(groupName, k -> new HashMap<>()).put(key, value); 344 } 345 346 public void addHostVar(String hostname, String key, String value) { 347 hostVars.computeIfAbsent(hostname, k -> new HashMap<>()).put(key, value); 348 } 349 350 public List<String> getHosts(String groupName) { 351 return groups.getOrDefault(groupName, new ArrayList<>()); 352 } 353 354 public Map<String, String> getGlobalVars() { 355 return new HashMap<>(globalVars); 356 } 357 358 public Map<String, String> getGroupVars(String groupName) { 359 return new HashMap<>(groupVars.getOrDefault(groupName, new HashMap<>())); 360 } 361 362 public Map<String, String> getHostVars(String hostname) { 363 return new HashMap<>(hostVars.getOrDefault(hostname, new HashMap<>())); 364 } 365 366 public Map<String, List<String>> getAllGroups() { 367 return new HashMap<>(groups); 368 } 369 } 370}