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.FileInputStream; 022import java.io.IOException; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.sql.SQLException; 026import java.time.LocalDateTime; 027import java.time.format.DateTimeFormatter; 028import java.util.HashMap; 029import java.util.LinkedHashMap; 030import java.util.List; 031import java.util.Map; 032import java.util.concurrent.Callable; 033import java.util.concurrent.ExecutorService; 034import java.util.logging.Level; 035import java.util.logging.LogManager; 036import java.util.logging.Logger; 037import java.util.stream.Collectors; 038import java.util.stream.Stream; 039import java.util.Comparator; 040 041import com.scivicslab.actoriac.IaCStreamingAccumulator; 042import com.scivicslab.actoriac.NodeGroup; 043import com.scivicslab.actoriac.NodeGroupInterpreter; 044import com.scivicslab.actoriac.NodeGroupIIAR; 045import com.scivicslab.actoriac.accumulator.ConsoleAccumulator; 046import com.scivicslab.actoriac.accumulator.DatabaseAccumulator; 047import com.scivicslab.actoriac.accumulator.FileAccumulator; 048import com.scivicslab.actoriac.accumulator.MultiplexerAccumulator; 049import com.scivicslab.actoriac.accumulator.MultiplexerAccumulatorIIAR; 050import com.scivicslab.actoriac.accumulator.MultiplexerLogHandler; 051import com.scivicslab.actoriac.log.DistributedLogStore; 052import com.scivicslab.actoriac.log.H2LogStore; 053import com.scivicslab.actoriac.log.LogLevel; 054import com.scivicslab.actoriac.log.SessionStatus; 055import com.scivicslab.pojoactor.core.ActionResult; 056import com.scivicslab.pojoactor.core.ActorRef; 057import com.scivicslab.pojoactor.workflow.DynamicActorLoaderActor; 058import com.scivicslab.pojoactor.workflow.IIActorRef; 059import com.scivicslab.pojoactor.workflow.IIActorSystem; 060import com.scivicslab.pojoactor.workflow.kustomize.WorkflowKustomizer; 061 062import picocli.CommandLine; 063import picocli.CommandLine.Command; 064import picocli.CommandLine.Option; 065 066/** 067 * CLI subcommand to execute actor-IaC workflows. 068 * 069 * <p>This is the main workflow execution command. It supports executing workflows 070 * defined in YAML, JSON, or XML format with optional overlay configuration.</p> 071 * 072 * <h2>Usage</h2> 073 * <pre> 074 * actor-iac run -d /path/to/workflows -w main-workflow.yaml 075 * actor-iac run --dir ./workflows --workflow deploy --threads 8 076 * actor-iac run -d ./workflows -w deploy -i inventory.ini -g webservers 077 * </pre> 078 * 079 * @author devteam@scivicslab.com 080 * @since 2.10.0 081 */ 082@Command( 083 name = "run", 084 mixinStandardHelpOptions = true, 085 versionProvider = VersionProvider.class, 086 description = "Execute actor-IaC workflows defined in YAML, JSON, or XML format." 087) 088public class RunCLI implements Callable<Integer> { 089 090 private static final Logger LOG = Logger.getLogger(RunCLI.class.getName()); 091 092 @Option( 093 names = {"-d", "--dir"}, 094 description = "Base directory (logs are created here). Defaults to current directory.", 095 defaultValue = "." 096 ) 097 private File workflowDir; 098 099 @Option( 100 names = {"-w", "--workflow"}, 101 description = "Workflow file path relative to -d (required)", 102 required = true 103 ) 104 private String workflowName; 105 106 @Option( 107 names = {"-i", "--inventory"}, 108 description = "Path to Ansible inventory file (enables node-based execution)" 109 ) 110 private File inventoryFile; 111 112 @Option( 113 names = {"-g", "--group"}, 114 description = "Name of the host group to target (requires --inventory)", 115 defaultValue = "all" 116 ) 117 private String groupName; 118 119 @Option( 120 names = {"-t", "--threads"}, 121 description = "Number of worker threads for CPU-bound operations (default: ${DEFAULT-VALUE})", 122 defaultValue = "4" 123 ) 124 private int threads; 125 126 @Option( 127 names = {"-v", "--verbose"}, 128 description = "Enable verbose output" 129 ) 130 private boolean verbose; 131 132 @Option( 133 names = {"-o", "--overlay"}, 134 description = "Overlay directory containing overlay-conf.yaml for environment-specific configuration" 135 ) 136 private File overlayDir; 137 138 @Option( 139 names = {"--file-log", "-l", "--log"}, 140 description = "Enable text file logging and specify output path. If not specified, only database logging is used." 141 ) 142 private File logFile; 143 144 @Option( 145 names = {"--no-file-log", "--no-log"}, 146 description = "Explicitly disable text file logging. (Text logging is disabled by default.)" 147 ) 148 private boolean noLog; 149 150 @Option( 151 names = {"--log-db"}, 152 description = "H2 database path for distributed logging (default: actor-iac-logs in current directory)" 153 ) 154 private File logDbPath; 155 156 @Option( 157 names = {"--no-log-db"}, 158 description = "Disable H2 database logging" 159 ) 160 private boolean noLogDb; 161 162 @Option( 163 names = {"-k", "--ask-pass"}, 164 description = "Prompt for SSH password (uses password authentication instead of ssh-agent)" 165 ) 166 private boolean askPass; 167 168 @Option( 169 names = {"--limit"}, 170 description = "Limit execution to specific hosts (comma-separated, e.g., '192.168.5.15' or '192.168.5.15,192.168.5.16')" 171 ) 172 private String limitHosts; 173 174 @Option( 175 names = {"-L", "--list-workflows"}, 176 description = "List workflows discovered under --dir and exit" 177 ) 178 private boolean listWorkflows; 179 180 @Option( 181 names = {"-q", "--quiet"}, 182 description = "Suppress all console output (stdout/stderr). Logs are still written to database." 183 ) 184 private boolean quiet; 185 186 @Option( 187 names = {"--cowfile", "-c"}, 188 description = "Cowsay character to use for step display. " + 189 "Available: tux, dragon, stegosaurus, kitty, bunny, turtle, elephant, " + 190 "ghostbusters, vader, and 35 more. Use '--cowfile list' to see all." 191 ) 192 private String cowfile; 193 194 @Option( 195 names = {"--render-to"}, 196 description = "Render overlay-applied workflows to specified directory (does not execute)" 197 ) 198 private File renderToDir; 199 200 @Option( 201 names = {"--max-steps", "-m"}, 202 description = "Maximum number of state transitions allowed (default: ${DEFAULT-VALUE}). " + 203 "Use a smaller value for debugging to prevent infinite loops.", 204 defaultValue = "10000" 205 ) 206 private int maxSteps; 207 208 /** Cache of discovered workflow files: name -> File */ 209 private final Map<String, File> workflowCache = new HashMap<>(); 210 211 /** Distributed log store (H2 database) */ 212 private DistributedLogStore logStore; 213 214 /** Actor reference for the log store (for async writes) */ 215 private ActorRef<DistributedLogStore> logStoreActor; 216 217 /** Dedicated executor service for DB writes */ 218 private ExecutorService dbExecutor; 219 220 /** Current session ID for distributed logging */ 221 private long sessionId = -1; 222 223 /** 224 * Executes the workflow. 225 * 226 * @return exit code (0 for success, non-zero for failure) 227 */ 228 @Override 229 public Integer call() { 230 // Handle --cowfile list: show available cowfiles and exit 231 if ("list".equalsIgnoreCase(cowfile)) { 232 printAvailableCowfiles(); 233 return 0; 234 } 235 236 // Validate --dir is required for all other operations 237 if (workflowDir == null) { 238 System.err.println("Missing required option: '--dir=<workflowDir>'"); 239 CommandLine.usage(this, System.err); 240 return 2; 241 } 242 243 // Validate required options 244 // --render-to only requires --dir and --overlay, not --workflow 245 if (renderToDir != null) { 246 if (overlayDir == null) { 247 if (!quiet) { 248 System.err.println("--render-to requires '--overlay=<overlayDir>'"); 249 CommandLine.usage(this, System.err); 250 } 251 return 2; 252 } 253 } else if (workflowName == null && !listWorkflows) { 254 if (!quiet) { 255 System.err.println("Missing required option: '--workflow=<workflowName>'"); 256 System.err.println("Use --list-workflows to see available workflows."); 257 CommandLine.usage(this, System.err); 258 } 259 return 2; 260 } 261 262 // In quiet mode, suppress all console output 263 if (quiet) { 264 suppressConsoleOutput(); 265 } 266 267 // Configure log level based on verbose flag 268 configureLogLevel(verbose); 269 270 // Setup H2 log database (enabled by default, use --no-log-db to disable) 271 if (!noLogDb) { 272 if (logDbPath == null) { 273 // Default: actor-iac-logs in current directory 274 logDbPath = new File("actor-iac-logs"); 275 } 276 try { 277 setupLogDatabase(); 278 } catch (SQLException e) { 279 if (!quiet) { 280 System.err.println("Warning: Failed to setup log database: " + e.getMessage()); 281 } 282 } 283 } 284 285 // Setup text file logging via H2LogStore (only when -l/--log option is specified) 286 if (logFile != null && logStore != null) { 287 try { 288 setupTextLogging(); 289 } catch (IOException e) { 290 if (!quiet) { 291 System.err.println("Warning: Failed to setup text file logging: " + e.getMessage()); 292 } 293 } 294 } 295 296 try { 297 return executeMain(); 298 } finally { 299 // Clean up resources (H2LogStore.close() also closes text log writer) 300 // Clear singleton before closing 301 DistributedLogStore.setInstance(null); 302 if (logStore != null) { 303 try { 304 logStore.close(); 305 } catch (Exception e) { 306 System.err.println("Warning: Failed to close log database: " + e.getMessage()); 307 } 308 } 309 } 310 } 311 312 /** 313 * Suppresses all console output (stdout/stderr) for quiet mode. 314 */ 315 private void suppressConsoleOutput() { 316 // Remove ConsoleHandler from root logger 317 Logger rootLogger = Logger.getLogger(""); 318 for (java.util.logging.Handler handler : rootLogger.getHandlers()) { 319 if (handler instanceof java.util.logging.ConsoleHandler) { 320 rootLogger.removeHandler(handler); 321 } 322 } 323 324 // Redirect System.out and System.err to null 325 java.io.PrintStream nullStream = new java.io.PrintStream(java.io.OutputStream.nullOutputStream()); 326 System.setOut(nullStream); 327 System.setErr(nullStream); 328 } 329 330 /** 331 * Sets up H2 database for distributed logging. 332 * 333 * <p>Uses H2's AUTO_SERVER mode which automatically handles multiple processes 334 * accessing the same database. The first process starts a TCP server, and 335 * subsequent processes connect to it automatically.</p> 336 */ 337 private void setupLogDatabase() throws SQLException { 338 Path dbPath = logDbPath.toPath(); 339 logStore = new H2LogStore(dbPath); 340 // Set singleton for WorkflowReporter and other components 341 DistributedLogStore.setInstance(logStore); 342 System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db"); 343 } 344 345 /** 346 * Sets up text file logging via H2LogStore. 347 * 348 * <p>This method configures the H2LogStore to also write log entries 349 * to a text file, in addition to the H2 database. Only called when 350 * -l/--log option is explicitly specified.</p> 351 */ 352 private void setupTextLogging() throws IOException { 353 Path logFilePath = logFile.toPath(); 354 355 // Configure H2LogStore to also write to text file 356 ((H2LogStore) logStore).setTextLogFile(logFilePath); 357 358 LOG.info("Text logging enabled: " + logFilePath); 359 } 360 361 /** 362 * Configures log level based on verbose flag. 363 */ 364 private void configureLogLevel(boolean verbose) { 365 Level targetLevel = verbose ? Level.FINER : Level.INFO; 366 367 // Try to load logging.properties from classpath if verbose mode 368 if (verbose) { 369 try (java.io.InputStream is = getClass().getClassLoader().getResourceAsStream("logging.properties")) { 370 if (is != null) { 371 LogManager.getLogManager().readConfiguration(is); 372 LOG.info("Loaded logging.properties from classpath"); 373 } 374 } catch (java.io.IOException e) { 375 LOG.fine("Could not load logging.properties: " + e.getMessage()); 376 } 377 } 378 379 // Configure root logger level 380 Logger rootLogger = Logger.getLogger(""); 381 rootLogger.setLevel(targetLevel); 382 383 // Configure handlers to respect the new level 384 for (java.util.logging.Handler handler : rootLogger.getHandlers()) { 385 handler.setLevel(targetLevel); 386 } 387 388 // Configure POJO-actor loggers for tracing 389 Logger interpreterLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.Interpreter"); 390 interpreterLogger.setLevel(targetLevel); 391 Logger actorSystemLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.IIActorSystem"); 392 actorSystemLogger.setLevel(targetLevel); 393 Logger genericIIARLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.DynamicActorLoaderActor$GenericIIAR"); 394 genericIIARLogger.setLevel(targetLevel); 395 396 if (verbose) { 397 LOG.info("Verbose mode enabled - log level set to FINER (tracing enabled)"); 398 } 399 } 400 401 /** 402 * Main execution logic. 403 */ 404 private Integer executeMain() { 405 // Validate workflow directory 406 if (!workflowDir.exists()) { 407 LOG.severe("Workflow directory does not exist: " + workflowDir); 408 return 1; 409 } 410 411 if (!workflowDir.isDirectory()) { 412 LOG.severe("Not a directory: " + workflowDir); 413 return 1; 414 } 415 416 // Scan workflow directory recursively 417 LOG.info("Scanning workflow directory: " + workflowDir.getAbsolutePath()); 418 scanWorkflowDirectory(workflowDir.toPath()); 419 420 if (verbose) { 421 LOG.info("Discovered " + workflowCache.size() + " workflow files:"); 422 workflowCache.forEach((name, file) -> 423 LOG.info(" " + name + " -> " + file.getPath())); 424 } 425 426 if (listWorkflows) { 427 printWorkflowList(); 428 return 0; 429 } 430 431 // Handle --render-to option 432 if (renderToDir != null) { 433 return renderOverlayWorkflows(); 434 } 435 436 if (workflowName == null || workflowName.isBlank()) { 437 LOG.severe("Workflow name is required unless --list-workflows is specified."); 438 return 1; 439 } 440 441 // Find main workflow 442 File mainWorkflowFile = findWorkflowFile(workflowName); 443 if (mainWorkflowFile == null) { 444 LOG.severe("Workflow not found: " + workflowName); 445 LOG.info("Available workflows: " + workflowCache.keySet()); 446 return 1; 447 } 448 449 // Validate overlay directory if specified 450 if (overlayDir != null) { 451 if (!overlayDir.exists()) { 452 LOG.severe("Overlay directory does not exist: " + overlayDir); 453 return 1; 454 } 455 if (!overlayDir.isDirectory()) { 456 LOG.severe("Overlay path is not a directory: " + overlayDir); 457 return 1; 458 } 459 } 460 461 LOG.info("=== actor-IaC Workflow CLI ==="); 462 LOG.info("Workflow directory: " + workflowDir.getAbsolutePath()); 463 LOG.info("Main workflow: " + mainWorkflowFile.getName()); 464 if (overlayDir != null) { 465 LOG.info("Overlay: " + overlayDir.getAbsolutePath()); 466 } 467 LOG.info("Worker threads: " + threads); 468 469 // Execute workflow (use local inventory if none specified) 470 return executeWorkflow(mainWorkflowFile); 471 } 472 473 /** 474 * Renders overlay-applied workflows to the specified directory. 475 */ 476 private Integer renderOverlayWorkflows() { 477 if (overlayDir == null) { 478 LOG.severe("--render-to requires --overlay to be specified"); 479 return 1; 480 } 481 482 try { 483 // Create output directory if it doesn't exist 484 if (!renderToDir.exists()) { 485 if (!renderToDir.mkdirs()) { 486 LOG.severe("Failed to create output directory: " + renderToDir); 487 return 1; 488 } 489 } 490 491 LOG.info("=== Rendering Overlay-Applied Workflows ==="); 492 LOG.info("Overlay directory: " + overlayDir.getAbsolutePath()); 493 LOG.info("Output directory: " + renderToDir.getAbsolutePath()); 494 495 WorkflowKustomizer kustomizer = new WorkflowKustomizer(); 496 Map<String, Map<String, Object>> workflows = kustomizer.build(overlayDir.toPath()); 497 498 org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml(); 499 500 for (Map.Entry<String, Map<String, Object>> entry : workflows.entrySet()) { 501 String fileName = entry.getKey(); 502 Map<String, Object> workflowContent = entry.getValue(); 503 504 Path outputPath = renderToDir.toPath().resolve(fileName); 505 506 // Add header comment 507 StringBuilder sb = new StringBuilder(); 508 sb.append("# Rendered from overlay: ").append(overlayDir.getName()).append("\n"); 509 sb.append("# Source: ").append(fileName).append("\n"); 510 sb.append("# Generated: ").append(LocalDateTime.now()).append("\n"); 511 sb.append("#\n"); 512 sb.append(yaml.dump(workflowContent)); 513 514 Files.writeString(outputPath, sb.toString()); 515 LOG.info(" Written: " + outputPath); 516 } 517 518 LOG.info("Rendered " + workflows.size() + " workflow(s) to " + renderToDir.getAbsolutePath()); 519 return 0; 520 521 } catch (IOException e) { 522 LOG.severe("Failed to render workflows: " + e.getMessage()); 523 return 1; 524 } catch (Exception e) { 525 LOG.severe("Error during rendering: " + e.getMessage()); 526 e.printStackTrace(); 527 return 1; 528 } 529 } 530 531 /** 532 * Executes workflow with node-based execution. 533 */ 534 private Integer executeWorkflow(File mainWorkflowFile) { 535 NodeGroup nodeGroup; 536 537 // Prompt for password if --ask-pass is specified 538 String sshPassword = null; 539 if (askPass) { 540 sshPassword = promptForPassword("SSH password: "); 541 if (sshPassword == null) { 542 LOG.severe("Password input cancelled"); 543 return 1; 544 } 545 } 546 547 IIActorSystem system = new IIActorSystem("actor-iac-cli", threads); 548 549 // Add dedicated thread pool for DB writes (single thread to serialize writes) 550 system.addManagedThreadPool(1); 551 dbExecutor = system.getManagedThreadPool(1); 552 553 // Create logStore actor if log database is configured 554 if (logStore != null) { 555 logStoreActor = system.actorOf("logStore", logStore); 556 557 // Start log session with execution context 558 String overlayName = overlayDir != null ? overlayDir.getName() : null; 559 String inventoryName = inventoryFile != null ? inventoryFile.getName() : null; 560 561 // Collect execution context for reproducibility 562 String cwd = System.getProperty("user.dir"); 563 String gitCommit = getGitCommit(workflowDir); 564 String gitBranch = getGitBranch(workflowDir); 565 String commandLine = buildCommandLine(); 566 String actorIacVersion = com.scivicslab.actoriac.Version.get(); 567 String actorIacCommit = getActorIacCommit(); 568 569 sessionId = logStore.startSession(workflowName, overlayName, inventoryName, 1, 570 cwd, gitCommit, gitBranch, commandLine, actorIacVersion, actorIacCommit); 571 logStore.log(sessionId, "cli", LogLevel.INFO, "Starting workflow: " + workflowName); 572 } 573 574 try { 575 if (inventoryFile != null) { 576 // Use specified inventory file 577 if (!inventoryFile.exists()) { 578 LOG.severe("Inventory file does not exist: " + inventoryFile); 579 logToDb("cli", LogLevel.ERROR, "Inventory file does not exist: " + inventoryFile); 580 return 1; 581 } 582 LOG.info("Inventory: " + inventoryFile.getAbsolutePath()); 583 nodeGroup = new NodeGroup.Builder() 584 .withInventory(new FileInputStream(inventoryFile)) 585 .build(); 586 } else { 587 // Use empty NodeGroup for local execution 588 LOG.info("Inventory: (none - using localhost)"); 589 nodeGroup = new NodeGroup(); 590 } 591 592 // Set SSH password if provided 593 if (sshPassword != null) { 594 nodeGroup.setSshPassword(sshPassword); 595 LOG.info("Authentication: password"); 596 } else { 597 LOG.info("Authentication: ssh-agent"); 598 } 599 600 // Set host limit if provided 601 if (limitHosts != null) { 602 nodeGroup.setHostLimit(limitHosts); 603 LOG.info("Host limit: " + limitHosts); 604 } 605 606 // Step 1: Create NodeGroupInterpreter 607 NodeGroupInterpreter nodeGroupInterpreter = new NodeGroupInterpreter(nodeGroup, system); 608 // Set workflow base dir to the directory containing the main workflow file 609 // so that sub-workflows can be found relative to the main workflow 610 File workflowBaseDir = mainWorkflowFile.getParentFile(); 611 if (workflowBaseDir == null) { 612 workflowBaseDir = workflowDir; 613 } 614 nodeGroupInterpreter.setWorkflowBaseDir(workflowBaseDir.getAbsolutePath()); 615 if (overlayDir != null) { 616 nodeGroupInterpreter.setOverlayDir(overlayDir.getAbsolutePath()); 617 } 618 if (verbose) { 619 nodeGroupInterpreter.setVerbose(true); 620 } 621 // Create IaCStreamingAccumulator for cowsay display 622 IaCStreamingAccumulator accumulator = new IaCStreamingAccumulator(); 623 if (cowfile != null && !cowfile.isBlank()) { 624 accumulator.setCowfile(cowfile); 625 } 626 nodeGroupInterpreter.setAccumulator(accumulator); 627 628 // Inject log store into interpreter for node-level logging 629 if (logStore != null) { 630 nodeGroupInterpreter.setLogStore(logStore, logStoreActor, dbExecutor, sessionId); 631 } 632 633 // Step 2: Create NodeGroupIIAR and register with system 634 NodeGroupIIAR nodeGroupActor = new NodeGroupIIAR("nodeGroup", nodeGroupInterpreter, system); 635 system.addIIActor(nodeGroupActor); 636 637 // Step 2.5: Create and register MultiplexerAccumulatorIIAR 638 MultiplexerAccumulator multiplexer = new MultiplexerAccumulator(); 639 640 // Add ConsoleAccumulator (unless --quiet is specified) 641 if (!quiet) { 642 multiplexer.addTarget(new ConsoleAccumulator()); 643 } 644 645 // Add FileAccumulator if --file-log is specified 646 if (logFile != null) { 647 try { 648 multiplexer.addTarget(new FileAccumulator(logFile.toPath())); 649 LOG.info("File logging enabled: " + logFile.getAbsolutePath()); 650 } catch (IOException e) { 651 LOG.warning("Failed to create file accumulator: " + e.getMessage()); 652 } 653 } 654 655 // Add DatabaseAccumulator if log store is configured 656 if (logStoreActor != null && sessionId >= 0) { 657 multiplexer.addTarget(new DatabaseAccumulator(logStoreActor, dbExecutor, sessionId)); 658 LOG.fine("Database logging enabled for session: " + sessionId); 659 } 660 661 // Register multiplexer actor with the system 662 MultiplexerAccumulatorIIAR multiplexerActor = new MultiplexerAccumulatorIIAR( 663 "outputMultiplexer", multiplexer, system); 664 system.addIIActor(multiplexerActor); 665 LOG.fine("MultiplexerAccumulator registered as 'outputMultiplexer'"); 666 667 // Step 2.6: Create and register DynamicActorLoaderActor for plugin loading 668 DynamicActorLoaderActor loaderActor = new DynamicActorLoaderActor(system); 669 IIActorRef<DynamicActorLoaderActor> loaderRef = new IIActorRef<>("loader", loaderActor, system) { 670 @Override 671 public ActionResult callByActionName(String actionName, String args) { 672 return loaderActor.callByActionName(actionName, args); 673 } 674 }; 675 system.addIIActor(loaderRef); 676 LOG.fine("DynamicActorLoaderActor registered as 'loader'"); 677 678 // Add log handler to forward java.util.logging to multiplexer 679 MultiplexerLogHandler logHandler = new MultiplexerLogHandler(system); 680 logHandler.setLevel(java.util.logging.Level.ALL); 681 java.util.logging.Logger.getLogger("").addHandler(logHandler); 682 683 // Step 3: Load the main workflow (with overlay if specified) 684 ActionResult loadResult = loadMainWorkflow(nodeGroupActor, mainWorkflowFile, overlayDir); 685 if (!loadResult.isSuccess()) { 686 LOG.severe("Failed to load workflow: " + loadResult.getResult()); 687 logToDb("cli", LogLevel.ERROR, "Failed to load workflow: " + loadResult.getResult()); 688 endSession(SessionStatus.FAILED); 689 return 1; 690 } 691 692 // Step 3.5: Create node actors 693 // If no inventory file is specified, use "local" group for localhost execution 694 String effectiveGroup = (inventoryFile == null) ? "local" : groupName; 695 if (effectiveGroup != null) { 696 LOG.info("Creating node actors for group: " + effectiveGroup); 697 logToDb("cli", LogLevel.INFO, "Creating node actors for group: " + effectiveGroup); 698 ActionResult createResult = nodeGroupActor.callByActionName("createNodeActors", "[\"" + effectiveGroup + "\"]"); 699 if (!createResult.isSuccess()) { 700 LOG.severe("Failed to create node actors: " + createResult.getResult()); 701 logToDb("cli", LogLevel.ERROR, "Failed to create node actors: " + createResult.getResult()); 702 endSession(SessionStatus.FAILED); 703 return 1; 704 } 705 LOG.info("Node actors created: " + createResult.getResult()); 706 } 707 708 // Step 4: Execute the workflow 709 LOG.info("Starting workflow execution..."); 710 LOG.info("Max steps: " + maxSteps); 711 LOG.info("-".repeat(50)); 712 logToDb("cli", LogLevel.INFO, "Starting workflow execution (max steps: " + maxSteps + ")"); 713 714 long startTime = System.currentTimeMillis(); 715 ActionResult result = nodeGroupActor.callByActionName("runUntilEnd", "[" + maxSteps + "]"); 716 long duration = System.currentTimeMillis() - startTime; 717 718 LOG.info("-".repeat(50)); 719 if (result.isSuccess()) { 720 LOG.info("Workflow completed successfully: " + result.getResult()); 721 logToDb("cli", LogLevel.INFO, "Workflow completed successfully in " + duration + "ms"); 722 endSession(SessionStatus.COMPLETED); 723 return 0; 724 } else { 725 LOG.severe("Workflow failed: " + result.getResult()); 726 logToDb("cli", LogLevel.ERROR, "Workflow failed: " + result.getResult()); 727 endSession(SessionStatus.FAILED); 728 return 1; 729 } 730 731 } catch (Exception e) { 732 LOG.log(Level.SEVERE, "Workflow execution failed", e); 733 logToDb("cli", LogLevel.ERROR, "Exception: " + e.getMessage()); 734 endSession(SessionStatus.FAILED); 735 return 1; 736 } finally { 737 system.terminate(); 738 } 739 } 740 741 /** 742 * Logs a message to the distributed log store if available. 743 */ 744 private void logToDb(String nodeId, LogLevel level, String message) { 745 if (logStore != null && sessionId >= 0) { 746 logStore.log(sessionId, nodeId, level, message); 747 } 748 } 749 750 /** 751 * Ends the current session with the given status. 752 */ 753 private void endSession(SessionStatus status) { 754 if (logStore != null && sessionId >= 0) { 755 logStore.endSession(sessionId, status); 756 } 757 } 758 759 /** 760 * Prompts for a password from the console. 761 */ 762 private String promptForPassword(String prompt) { 763 java.io.Console console = System.console(); 764 if (console != null) { 765 // Secure input - password not echoed 766 char[] passwordChars = console.readPassword(prompt); 767 if (passwordChars != null) { 768 return new String(passwordChars); 769 } 770 return null; 771 } else { 772 // Fallback for environments without console (e.g., IDE) 773 System.out.print(prompt); 774 try (java.util.Scanner scanner = new java.util.Scanner(System.in)) { 775 if (scanner.hasNextLine()) { 776 return scanner.nextLine(); 777 } 778 } 779 return null; 780 } 781 } 782 783 /** 784 * Scans the immediate directory for workflow files (non-recursive). 785 * 786 * @since 2.12.1 787 */ 788 private void scanWorkflowDirectory(Path dir) { 789 try (Stream<Path> paths = Files.list(dir)) { 790 paths.filter(Files::isRegularFile) 791 .filter(this::isWorkflowFile) 792 .forEach(this::registerWorkflowFile); 793 } catch (IOException e) { 794 LOG.warning("Error scanning directory: " + e.getMessage()); 795 } 796 } 797 798 /** 799 * Checks if a file is a workflow file (YAML, JSON, or XML). 800 */ 801 private boolean isWorkflowFile(Path path) { 802 String name = path.getFileName().toString().toLowerCase(); 803 return name.endsWith(".yaml") || name.endsWith(".yml") || 804 name.endsWith(".json") || name.endsWith(".xml"); 805 } 806 807 /** 808 * Registers a workflow file in the cache. 809 */ 810 private void registerWorkflowFile(Path path) { 811 File file = path.toFile(); 812 String fileName = file.getName(); 813 814 // Register with full filename 815 workflowCache.put(fileName, file); 816 817 // Register without extension 818 int dotIndex = fileName.lastIndexOf('.'); 819 if (dotIndex > 0) { 820 String baseName = fileName.substring(0, dotIndex); 821 // Only register base name if not already taken by another file 822 workflowCache.putIfAbsent(baseName, file); 823 } 824 } 825 826 /** 827 * Finds a workflow file by path relative to workflowDir. 828 * 829 * <p>Supports paths like "sysinfo/main-collect-sysinfo.yaml" or 830 * "sysinfo/main-collect-sysinfo" (extension auto-detected).</p> 831 * 832 * @param path workflow file path relative to workflowDir 833 * @return the workflow file, or null if not found 834 * @since 2.12.1 835 */ 836 public File findWorkflowFile(String path) { 837 // Resolve path relative to workflowDir 838 File file = new File(workflowDir, path); 839 840 // Try exact path first 841 if (file.isFile()) { 842 return file; 843 } 844 845 // Try with common extensions 846 String[] extensions = {".yaml", ".yml", ".json", ".xml"}; 847 for (String ext : extensions) { 848 File candidate = new File(workflowDir, path + ext); 849 if (candidate.isFile()) { 850 return candidate; 851 } 852 } 853 854 return null; 855 } 856 857 /** 858 * Loads the main workflow file with optional overlay support. 859 */ 860 private ActionResult loadMainWorkflow(NodeGroupIIAR nodeGroupActor, File workflowFile, File overlayDir) { 861 LOG.info("Loading workflow: " + workflowFile.getAbsolutePath()); 862 logToDb("cli", LogLevel.INFO, "Loading workflow: " + workflowFile.getName()); 863 864 String loadArg; 865 if (overlayDir != null) { 866 // Pass both workflow path and overlay directory 867 loadArg = "[\"" + workflowFile.getAbsolutePath() + "\", \"" + overlayDir.getAbsolutePath() + "\"]"; 868 LOG.fine("Loading with overlay: " + overlayDir.getAbsolutePath()); 869 } else { 870 // Load without overlay 871 loadArg = "[\"" + workflowFile.getAbsolutePath() + "\"]"; 872 } 873 874 return nodeGroupActor.callByActionName("readYaml", loadArg); 875 } 876 877 private void printWorkflowList() { 878 List<WorkflowDisplay> displays = scanWorkflowsForDisplay(workflowDir); 879 880 if (displays.isEmpty()) { 881 System.out.println("No workflow files found under " 882 + workflowDir.getAbsolutePath()); 883 return; 884 } 885 886 System.out.println("Available workflows (directory: " 887 + workflowDir.getAbsolutePath() + ")"); 888 System.out.println("-".repeat(90)); 889 System.out.printf("%-4s %-25s %-35s %s%n", "#", "File (-w)", "Path", "Workflow Name (in logs)"); 890 System.out.println("-".repeat(90)); 891 int index = 1; 892 for (WorkflowDisplay display : displays) { 893 String wfName = display.workflowName() != null ? display.workflowName() : "(no name)"; 894 System.out.printf("%2d. %-25s %-35s %s%n", 895 index++, display.baseName(), display.relativePath(), wfName); 896 } 897 System.out.println("-".repeat(90)); 898 System.out.println("Use -w <File> with the names shown in the 'File (-w)' column."); 899 } 900 901 /** 902 * Prints available cowfiles for cowsay output customization. 903 */ 904 private void printAvailableCowfiles() { 905 System.out.println("Available cowfiles for --cowfile option:"); 906 System.out.println("=".repeat(70)); 907 System.out.println(); 908 909 // Get list from Cowsay library 910 String[] listArgs = { "-l" }; 911 String cowList = com.github.ricksbrown.cowsay.Cowsay.say(listArgs); 912 913 // Split and format nicely 914 String[] cowfiles = cowList.trim().split("\\s+"); 915 System.out.println("Total: " + cowfiles.length + " cowfiles"); 916 System.out.println(); 917 918 // Print in columns 919 int cols = 4; 920 int colWidth = 17; 921 for (int i = 0; i < cowfiles.length; i++) { 922 System.out.printf("%-" + colWidth + "s", cowfiles[i]); 923 if ((i + 1) % cols == 0) { 924 System.out.println(); 925 } 926 } 927 if (cowfiles.length % cols != 0) { 928 System.out.println(); 929 } 930 931 System.out.println(); 932 System.out.println("=".repeat(70)); 933 System.out.println("Usage: actor_iac.java run -d <dir> -w <workflow> --cowfile tux"); 934 System.out.println(); 935 System.out.println("Popular choices:"); 936 System.out.println(" tux - Linux penguin (great for server work)"); 937 System.out.println(" dragon - Majestic dragon"); 938 System.out.println(" stegosaurus - Prehistoric dinosaur"); 939 System.out.println(" turtle - Slow and steady"); 940 System.out.println(" ghostbusters - Who you gonna call?"); 941 } 942 943 private static String getBaseName(String fileName) { 944 int dotIndex = fileName.lastIndexOf('.'); 945 if (dotIndex > 0) { 946 return fileName.substring(0, dotIndex); 947 } 948 return fileName; 949 } 950 951 private static String relativize(Path base, Path target) { 952 try { 953 return base.relativize(target).toString(); 954 } catch (IllegalArgumentException e) { 955 return target.toAbsolutePath().toString(); 956 } 957 } 958 959 /** 960 * Scans the immediate directory for workflow files (non-recursive). 961 * 962 * @since 2.12.1 963 */ 964 private static List<WorkflowDisplay> scanWorkflowsForDisplay(File directory) { 965 if (directory == null) { 966 return List.of(); 967 } 968 969 try (Stream<Path> paths = Files.list(directory.toPath())) { 970 Map<String, File> uniqueFiles = new LinkedHashMap<>(); 971 paths.filter(Files::isRegularFile) 972 .filter(path -> { 973 String name = path.getFileName().toString().toLowerCase(); 974 return name.endsWith(".yaml") || name.endsWith(".yml") 975 || name.endsWith(".json") || name.endsWith(".xml"); 976 }) 977 .forEach(path -> uniqueFiles.putIfAbsent(path.toFile().getAbsolutePath(), path.toFile())); 978 979 Path basePath = directory.toPath(); 980 return uniqueFiles.values().stream() 981 .map(file -> new WorkflowDisplay( 982 getBaseName(file.getName()), 983 relativize(basePath, file.toPath()), 984 extractWorkflowName(file))) 985 .sorted(Comparator.comparing(WorkflowDisplay::baseName, String.CASE_INSENSITIVE_ORDER)) 986 .collect(Collectors.toList()); 987 } catch (IOException e) { 988 System.err.println("Failed to scan workflows: " + e.getMessage()); 989 return List.of(); 990 } 991 } 992 993 /** 994 * Extracts the workflow name from a YAML/JSON/XML file. 995 */ 996 private static String extractWorkflowName(File file) { 997 String fileName = file.getName().toLowerCase(); 998 if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) { 999 return extractNameFromYaml(file); 1000 } else if (fileName.endsWith(".json")) { 1001 return extractNameFromJson(file); 1002 } 1003 return null; 1004 } 1005 1006 /** 1007 * Extracts name field from YAML file using simple line parsing. 1008 */ 1009 private static String extractNameFromYaml(File file) { 1010 try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) { 1011 String line; 1012 while ((line = reader.readLine()) != null) { 1013 line = line.trim(); 1014 // Look for "name: value" at the top level (not indented) 1015 if (line.startsWith("name:")) { 1016 String value = line.substring(5).trim(); 1017 // Remove quotes if present 1018 if ((value.startsWith("\"") && value.endsWith("\"")) || 1019 (value.startsWith("'") && value.endsWith("'"))) { 1020 value = value.substring(1, value.length() - 1); 1021 } 1022 return value.isEmpty() ? null : value; 1023 } 1024 // Stop if we hit a steps: or other major section 1025 if (line.startsWith("steps:") || line.startsWith("vertices:")) { 1026 break; 1027 } 1028 } 1029 } catch (IOException e) { 1030 // Ignore and return null 1031 } 1032 return null; 1033 } 1034 1035 /** 1036 * Extracts name field from JSON file. 1037 */ 1038 private static String extractNameFromJson(File file) { 1039 try (java.io.FileReader reader = new java.io.FileReader(file)) { 1040 org.json.JSONTokener tokener = new org.json.JSONTokener(reader); 1041 org.json.JSONObject json = new org.json.JSONObject(tokener); 1042 return json.optString("name", null); 1043 } catch (Exception e) { 1044 // Ignore and return null 1045 } 1046 return null; 1047 } 1048 1049 /** 1050 * Record for displaying workflow information. 1051 */ 1052 private static record WorkflowDisplay(String baseName, String relativePath, String workflowName) {} 1053 1054 /** 1055 * Gets the git commit hash of the specified directory. 1056 * 1057 * @param dir directory to check (uses current directory if null) 1058 * @return short git commit hash, or null if not a git repository 1059 */ 1060 private String getGitCommit(File dir) { 1061 try { 1062 ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--short", "HEAD"); 1063 if (dir != null) { 1064 pb.directory(dir); 1065 } 1066 pb.redirectErrorStream(true); 1067 Process p = pb.start(); 1068 String output = new String(p.getInputStream().readAllBytes()).trim(); 1069 int exitCode = p.waitFor(); 1070 return exitCode == 0 && !output.isEmpty() ? output : null; 1071 } catch (Exception e) { 1072 return null; 1073 } 1074 } 1075 1076 /** 1077 * Gets the git branch name of the specified directory. 1078 * 1079 * @param dir directory to check (uses current directory if null) 1080 * @return git branch name, or null if not a git repository 1081 */ 1082 private String getGitBranch(File dir) { 1083 try { 1084 ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD"); 1085 if (dir != null) { 1086 pb.directory(dir); 1087 } 1088 pb.redirectErrorStream(true); 1089 Process p = pb.start(); 1090 String output = new String(p.getInputStream().readAllBytes()).trim(); 1091 int exitCode = p.waitFor(); 1092 return exitCode == 0 && !output.isEmpty() ? output : null; 1093 } catch (Exception e) { 1094 return null; 1095 } 1096 } 1097 1098 /** 1099 * Builds a command line string from the current options. 1100 * 1101 * @return command line string 1102 */ 1103 private String buildCommandLine() { 1104 StringBuilder sb = new StringBuilder("run"); 1105 if (workflowDir != null) { 1106 sb.append(" -d ").append(workflowDir.getPath()); 1107 } 1108 if (workflowName != null) { 1109 sb.append(" -w ").append(workflowName); 1110 } 1111 if (inventoryFile != null) { 1112 sb.append(" -i ").append(inventoryFile.getPath()); 1113 } 1114 if (groupName != null && !groupName.equals("all")) { 1115 sb.append(" -g ").append(groupName); 1116 } 1117 if (overlayDir != null) { 1118 sb.append(" -o ").append(overlayDir.getPath()); 1119 } 1120 if (threads != 4) { 1121 sb.append(" -t ").append(threads); 1122 } 1123 if (verbose) { 1124 sb.append(" -v"); 1125 } 1126 if (quiet) { 1127 sb.append(" -q"); 1128 } 1129 if (limitHosts != null) { 1130 sb.append(" --limit ").append(limitHosts); 1131 } 1132 return sb.toString(); 1133 } 1134 1135 /** 1136 * Gets the git commit hash of the actor-IaC installation. 1137 * This looks for a .git directory relative to where actor-IaC is running. 1138 * 1139 * @return short git commit hash, or null if not available 1140 */ 1141 private String getActorIacCommit() { 1142 // Try to get commit from the actor-IaC source directory if running in dev mode 1143 try { 1144 String classPath = RunCLI.class.getProtectionDomain().getCodeSource().getLocation().getPath(); 1145 File classFile = new File(classPath); 1146 // If running from target/classes, go up to project root 1147 if (classFile.getPath().contains("target")) { 1148 File projectRoot = classFile.getParentFile(); 1149 while (projectRoot != null && !new File(projectRoot, ".git").exists()) { 1150 projectRoot = projectRoot.getParentFile(); 1151 } 1152 if (projectRoot != null) { 1153 return getGitCommit(projectRoot); 1154 } 1155 } 1156 } catch (Exception e) { 1157 // Ignore 1158 } 1159 return null; 1160 } 1161}