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}