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.IOException; 021import java.io.InputStream; 022import java.util.ArrayList; 023import java.util.HashMap; 024import java.util.List; 025import java.util.Map; 026import java.util.logging.Logger; 027 028/** 029 * Manages a group of nodes based on an Ansible inventory file. 030 * 031 * <p>This is a pure POJO class that reads Ansible inventory files and creates 032 * Node objects for a specific group. It does not depend on ActorSystem - 033 * the responsibility of converting Node objects to actors belongs to the caller.</p> 034 * 035 * <p>SSH authentication is handled by ssh-agent. Make sure ssh-agent is running 036 * and your SSH key is added before creating nodes.</p> 037 * 038 * <h2>Usage Examples</h2> 039 * 040 * <h3>Using Builder Pattern (Recommended)</h3> 041 * <pre>{@code 042 * NodeGroup nodeGroup = new NodeGroup.Builder() 043 * .withInventory(new FileInputStream("inventory.ini")) 044 * .build(); 045 * }</pre> 046 * 047 * <h3>Legacy Constructor Pattern</h3> 048 * <pre>{@code 049 * NodeGroup nodeGroup = new NodeGroup(); 050 * nodeGroup.loadInventory(new FileInputStream("inventory.ini")); 051 * }</pre> 052 * 053 * @author devteam@scivicslab.com 054 */ 055public class NodeGroup { 056 057 private static final Logger logger = Logger.getLogger(NodeGroup.class.getName()); 058 059 private InventoryParser.Inventory inventory; 060 private List<String> parseWarnings = new ArrayList<>(); 061 private String sshPassword; 062 private List<String> hostLimit; 063 064 /** 065 * Builder for creating NodeGroup instances with fluent API. 066 * 067 * <p>This is the recommended way to create NodeGroup instances.</p> 068 * 069 * <p><strong>Example:</strong></p> 070 * <pre>{@code 071 * NodeGroup nodeGroup = new NodeGroup.Builder() 072 * .withInventory(new FileInputStream("inventory.ini")) 073 * .build(); 074 * }</pre> 075 */ 076 public static class Builder { 077 private InventoryParser.Inventory inventory; 078 private List<String> warnings = new ArrayList<>(); 079 080 /** 081 * Loads an Ansible inventory file. 082 * 083 * @param inventoryStream the input stream containing the inventory file 084 * @return this builder for method chaining 085 * @throws IOException if reading the inventory fails 086 */ 087 public Builder withInventory(InputStream inventoryStream) throws IOException { 088 InventoryParser.ParseResult result = InventoryParser.parse(inventoryStream); 089 this.inventory = result.getInventory(); 090 this.warnings = result.getWarnings(); 091 return this; 092 } 093 094 /** 095 * Builds the NodeGroup instance. 096 * 097 * @return a new NodeGroup instance with the configured settings 098 */ 099 public NodeGroup build() { 100 return new NodeGroup(inventory, warnings); 101 } 102 } 103 104 /** 105 * Constructs an empty NodeGroup. 106 * 107 * <p><strong>Note:</strong> Consider using {@link Builder} for a more fluent API.</p> 108 */ 109 public NodeGroup() { 110 } 111 112 /** 113 * Private constructor used by Builder. 114 * 115 * @param inventory the parsed inventory 116 * @param warnings list of warnings from parsing 117 */ 118 private NodeGroup(InventoryParser.Inventory inventory, List<String> warnings) { 119 this.inventory = inventory; 120 this.parseWarnings = warnings; 121 } 122 123 /** 124 * Loads an inventory file from an input stream. 125 * 126 * <p>Any warnings generated during parsing are stored and can be retrieved 127 * via {@link #getParseWarnings()}.</p> 128 * 129 * @param inventoryStream the input stream containing the inventory file 130 * @throws IOException if reading the inventory fails 131 */ 132 public void loadInventory(InputStream inventoryStream) throws IOException { 133 InventoryParser.ParseResult result = InventoryParser.parse(inventoryStream); 134 this.inventory = result.getInventory(); 135 this.parseWarnings = result.getWarnings(); 136 } 137 138 /** 139 * Gets the warnings generated during inventory parsing. 140 * 141 * <p>These warnings should be logged by the caller using the appropriate 142 * logging mechanism (e.g., DistributedLogStore for database logging, 143 * or java.util.logging for console/file logging).</p> 144 * 145 * @return list of warning messages (may be empty) 146 */ 147 public List<String> getParseWarnings() { 148 return parseWarnings; 149 } 150 151 /** 152 * Checks if there are any parse warnings. 153 * 154 * @return true if there are warnings 155 */ 156 public boolean hasParseWarnings() { 157 return !parseWarnings.isEmpty(); 158 } 159 160 /** 161 * Creates Node objects for all hosts in the specified group. 162 * 163 * <p>This method reads the group from the inventory, applies global and 164 * group-specific variables, and creates a Node POJO for each host.</p> 165 * 166 * <p>If Vault integration is configured, this method will fetch SSH keys and 167 * sudo passwords from Vault based on the vault-config.ini settings.</p> 168 * 169 * <p>Note: This method returns plain Node objects, not actors. The caller 170 * is responsible for converting them to actors using ActorSystem.actorOf() 171 * if needed.</p> 172 * 173 * @param groupName the name of the group from the inventory file 174 * @return the list of created Node objects 175 * @throws IllegalStateException if inventory has not been loaded 176 * @throws RuntimeException if Vault secret retrieval fails 177 */ 178 public List<Node> createNodesForGroup(String groupName) { 179 if (inventory == null) { 180 throw new IllegalStateException("Inventory not loaded. Call loadInventory() first."); 181 } 182 183 List<String> hosts = inventory.getHosts(groupName); 184 List<Node> nodes = new ArrayList<>(); 185 186 // Get base variables 187 Map<String, String> globalVars = inventory.getGlobalVars(); 188 Map<String, String> groupVars = inventory.getGroupVars(groupName); 189 190 // Create nodes 191 for (String hostname : hosts) { 192 // Apply host limit if set 193 if (hostLimit != null && !hostLimit.contains(hostname)) { 194 continue; 195 } 196 // Merge vars with priority: host vars > group vars > global vars 197 Map<String, String> effectiveVars = new HashMap<>(globalVars); 198 effectiveVars.putAll(groupVars); 199 effectiveVars.putAll(inventory.getHostVars(hostname)); 200 201 // Extract connection parameters for this host 202 // Support both actoriac_* (preferred) and ansible_* (for compatibility) prefixes 203 // Use actoriac_host or ansible_host if specified, otherwise use the logical hostname 204 String actualHost = getVar(effectiveVars, "host", hostname); 205 String user = getVar(effectiveVars, "user", System.getProperty("user.name")); 206 int port = Integer.parseInt(getVar(effectiveVars, "port", "22")); 207 208 // Check if local connection mode is requested 209 // (actoriac_connection=local or ansible_connection=local) 210 String connection = getVar(effectiveVars, "connection", "ssh"); 211 boolean localMode = "local".equalsIgnoreCase(connection); 212 213 // Create Node using actualHost (IP or DNS) for SSH connection 214 Node node = new Node(actualHost, user, port, localMode, sshPassword); 215 nodes.add(node); 216 } 217 218 return nodes; 219 } 220 221 /** 222 * Creates a single Node for localhost execution. 223 * 224 * <p>This method creates a Node configured for local execution without requiring 225 * an inventory file. Useful for development, testing, or single-host scenarios.</p> 226 * 227 * <p>The node is created with:</p> 228 * <ul> 229 * <li>hostname: "localhost"</li> 230 * <li>user: current system user</li> 231 * <li>localMode: true (uses ProcessBuilder instead of SSH)</li> 232 * </ul> 233 * 234 * @return a list containing a single localhost Node 235 */ 236 public List<Node> createLocalNode() { 237 Node localNode = new Node("localhost", 238 System.getProperty("user.name"), 239 22, 240 true); // localMode = true 241 return List.of(localNode); 242 } 243 244 /** 245 * Gets the inventory object. 246 * 247 * @return the loaded inventory, or null if not loaded 248 */ 249 public InventoryParser.Inventory getInventory() { 250 return inventory; 251 } 252 253 /** 254 * Sets the SSH password for all nodes in this group. 255 * 256 * <p>When set, nodes will use password authentication instead of 257 * ssh-agent key authentication.</p> 258 * 259 * @param password the SSH password to use for all nodes 260 */ 261 public void setSshPassword(String password) { 262 this.sshPassword = password; 263 } 264 265 /** 266 * Gets the SSH password. 267 * 268 * @return the SSH password, or null if not set 269 */ 270 public String getSshPassword() { 271 return sshPassword; 272 } 273 274 /** 275 * Sets the host limit to restrict execution to specific hosts. 276 * 277 * <p>When set, only hosts in this list will be included when creating nodes. 278 * This is similar to Ansible's --limit option.</p> 279 * 280 * @param limitString comma-separated list of hosts (e.g., "192.168.5.15,192.168.5.16") 281 */ 282 public void setHostLimit(String limitString) { 283 if (limitString == null || limitString.trim().isEmpty()) { 284 this.hostLimit = null; 285 } else { 286 this.hostLimit = new ArrayList<>(); 287 for (String host : limitString.split(",")) { 288 String trimmed = host.trim(); 289 if (!trimmed.isEmpty()) { 290 this.hostLimit.add(trimmed); 291 } 292 } 293 } 294 } 295 296 /** 297 * Gets the host limit. 298 * 299 * @return the list of limited hosts, or null if no limit is set 300 */ 301 public List<String> getHostLimit() { 302 return hostLimit; 303 } 304 305 /** 306 * Gets a variable value with support for both actoriac_* and ansible_* prefixes. 307 * 308 * <p>This method checks for the variable in the following order:</p> 309 * <ol> 310 * <li>actoriac_{suffix} - actor-IaC native naming</li> 311 * <li>ansible_{suffix} - Ansible-compatible naming</li> 312 * <li>defaultValue - if neither is found</li> 313 * </ol> 314 * 315 * @param vars the variable map to search 316 * @param suffix the variable suffix (e.g., "host", "user", "port", "connection") 317 * @param defaultValue the default value if neither prefix is found 318 * @return the variable value, or defaultValue if not found 319 */ 320 private String getVar(Map<String, String> vars, String suffix, String defaultValue) { 321 // Check actoriac_* first (preferred) 322 String actoriacKey = "actoriac_" + suffix; 323 if (vars.containsKey(actoriacKey)) { 324 return vars.get(actoriacKey); 325 } 326 327 // Fallback to ansible_* for compatibility 328 String ansibleKey = "ansible_" + suffix; 329 if (vars.containsKey(ansibleKey)) { 330 return vars.get(ansibleKey); 331 } 332 333 return defaultValue; 334 } 335 336 @Override 337 public String toString() { 338 return String.format("NodeGroup{groups=%s}", 339 inventory != null ? inventory.getAllGroups().keySet() : "[]"); 340 } 341}