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}