001/* 002 * Copyright 2025 devteam@scivics-lab.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; 026 027/** 028 * Manages a group of nodes based on an Ansible inventory file. 029 * 030 * <p>This is a pure POJO class that reads Ansible inventory files and creates 031 * Node objects for a specific group. It does not depend on ActorSystem - 032 * the responsibility of converting Node objects to actors belongs to the caller.</p> 033 * 034 * <p>SSH authentication is handled by ssh-agent. Make sure ssh-agent is running 035 * and your SSH key is added before creating nodes.</p> 036 * 037 * <h2>Usage Examples</h2> 038 * 039 * <h3>Using Builder Pattern (Recommended)</h3> 040 * <pre>{@code 041 * NodeGroup nodeGroup = new NodeGroup.Builder() 042 * .withInventory(new FileInputStream("inventory.ini")) 043 * .build(); 044 * }</pre> 045 * 046 * <h3>Legacy Constructor Pattern</h3> 047 * <pre>{@code 048 * NodeGroup nodeGroup = new NodeGroup(); 049 * nodeGroup.loadInventory(new FileInputStream("inventory.ini")); 050 * }</pre> 051 * 052 * @author devteam@scivics-lab.com 053 */ 054public class NodeGroup { 055 056 private InventoryParser.Inventory inventory; 057 private String sshPassword; 058 private List<String> hostLimit; 059 060 /** 061 * Builder for creating NodeGroup instances with fluent API. 062 * 063 * <p>This is the recommended way to create NodeGroup instances.</p> 064 * 065 * <p><strong>Example:</strong></p> 066 * <pre>{@code 067 * NodeGroup nodeGroup = new NodeGroup.Builder() 068 * .withInventory(new FileInputStream("inventory.ini")) 069 * .build(); 070 * }</pre> 071 */ 072 public static class Builder { 073 private InventoryParser.Inventory inventory; 074 075 /** 076 * Loads an Ansible inventory file. 077 * 078 * @param inventoryStream the input stream containing the inventory file 079 * @return this builder for method chaining 080 * @throws IOException if reading the inventory fails 081 */ 082 public Builder withInventory(InputStream inventoryStream) throws IOException { 083 this.inventory = InventoryParser.parse(inventoryStream); 084 return this; 085 } 086 087 /** 088 * Builds the NodeGroup instance. 089 * 090 * @return a new NodeGroup instance with the configured settings 091 */ 092 public NodeGroup build() { 093 return new NodeGroup(inventory); 094 } 095 } 096 097 /** 098 * Constructs an empty NodeGroup. 099 * 100 * <p><strong>Note:</strong> Consider using {@link Builder} for a more fluent API.</p> 101 */ 102 public NodeGroup() { 103 } 104 105 /** 106 * Private constructor used by Builder. 107 * 108 * @param inventory the parsed inventory 109 */ 110 private NodeGroup(InventoryParser.Inventory inventory) { 111 this.inventory = inventory; 112 } 113 114 /** 115 * Loads an inventory file from an input stream. 116 * 117 * @param inventoryStream the input stream containing the inventory file 118 * @throws IOException if reading the inventory fails 119 */ 120 public void loadInventory(InputStream inventoryStream) throws IOException { 121 this.inventory = InventoryParser.parse(inventoryStream); 122 } 123 124 /** 125 * Creates Node objects for all hosts in the specified group. 126 * 127 * <p>This method reads the group from the inventory, applies global and 128 * group-specific variables, and creates a Node POJO for each host.</p> 129 * 130 * <p>If Vault integration is configured, this method will fetch SSH keys and 131 * sudo passwords from Vault based on the vault-config.ini settings.</p> 132 * 133 * <p>Note: This method returns plain Node objects, not actors. The caller 134 * is responsible for converting them to actors using ActorSystem.actorOf() 135 * if needed.</p> 136 * 137 * @param groupName the name of the group from the inventory file 138 * @return the list of created Node objects 139 * @throws IllegalStateException if inventory has not been loaded 140 * @throws RuntimeException if Vault secret retrieval fails 141 */ 142 public List<Node> createNodesForGroup(String groupName) { 143 if (inventory == null) { 144 throw new IllegalStateException("Inventory not loaded. Call loadInventory() first."); 145 } 146 147 List<String> hosts = inventory.getHosts(groupName); 148 List<Node> nodes = new ArrayList<>(); 149 150 // Get base variables 151 Map<String, String> globalVars = inventory.getGlobalVars(); 152 Map<String, String> groupVars = inventory.getGroupVars(groupName); 153 154 // Create nodes 155 for (String hostname : hosts) { 156 // Apply host limit if set 157 if (hostLimit != null && !hostLimit.contains(hostname)) { 158 continue; 159 } 160 // Merge vars with priority: host vars > group vars > global vars 161 Map<String, String> effectiveVars = new HashMap<>(globalVars); 162 effectiveVars.putAll(groupVars); 163 effectiveVars.putAll(inventory.getHostVars(hostname)); 164 165 // Extract connection parameters for this host 166 // Use ansible_host if specified, otherwise use the logical hostname 167 String actualHost = effectiveVars.getOrDefault("ansible_host", hostname); 168 String user = effectiveVars.getOrDefault("ansible_user", System.getProperty("user.name")); 169 int port = Integer.parseInt(effectiveVars.getOrDefault("ansible_port", "22")); 170 171 // Check if local connection mode is requested (like Ansible's ansible_connection=local) 172 String connection = effectiveVars.getOrDefault("ansible_connection", "ssh"); 173 boolean localMode = "local".equalsIgnoreCase(connection); 174 175 // Create Node using actualHost (IP or DNS) for SSH connection 176 Node node = new Node(actualHost, user, port, localMode, sshPassword); 177 nodes.add(node); 178 } 179 180 return nodes; 181 } 182 183 /** 184 * Creates a single Node for localhost execution. 185 * 186 * <p>This method creates a Node configured for local execution without requiring 187 * an inventory file. Useful for development, testing, or single-host scenarios.</p> 188 * 189 * <p>The node is created with:</p> 190 * <ul> 191 * <li>hostname: "localhost"</li> 192 * <li>user: current system user</li> 193 * <li>localMode: true (uses ProcessBuilder instead of SSH)</li> 194 * </ul> 195 * 196 * @return a list containing a single localhost Node 197 */ 198 public List<Node> createLocalNode() { 199 Node localNode = new Node("localhost", 200 System.getProperty("user.name"), 201 22, 202 true); // localMode = true 203 return List.of(localNode); 204 } 205 206 /** 207 * Gets the inventory object. 208 * 209 * @return the loaded inventory, or null if not loaded 210 */ 211 public InventoryParser.Inventory getInventory() { 212 return inventory; 213 } 214 215 /** 216 * Sets the SSH password for all nodes in this group. 217 * 218 * <p>When set, nodes will use password authentication instead of 219 * ssh-agent key authentication.</p> 220 * 221 * @param password the SSH password to use for all nodes 222 */ 223 public void setSshPassword(String password) { 224 this.sshPassword = password; 225 } 226 227 /** 228 * Gets the SSH password. 229 * 230 * @return the SSH password, or null if not set 231 */ 232 public String getSshPassword() { 233 return sshPassword; 234 } 235 236 /** 237 * Sets the host limit to restrict execution to specific hosts. 238 * 239 * <p>When set, only hosts in this list will be included when creating nodes. 240 * This is similar to Ansible's --limit option.</p> 241 * 242 * @param limitString comma-separated list of hosts (e.g., "192.168.5.15,192.168.5.16") 243 */ 244 public void setHostLimit(String limitString) { 245 if (limitString == null || limitString.trim().isEmpty()) { 246 this.hostLimit = null; 247 } else { 248 this.hostLimit = new ArrayList<>(); 249 for (String host : limitString.split(",")) { 250 String trimmed = host.trim(); 251 if (!trimmed.isEmpty()) { 252 this.hostLimit.add(trimmed); 253 } 254 } 255 } 256 } 257 258 /** 259 * Gets the host limit. 260 * 261 * @return the list of limited hosts, or null if no limit is set 262 */ 263 public List<String> getHostLimit() { 264 return hostLimit; 265 } 266 267 @Override 268 public String toString() { 269 return String.format("NodeGroup{groups=%s}", 270 inventory != null ? inventory.getAllGroups().keySet() : "[]"); 271 } 272}