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}