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.cli; 019 020import java.io.File; 021import java.io.IOException; 022import java.io.InputStream; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.util.Comparator; 026import java.util.List; 027import java.util.concurrent.Callable; 028import java.util.logging.LogManager; 029import java.util.stream.Collectors; 030import java.util.stream.Stream; 031 032import picocli.CommandLine; 033import picocli.CommandLine.Command; 034import picocli.CommandLine.Option; 035 036/** 037 * Main command-line interface for actor-IaC. 038 * 039 * <p>This is the entry point for all actor-IaC commands. Use subcommands to 040 * execute specific operations:</p> 041 * 042 * <h2>Subcommands</h2> 043 * <ul> 044 * <li>{@code run} - Execute workflows</li> 045 * <li>{@code list} - List available workflows</li> 046 * <li>{@code describe} - Describe workflow structure</li> 047 * <li>{@code logs} - Query execution logs</li> 048 * <li>{@code db-clear} - Clear (delete) the log database</li> 049 * </ul> 050 * 051 * <h2>Usage Examples</h2> 052 * <pre> 053 * actor-iac run -d ./workflows -w deploy 054 * actor-iac list -d ./workflows 055 * actor-iac logs --db ./logs --list 056 * </pre> 057 * 058 * @author devteam@scivicslab.com 059 * @since 2.7.0 060 */ 061@Command( 062 name = "actor-iac", 063 mixinStandardHelpOptions = true, 064 versionProvider = VersionProvider.class, 065 description = "Infrastructure as Code workflow automation tool.", 066 subcommands = { 067 RunCLI.class, 068 ListWorkflowsCLI.class, 069 DescribeCLI.class, 070 LogsCLI.class, 071 MergeLogsCLI.class, 072 DbClearCLI.class 073 } 074) 075public class WorkflowCLI implements Callable<Integer> { 076 077 /** 078 * Main entry point. 079 * 080 * @param args command line arguments 081 */ 082 public static void main(String[] args) { 083 // Load logging configuration from resources 084 try (InputStream is = WorkflowCLI.class.getResourceAsStream("/logging.properties")) { 085 if (is != null) { 086 LogManager.getLogManager().readConfiguration(is); 087 } 088 } catch (IOException e) { 089 System.err.println("Warning: Failed to load logging.properties: " + e.getMessage()); 090 } 091 092 int exitCode = new CommandLine(new WorkflowCLI()).execute(args); 093 System.exit(exitCode); 094 } 095 096 /** 097 * Called when no subcommand is specified. 098 * Shows help message. 099 */ 100 @Override 101 public Integer call() { 102 System.out.println("actor-IaC - Infrastructure as Code workflow automation tool"); 103 System.out.println(); 104 System.out.println("Usage: actor-iac <command> [options]"); 105 System.out.println(); 106 System.out.println("Commands:"); 107 System.out.println(" run Execute a workflow"); 108 System.out.println(" list List available workflows"); 109 System.out.println(" describe Describe workflow structure"); 110 System.out.println(" log-search Search execution logs"); 111 System.out.println(" log-merge Merge scattered log databases into one"); 112 System.out.println(); 113 System.out.println("Examples:"); 114 System.out.println(" actor-iac run -w sysinfo/main-collect-sysinfo.yaml -i inventory.ini"); 115 System.out.println(" actor-iac run -d ./sysinfo -w main-collect-sysinfo.yaml -i inventory.ini"); 116 System.out.println(" actor-iac list -w sysinfo"); 117 System.out.println(" actor-iac describe -w sysinfo/main-collect-sysinfo.yaml"); 118 System.out.println(" actor-iac log-search --db ./logs --list"); 119 System.out.println(); 120 System.out.println("Use 'actor-iac <command> --help' for more information about a command."); 121 return 0; 122 } 123} 124 125/** 126 * Subcommand to list workflows discovered under a directory. 127 */ 128@Command( 129 name = "list", 130 mixinStandardHelpOptions = true, 131 versionProvider = VersionProvider.class, 132 description = "List workflows in the specified directory." 133) 134class ListWorkflowsCLI implements Callable<Integer> { 135 136 @Option( 137 names = {"-d", "--dir"}, 138 description = "Base directory. Defaults to current directory.", 139 defaultValue = "." 140 ) 141 private File baseDir; 142 143 @Option( 144 names = {"-w", "--workflow"}, 145 required = true, 146 description = "Workflow directory path (relative to -d)" 147 ) 148 private String workflowPath; 149 150 @Option( 151 names = {"-o", "--output"}, 152 description = "Output format: table, json, yaml (default: table)", 153 defaultValue = "table" 154 ) 155 private String outputFormat; 156 157 @Override 158 public Integer call() { 159 File workflowDir = new File(baseDir, workflowPath); 160 if (!workflowDir.isDirectory()) { 161 System.err.println("Not a directory: " + workflowDir); 162 return 1; 163 } 164 165 List<WorkflowInfo> workflows = scanWorkflowsForDisplay(workflowDir, workflowPath); 166 if (workflows.isEmpty()) { 167 if ("json".equalsIgnoreCase(outputFormat)) { 168 System.out.println("[]"); 169 } else if ("yaml".equalsIgnoreCase(outputFormat)) { 170 System.out.println("workflows: []"); 171 } else { 172 System.out.println("No workflow files found in " + workflowDir.getPath()); 173 } 174 return 0; 175 } 176 177 switch (outputFormat.toLowerCase()) { 178 case "json" -> printJson(workflows); 179 case "yaml" -> printYaml(workflows); 180 default -> printTable(workflows, workflowDir.getPath()); 181 } 182 return 0; 183 } 184 185 private static final int WRAP_WIDTH = 70; 186 private static final String INDENT = " "; 187 188 private void printTable(List<WorkflowInfo> workflows, String dirPath) { 189 System.out.println("Workflows in " + dirPath + ":"); 190 System.out.println(); 191 String separator = "-".repeat(WRAP_WIDTH + INDENT.length()); 192 for (int i = 0; i < workflows.size(); i++) { 193 WorkflowInfo wf = workflows.get(i); 194 System.out.println(" " + wf.path()); 195 if (wf.name() != null) { 196 System.out.println(INDENT + "name: " + wf.name()); 197 } 198 if (wf.description() != null) { 199 printWrapped("description", wf.description()); 200 } 201 if (i < workflows.size() - 1) { 202 System.out.println(separator); 203 } 204 } 205 System.out.println(); 206 } 207 208 private void printWrapped(String label, String text) { 209 String prefix = INDENT + label + ": "; 210 String continuationIndent = INDENT + " ".repeat(label.length() + 2); 211 int maxWidth = WRAP_WIDTH; 212 213 String[] words = text.split("\\s+"); 214 StringBuilder line = new StringBuilder(prefix); 215 boolean firstLine = true; 216 217 for (String word : words) { 218 int currentLen = firstLine ? line.length() : line.length(); 219 if (currentLen + word.length() + 1 > maxWidth + prefix.length() && line.length() > (firstLine ? prefix.length() : continuationIndent.length())) { 220 System.out.println(line.toString()); 221 line = new StringBuilder(continuationIndent); 222 firstLine = false; 223 } 224 if (line.length() > (firstLine ? prefix.length() : continuationIndent.length())) { 225 line.append(" "); 226 } 227 line.append(word); 228 } 229 if (line.length() > (firstLine ? prefix.length() : continuationIndent.length())) { 230 System.out.println(line.toString()); 231 } 232 } 233 234 private void printJson(List<WorkflowInfo> workflows) { 235 org.json.JSONArray arr = new org.json.JSONArray(); 236 for (WorkflowInfo wf : workflows) { 237 org.json.JSONObject obj = new org.json.JSONObject(); 238 obj.put("path", wf.path()); 239 if (wf.name() != null) obj.put("name", wf.name()); 240 if (wf.description() != null) obj.put("description", wf.description()); 241 if (wf.note() != null) obj.put("note", wf.note()); 242 arr.put(obj); 243 } 244 System.out.println(arr.toString(2)); 245 } 246 247 private void printYaml(List<WorkflowInfo> workflows) { 248 System.out.println("workflows:"); 249 for (WorkflowInfo wf : workflows) { 250 System.out.println(" - path: " + wf.path()); 251 if (wf.name() != null) { 252 System.out.println(" name: " + wf.name()); 253 } 254 if (wf.description() != null) { 255 // Handle multi-line descriptions 256 if (wf.description().contains("\n")) { 257 System.out.println(" description: |"); 258 for (String line : wf.description().split("\n")) { 259 System.out.println(" " + line); 260 } 261 } else { 262 System.out.println(" description: " + wf.description()); 263 } 264 } 265 if (wf.note() != null) { 266 System.out.println(" note: " + wf.note()); 267 } 268 } 269 } 270 271 private static List<WorkflowInfo> scanWorkflowsForDisplay(File directory, String workflowPath) { 272 if (directory == null) { 273 return List.of(); 274 } 275 276 // Non-recursive scan - only files in immediate directory 277 try (Stream<Path> paths = Files.list(directory.toPath())) { 278 return paths.filter(Files::isRegularFile) 279 .filter(path -> { 280 String name = path.getFileName().toString().toLowerCase(); 281 return name.endsWith(".yaml") || name.endsWith(".yml") 282 || name.endsWith(".json") || name.endsWith(".xml"); 283 }) 284 .map(path -> { 285 File file = path.toFile(); 286 String relPath = workflowPath.endsWith("/") 287 ? workflowPath + file.getName() 288 : workflowPath + "/" + file.getName(); 289 return extractWorkflowInfo(file, relPath); 290 }) 291 .sorted(Comparator.comparing(WorkflowInfo::path, String.CASE_INSENSITIVE_ORDER)) 292 .collect(Collectors.toList()); 293 } catch (IOException e) { 294 System.err.println("Failed to scan workflows: " + e.getMessage()); 295 return List.of(); 296 } 297 } 298 299 private static WorkflowInfo extractWorkflowInfo(File file, String path) { 300 String fileName = file.getName().toLowerCase(); 301 if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) { 302 return extractWorkflowInfoFromYaml(file, path); 303 } else if (fileName.endsWith(".json")) { 304 return extractWorkflowInfoFromJson(file, path); 305 } 306 return new WorkflowInfo(path, null, null, null); 307 } 308 309 private static WorkflowInfo extractWorkflowInfoFromYaml(File file, String path) { 310 String name = null; 311 String description = null; 312 String note = null; 313 314 try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) { 315 String line; 316 StringBuilder currentValue = new StringBuilder(); 317 String currentField = null; 318 boolean inMultiLine = false; 319 320 while ((line = reader.readLine()) != null) { 321 // Stop at steps or vertices 322 if (line.trim().startsWith("steps:") || line.trim().startsWith("vertices:")) { 323 break; 324 } 325 326 if (inMultiLine) { 327 if (line.startsWith(" ") || line.startsWith("\t") || line.trim().isEmpty()) { 328 if (!line.trim().isEmpty()) { 329 currentValue.append(line.trim()).append(" "); 330 } 331 continue; 332 } else { 333 // End of multi-line 334 String value = currentValue.toString().trim(); 335 if ("name".equals(currentField)) name = value; 336 else if ("description".equals(currentField)) description = value; 337 else if ("note".equals(currentField)) note = value; 338 inMultiLine = false; 339 currentValue = new StringBuilder(); 340 currentField = null; 341 } 342 } 343 344 if (line.trim().startsWith("name:") && name == null) { 345 String value = line.substring(line.indexOf(':') + 1).trim(); 346 if (value.equals("|") || value.equals(">")) { 347 inMultiLine = true; 348 currentField = "name"; 349 } else if (!value.isEmpty()) { 350 name = stripQuotes(value); 351 } 352 } else if (line.trim().startsWith("description:") && description == null) { 353 String value = line.substring(line.indexOf(':') + 1).trim(); 354 if (value.equals("|") || value.equals(">")) { 355 inMultiLine = true; 356 currentField = "description"; 357 } else if (!value.isEmpty()) { 358 description = stripQuotes(value); 359 } 360 } else if (line.trim().startsWith("note:") && note == null) { 361 String value = line.substring(line.indexOf(':') + 1).trim(); 362 if (value.equals("|") || value.equals(">")) { 363 inMultiLine = true; 364 currentField = "note"; 365 } else if (!value.isEmpty()) { 366 note = stripQuotes(value); 367 } 368 } 369 } 370 371 // Handle trailing multi-line 372 if (inMultiLine && currentValue.length() > 0) { 373 String value = currentValue.toString().trim(); 374 if ("name".equals(currentField)) name = value; 375 else if ("description".equals(currentField)) description = value; 376 else if ("note".equals(currentField)) note = value; 377 } 378 } catch (IOException e) { 379 // Ignore 380 } 381 return new WorkflowInfo(path, name, description, note); 382 } 383 384 private static String stripQuotes(String value) { 385 if ((value.startsWith("\"") && value.endsWith("\"")) || 386 (value.startsWith("'") && value.endsWith("'"))) { 387 return value.substring(1, value.length() - 1); 388 } 389 return value; 390 } 391 392 private static WorkflowInfo extractWorkflowInfoFromJson(File file, String path) { 393 try (java.io.FileReader reader = new java.io.FileReader(file)) { 394 org.json.JSONTokener tokener = new org.json.JSONTokener(reader); 395 org.json.JSONObject json = new org.json.JSONObject(tokener); 396 return new WorkflowInfo( 397 path, 398 json.optString("name", null), 399 json.optString("description", null), 400 json.optString("note", null) 401 ); 402 } catch (Exception e) { 403 // Ignore 404 } 405 return new WorkflowInfo(path, null, null, null); 406 } 407 408 private static record WorkflowInfo(String path, String name, String description, String note) {} 409}