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.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.logging.FileHandler; 034import java.util.logging.Level; 035import java.util.logging.Logger; 036import java.util.logging.SimpleFormatter; 037import java.util.stream.Collectors; 038import java.util.stream.Stream; 039import java.util.Comparator; 040 041import com.scivicslab.actoriac.NodeGroup; 042import com.scivicslab.actoriac.NodeGroupInterpreter; 043import com.scivicslab.actoriac.NodeGroupIIAR; 044import com.scivicslab.actoriac.log.DistributedLogStore; 045import com.scivicslab.actoriac.log.H2LogStore; 046import com.scivicslab.actoriac.log.LogLevel; 047import com.scivicslab.actoriac.log.SessionStatus; 048import com.scivicslab.pojoactor.core.ActionResult; 049import com.scivicslab.pojoactor.workflow.IIActorSystem; 050import com.scivicslab.pojoactor.workflow.kustomize.WorkflowKustomizer; 051 052import picocli.CommandLine; 053import picocli.CommandLine.Command; 054import picocli.CommandLine.Option; 055 056/** 057 * CLI subcommand to execute actor-IaC workflows. 058 * 059 * <p>This is the main workflow execution command. It supports executing workflows 060 * defined in YAML, JSON, or XML format with optional overlay configuration.</p> 061 * 062 * <h2>Usage</h2> 063 * <pre> 064 * actor-iac run -d /path/to/workflows -w main-workflow.yaml 065 * actor-iac run --dir ./workflows --workflow deploy --threads 8 066 * actor-iac run -d ./workflows -w deploy -i inventory.ini -g webservers 067 * </pre> 068 * 069 * @author devteam@scivics-lab.com 070 * @since 2.10.0 071 */ 072@Command( 073 name = "run", 074 mixinStandardHelpOptions = true, 075 version = "actor-IaC run 2.11.0", 076 description = "Execute actor-IaC workflows defined in YAML, JSON, or XML format." 077) 078public class RunCLI implements Callable<Integer> { 079 080 private static final Logger LOG = Logger.getLogger(RunCLI.class.getName()); 081 082 @Option( 083 names = {"-d", "--dir"}, 084 required = true, 085 description = "Directory containing workflow files (searched recursively)" 086 ) 087 private File workflowDir; 088 089 @Option( 090 names = {"-w", "--workflow"}, 091 description = "Name of the main workflow to execute (with or without extension)" 092 ) 093 private String workflowName; 094 095 @Option( 096 names = {"-i", "--inventory"}, 097 description = "Path to Ansible inventory file (enables node-based execution)" 098 ) 099 private File inventoryFile; 100 101 @Option( 102 names = {"-g", "--group"}, 103 description = "Name of the host group to target (requires --inventory)", 104 defaultValue = "all" 105 ) 106 private String groupName; 107 108 @Option( 109 names = {"-t", "--threads"}, 110 description = "Number of worker threads for CPU-bound operations (default: ${DEFAULT-VALUE})", 111 defaultValue = "4" 112 ) 113 private int threads; 114 115 @Option( 116 names = {"-v", "--verbose"}, 117 description = "Enable verbose output" 118 ) 119 private boolean verbose; 120 121 @Option( 122 names = {"-o", "--overlay"}, 123 description = "Overlay directory containing overlay-conf.yaml for environment-specific configuration" 124 ) 125 private File overlayDir; 126 127 @Option( 128 names = {"-l", "--log"}, 129 description = "Log file path (default: actor-iac-YYYYMMDDHHmm.log in current directory)" 130 ) 131 private File logFile; 132 133 @Option( 134 names = {"--no-log"}, 135 description = "Disable file logging (output to console only)" 136 ) 137 private boolean noLog; 138 139 @Option( 140 names = {"--log-db"}, 141 description = "H2 database path for distributed logging (default: actor-iac-logs in workflow directory)" 142 ) 143 private File logDbPath; 144 145 @Option( 146 names = {"--no-log-db"}, 147 description = "Disable H2 database logging" 148 ) 149 private boolean noLogDb; 150 151 @Option( 152 names = {"--log-serve"}, 153 description = "H2 log server address (host:port, e.g., localhost:29090). " + 154 "Enables multiple workflows to share a single log database. " + 155 "Falls back to embedded mode if server is unreachable." 156 ) 157 private String logServer; 158 159 @Option( 160 names = {"--embedded"}, 161 description = "Use embedded H2 database mode. " + 162 "Default behavior is to auto-start a TCP server with auto-shutdown." 163 ) 164 private boolean embedded; 165 166 @Option( 167 names = {"-k", "--ask-pass"}, 168 description = "Prompt for SSH password (uses password authentication instead of ssh-agent)" 169 ) 170 private boolean askPass; 171 172 @Option( 173 names = {"--limit"}, 174 description = "Limit execution to specific hosts (comma-separated, e.g., '192.168.5.15' or '192.168.5.15,192.168.5.16')" 175 ) 176 private String limitHosts; 177 178 @Option( 179 names = {"-L", "--list-workflows"}, 180 description = "List workflows discovered under --dir and exit" 181 ) 182 private boolean listWorkflows; 183 184 @Option( 185 names = {"-q", "--quiet"}, 186 description = "Suppress all console output (stdout/stderr). Logs are still written to database." 187 ) 188 private boolean quiet; 189 190 @Option( 191 names = {"--render-to"}, 192 description = "Render overlay-applied workflows to specified directory (does not execute)" 193 ) 194 private File renderToDir; 195 196 /** Cache of discovered workflow files: name -> File */ 197 private final Map<String, File> workflowCache = new HashMap<>(); 198 199 /** File handler for logging */ 200 private FileHandler fileHandler; 201 202 /** Distributed log store (H2 database) */ 203 private DistributedLogStore logStore; 204 205 /** Current session ID for distributed logging */ 206 private long sessionId = -1; 207 208 /** 209 * Executes the workflow. 210 * 211 * @return exit code (0 for success, non-zero for failure) 212 */ 213 @Override 214 public Integer call() { 215 // Validate required options 216 // --render-to only requires --dir and --overlay, not --workflow 217 if (renderToDir != null) { 218 if (overlayDir == null) { 219 if (!quiet) { 220 System.err.println("--render-to requires '--overlay=<overlayDir>'"); 221 CommandLine.usage(this, System.err); 222 } 223 return 2; 224 } 225 } else if (workflowName == null && !listWorkflows) { 226 if (!quiet) { 227 System.err.println("Missing required option: '--workflow=<workflowName>'"); 228 System.err.println("Use --list-workflows to see available workflows."); 229 CommandLine.usage(this, System.err); 230 } 231 return 2; 232 } 233 234 // In quiet mode, suppress all console output 235 if (quiet) { 236 suppressConsoleOutput(); 237 } 238 239 // Setup file logging 240 if (!noLog) { 241 try { 242 setupFileLogging(); 243 } catch (IOException e) { 244 if (!quiet) { 245 System.err.println("Warning: Failed to setup file logging: " + e.getMessage()); 246 } 247 } 248 } 249 250 // Configure log level based on verbose flag 251 configureLogLevel(verbose); 252 253 // Setup H2 log database (enabled by default, use --no-log-db to disable) 254 if (!noLogDb) { 255 if (logDbPath == null) { 256 // Default: actor-iac-logs in workflow directory 257 logDbPath = new File(workflowDir, "actor-iac-logs"); 258 } 259 try { 260 setupLogDatabase(); 261 } catch (SQLException e) { 262 if (!quiet) { 263 System.err.println("Warning: Failed to setup log database: " + e.getMessage()); 264 } 265 } 266 } 267 268 try { 269 return executeMain(); 270 } finally { 271 // Clean up resources 272 if (fileHandler != null) { 273 fileHandler.close(); 274 } 275 if (logStore != null) { 276 try { 277 logStore.close(); 278 } catch (Exception e) { 279 System.err.println("Warning: Failed to close log database: " + e.getMessage()); 280 } 281 } 282 } 283 } 284 285 /** 286 * Suppresses all console output (stdout/stderr) for quiet mode. 287 */ 288 private void suppressConsoleOutput() { 289 // Remove ConsoleHandler from root logger 290 Logger rootLogger = Logger.getLogger(""); 291 for (java.util.logging.Handler handler : rootLogger.getHandlers()) { 292 if (handler instanceof java.util.logging.ConsoleHandler) { 293 rootLogger.removeHandler(handler); 294 } 295 } 296 297 // Redirect System.out and System.err to null 298 java.io.PrintStream nullStream = new java.io.PrintStream(java.io.OutputStream.nullOutputStream()); 299 System.setOut(nullStream); 300 System.setErr(nullStream); 301 } 302 303 /** 304 * Sets up H2 database for distributed logging. 305 * 306 * <p>Connection priority:</p> 307 * <ol> 308 * <li>If --embedded is specified, use embedded mode</li> 309 * <li>If --log-serve is specified, connect to that server</li> 310 * <li>Otherwise (default): auto-detect or start a background TCP server with auto-shutdown</li> 311 * </ol> 312 */ 313 private void setupLogDatabase() throws SQLException { 314 Path dbPath = logDbPath.toPath(); 315 316 // Priority 1: Explicit embedded mode 317 if (embedded) { 318 logStore = new H2LogStore(dbPath); 319 System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db (embedded mode)"); 320 return; 321 } 322 323 // Priority 2: Explicit log server specified 324 if (logServer != null && !logServer.isBlank()) { 325 logStore = H2LogStore.createWithFallback(logServer, dbPath); 326 System.out.println("Log database: " + logServer + " (TCP mode)"); 327 return; 328 } 329 330 // Priority 3 (default): Auto-detect existing server or start a new one 331 LogServerDiscovery discovery = new LogServerDiscovery(); 332 LogServerDiscovery.DiscoveryResult result = discovery.discoverServer(dbPath); 333 334 if (result.isFound()) { 335 // Found existing server, connect to it 336 logStore = H2LogStore.createWithFallback(result.getServerAddress(), dbPath); 337 System.out.println("Auto-detected log server at " + result.getServerAddress()); 338 System.out.println("Log database: " + result.getServerAddress() + " (TCP mode)"); 339 return; 340 } 341 342 // No existing server found - start a background server with auto-shutdown 343 int serverPort = startBackgroundLogServer(dbPath); 344 if (serverPort > 0) { 345 String serverAddress = "localhost:" + serverPort; 346 logStore = H2LogStore.createWithFallback(serverAddress, dbPath); 347 System.out.println("Started background log server on port " + serverPort); 348 System.out.println("Log database: " + serverAddress + " (TCP mode, auto-shutdown enabled)"); 349 } else { 350 // Failed to start server, fall back to embedded mode 351 System.out.println("Warning: Failed to start background log server, using embedded mode"); 352 logStore = new H2LogStore(dbPath); 353 System.out.println("Log database: " + logDbPath.getAbsolutePath() + ".mv.db (embedded mode)"); 354 } 355 } 356 357 /** Background log server process (for auto-shutdown) */ 358 private Process backgroundServerProcess; 359 360 /** Background log server port */ 361 private int backgroundServerPort = -1; 362 363 /** 364 * Starts a background H2 log server with auto-shutdown. 365 * 366 * <p>The server runs in a separate process and will automatically shut down 367 * when there are no connections for 5 minutes.</p> 368 * 369 * @param dbPath database path 370 * @return the TCP port of the started server, or -1 if failed 371 */ 372 private int startBackgroundLogServer(Path dbPath) { 373 // Find an available port in the reserved range 374 int port = findAvailablePort(); 375 if (port < 0) { 376 LOG.warning("No available ports in range 29090-29100"); 377 return -1; 378 } 379 380 try { 381 // Get the path to actor_iac.java 382 String actorIacPath = findActorIacScript(); 383 if (actorIacPath == null) { 384 LOG.warning("Could not find actor_iac.java script"); 385 return -1; 386 } 387 388 // Build the command to start log server with auto-shutdown 389 ProcessBuilder pb = new ProcessBuilder( 390 actorIacPath, 391 "log-serve", 392 "--port", String.valueOf(port), 393 "--db", dbPath.toAbsolutePath().toString(), 394 "--auto-shutdown", 395 "--idle-timeout", "300", // 5 minutes 396 "--check-interval", "60" // Check every 1 minute 397 ); 398 399 // Redirect output to null (server runs silently in background) 400 pb.redirectOutput(ProcessBuilder.Redirect.DISCARD); 401 pb.redirectError(ProcessBuilder.Redirect.DISCARD); 402 403 // Start the server process 404 backgroundServerProcess = pb.start(); 405 backgroundServerPort = port; 406 407 // Wait briefly for server to start 408 Thread.sleep(1000); 409 410 // Verify server is running 411 if (!backgroundServerProcess.isAlive()) { 412 LOG.warning("Background log server process exited unexpectedly"); 413 return -1; 414 } 415 416 // Verify we can connect 417 LogServerDiscovery.DiscoveryResult verify = new LogServerDiscovery().discoverServer(dbPath); 418 if (!verify.isFound()) { 419 LOG.warning("Background log server started but not responding"); 420 backgroundServerProcess.destroy(); 421 return -1; 422 } 423 424 if (verbose) { 425 LOG.info("Background log server started on port " + port + " (PID: " + backgroundServerProcess.pid() + ")"); 426 } 427 428 return port; 429 430 } catch (Exception e) { 431 LOG.warning("Failed to start background log server: " + e.getMessage()); 432 return -1; 433 } 434 } 435 436 /** 437 * Finds an available port in the actor-IaC reserved range (29090-29100). 438 */ 439 private int findAvailablePort() { 440 int[] ports = {29090, 29091, 29092, 29093, 29094, 29095, 29096, 29097, 29098, 29099, 29100}; 441 for (int port : ports) { 442 try (java.net.Socket socket = new java.net.Socket("localhost", port)) { 443 // Port is in use 444 } catch (Exception e) { 445 // Port is available 446 return port; 447 } 448 } 449 return -1; 450 } 451 452 /** 453 * Finds the path to the actor_iac.java script. 454 */ 455 private String findActorIacScript() { 456 // Try common locations 457 String[] candidates = { 458 "./actor_iac.java", 459 "../actor_iac.java", 460 System.getProperty("user.dir") + "/actor_iac.java", 461 workflowDir.getParent() + "/actor_iac.java" 462 }; 463 464 for (String path : candidates) { 465 java.io.File file = new java.io.File(path); 466 if (file.exists() && file.canExecute()) { 467 return file.getAbsolutePath(); 468 } 469 } 470 471 // Try to find via PATH 472 try { 473 ProcessBuilder pb = new ProcessBuilder("which", "actor_iac.java"); 474 Process p = pb.start(); 475 try (java.io.BufferedReader reader = new java.io.BufferedReader( 476 new java.io.InputStreamReader(p.getInputStream()))) { 477 String line = reader.readLine(); 478 if (line != null && !line.isBlank()) { 479 return line.trim(); 480 } 481 } 482 } catch (Exception e) { 483 // Ignore 484 } 485 486 return null; 487 } 488 489 /** 490 * Sets up file logging with default or specified log file. 491 */ 492 private void setupFileLogging() throws IOException { 493 String logFilePath; 494 if (logFile != null) { 495 logFilePath = logFile.getAbsolutePath(); 496 } else { 497 // Generate default log file name: actor-iac-YYYYMMDDHHmm.log 498 String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); 499 logFilePath = "actor-iac-" + timestamp + ".log"; 500 } 501 502 fileHandler = new FileHandler(logFilePath, false); 503 fileHandler.setFormatter(new SimpleFormatter()); 504 505 // Add handler to root logger to capture all logs 506 Logger rootLogger = Logger.getLogger(""); 507 rootLogger.addHandler(fileHandler); 508 509 System.out.println("Logging to: " + logFilePath); 510 } 511 512 /** 513 * Configures log level based on verbose flag. 514 */ 515 private void configureLogLevel(boolean verbose) { 516 Level targetLevel = verbose ? Level.FINE : Level.INFO; 517 518 // Configure root logger level 519 Logger rootLogger = Logger.getLogger(""); 520 rootLogger.setLevel(targetLevel); 521 522 // Configure handlers to respect the new level 523 for (java.util.logging.Handler handler : rootLogger.getHandlers()) { 524 handler.setLevel(targetLevel); 525 } 526 527 // Configure POJO-actor Interpreter logger 528 Logger interpreterLogger = Logger.getLogger("com.scivicslab.pojoactor.workflow.Interpreter"); 529 interpreterLogger.setLevel(targetLevel); 530 531 if (verbose) { 532 LOG.info("Verbose mode enabled - log level set to FINE"); 533 } 534 } 535 536 /** 537 * Main execution logic. 538 */ 539 private Integer executeMain() { 540 // Validate workflow directory 541 if (!workflowDir.exists()) { 542 LOG.severe("Workflow directory does not exist: " + workflowDir); 543 return 1; 544 } 545 546 if (!workflowDir.isDirectory()) { 547 LOG.severe("Not a directory: " + workflowDir); 548 return 1; 549 } 550 551 // Scan workflow directory recursively 552 LOG.info("Scanning workflow directory: " + workflowDir.getAbsolutePath()); 553 scanWorkflowDirectory(workflowDir.toPath()); 554 555 if (verbose) { 556 LOG.info("Discovered " + workflowCache.size() + " workflow files:"); 557 workflowCache.forEach((name, file) -> 558 LOG.info(" " + name + " -> " + file.getPath())); 559 } 560 561 if (listWorkflows) { 562 printWorkflowList(); 563 return 0; 564 } 565 566 // Handle --render-to option 567 if (renderToDir != null) { 568 return renderOverlayWorkflows(); 569 } 570 571 if (workflowName == null || workflowName.isBlank()) { 572 LOG.severe("Workflow name is required unless --list-workflows is specified."); 573 return 1; 574 } 575 576 // Find main workflow 577 File mainWorkflowFile = findWorkflowFile(workflowName); 578 if (mainWorkflowFile == null) { 579 LOG.severe("Workflow not found: " + workflowName); 580 LOG.info("Available workflows: " + workflowCache.keySet()); 581 return 1; 582 } 583 584 // Validate overlay directory if specified 585 if (overlayDir != null) { 586 if (!overlayDir.exists()) { 587 LOG.severe("Overlay directory does not exist: " + overlayDir); 588 return 1; 589 } 590 if (!overlayDir.isDirectory()) { 591 LOG.severe("Overlay path is not a directory: " + overlayDir); 592 return 1; 593 } 594 } 595 596 LOG.info("=== actor-IaC Workflow CLI ==="); 597 LOG.info("Workflow directory: " + workflowDir.getAbsolutePath()); 598 LOG.info("Main workflow: " + mainWorkflowFile.getName()); 599 if (overlayDir != null) { 600 LOG.info("Overlay: " + overlayDir.getAbsolutePath()); 601 } 602 LOG.info("Worker threads: " + threads); 603 604 // Execute workflow (use local inventory if none specified) 605 return executeWorkflow(mainWorkflowFile); 606 } 607 608 /** 609 * Renders overlay-applied workflows to the specified directory. 610 */ 611 private Integer renderOverlayWorkflows() { 612 if (overlayDir == null) { 613 LOG.severe("--render-to requires --overlay to be specified"); 614 return 1; 615 } 616 617 try { 618 // Create output directory if it doesn't exist 619 if (!renderToDir.exists()) { 620 if (!renderToDir.mkdirs()) { 621 LOG.severe("Failed to create output directory: " + renderToDir); 622 return 1; 623 } 624 } 625 626 LOG.info("=== Rendering Overlay-Applied Workflows ==="); 627 LOG.info("Overlay directory: " + overlayDir.getAbsolutePath()); 628 LOG.info("Output directory: " + renderToDir.getAbsolutePath()); 629 630 WorkflowKustomizer kustomizer = new WorkflowKustomizer(); 631 Map<String, Map<String, Object>> workflows = kustomizer.build(overlayDir.toPath()); 632 633 org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml(); 634 635 for (Map.Entry<String, Map<String, Object>> entry : workflows.entrySet()) { 636 String fileName = entry.getKey(); 637 Map<String, Object> workflowContent = entry.getValue(); 638 639 Path outputPath = renderToDir.toPath().resolve(fileName); 640 641 // Add header comment 642 StringBuilder sb = new StringBuilder(); 643 sb.append("# Rendered from overlay: ").append(overlayDir.getName()).append("\n"); 644 sb.append("# Source: ").append(fileName).append("\n"); 645 sb.append("# Generated: ").append(LocalDateTime.now()).append("\n"); 646 sb.append("#\n"); 647 sb.append(yaml.dump(workflowContent)); 648 649 Files.writeString(outputPath, sb.toString()); 650 LOG.info(" Written: " + outputPath); 651 } 652 653 LOG.info("Rendered " + workflows.size() + " workflow(s) to " + renderToDir.getAbsolutePath()); 654 return 0; 655 656 } catch (IOException e) { 657 LOG.severe("Failed to render workflows: " + e.getMessage()); 658 return 1; 659 } catch (Exception e) { 660 LOG.severe("Error during rendering: " + e.getMessage()); 661 e.printStackTrace(); 662 return 1; 663 } 664 } 665 666 /** 667 * Executes workflow with node-based execution. 668 */ 669 private Integer executeWorkflow(File mainWorkflowFile) { 670 NodeGroup nodeGroup; 671 672 // Prompt for password if --ask-pass is specified 673 String sshPassword = null; 674 if (askPass) { 675 sshPassword = promptForPassword("SSH password: "); 676 if (sshPassword == null) { 677 LOG.severe("Password input cancelled"); 678 return 1; 679 } 680 } 681 682 IIActorSystem system = new IIActorSystem("actor-iac-cli", threads); 683 684 // Start log session if log database is configured 685 if (logStore != null) { 686 String overlayName = overlayDir != null ? overlayDir.getName() : null; 687 String inventoryName = inventoryFile != null ? inventoryFile.getName() : null; 688 sessionId = logStore.startSession(workflowName, overlayName, inventoryName, 1); 689 logStore.log(sessionId, "cli", LogLevel.INFO, "Starting workflow: " + workflowName); 690 } 691 692 try { 693 if (inventoryFile != null) { 694 // Use specified inventory file 695 if (!inventoryFile.exists()) { 696 LOG.severe("Inventory file does not exist: " + inventoryFile); 697 logToDb("cli", LogLevel.ERROR, "Inventory file does not exist: " + inventoryFile); 698 return 1; 699 } 700 LOG.info("Inventory: " + inventoryFile.getAbsolutePath()); 701 nodeGroup = new NodeGroup.Builder() 702 .withInventory(new FileInputStream(inventoryFile)) 703 .build(); 704 } else { 705 // Use empty NodeGroup for local execution 706 LOG.info("Inventory: (none - using localhost)"); 707 nodeGroup = new NodeGroup(); 708 } 709 710 // Set SSH password if provided 711 if (sshPassword != null) { 712 nodeGroup.setSshPassword(sshPassword); 713 LOG.info("Authentication: password"); 714 } else { 715 LOG.info("Authentication: ssh-agent"); 716 } 717 718 // Set host limit if provided 719 if (limitHosts != null) { 720 nodeGroup.setHostLimit(limitHosts); 721 LOG.info("Host limit: " + limitHosts); 722 } 723 724 // Step 1: Create NodeGroupInterpreter 725 NodeGroupInterpreter nodeGroupInterpreter = new NodeGroupInterpreter(nodeGroup, system); 726 nodeGroupInterpreter.setWorkflowBaseDir(workflowDir.getAbsolutePath()); 727 if (overlayDir != null) { 728 nodeGroupInterpreter.setOverlayDir(overlayDir.getAbsolutePath()); 729 } 730 if (verbose) { 731 nodeGroupInterpreter.setVerbose(true); 732 } 733 734 // Inject log store into interpreter for node-level logging 735 if (logStore != null) { 736 nodeGroupInterpreter.setLogStore(logStore, sessionId); 737 } 738 739 // Step 2: Create NodeGroupIIAR and register with system 740 NodeGroupIIAR nodeGroupActor = new NodeGroupIIAR("nodeGroup", nodeGroupInterpreter, system); 741 system.addIIActor(nodeGroupActor); 742 743 // Step 3: Load the main workflow (with overlay if specified) 744 ActionResult loadResult = loadMainWorkflow(nodeGroupActor, mainWorkflowFile, overlayDir); 745 if (!loadResult.isSuccess()) { 746 LOG.severe("Failed to load workflow: " + loadResult.getResult()); 747 logToDb("cli", LogLevel.ERROR, "Failed to load workflow: " + loadResult.getResult()); 748 endSession(SessionStatus.FAILED); 749 return 1; 750 } 751 752 // Step 4: Execute the workflow 753 LOG.info("Starting workflow execution..."); 754 LOG.info("-".repeat(50)); 755 logToDb("cli", LogLevel.INFO, "Starting workflow execution"); 756 757 long startTime = System.currentTimeMillis(); 758 ActionResult result = nodeGroupActor.callByActionName("runUntilEnd", "[10000]"); 759 long duration = System.currentTimeMillis() - startTime; 760 761 LOG.info("-".repeat(50)); 762 if (result.isSuccess()) { 763 LOG.info("Workflow completed successfully: " + result.getResult()); 764 logToDb("cli", LogLevel.INFO, "Workflow completed successfully in " + duration + "ms"); 765 endSession(SessionStatus.COMPLETED); 766 return 0; 767 } else { 768 LOG.severe("Workflow failed: " + result.getResult()); 769 logToDb("cli", LogLevel.ERROR, "Workflow failed: " + result.getResult()); 770 endSession(SessionStatus.FAILED); 771 return 1; 772 } 773 774 } catch (Exception e) { 775 LOG.log(Level.SEVERE, "Workflow execution failed", e); 776 logToDb("cli", LogLevel.ERROR, "Exception: " + e.getMessage()); 777 endSession(SessionStatus.FAILED); 778 return 1; 779 } finally { 780 system.terminate(); 781 } 782 } 783 784 /** 785 * Logs a message to the distributed log store if available. 786 */ 787 private void logToDb(String nodeId, LogLevel level, String message) { 788 if (logStore != null && sessionId >= 0) { 789 logStore.log(sessionId, nodeId, level, message); 790 } 791 } 792 793 /** 794 * Ends the current session with the given status. 795 */ 796 private void endSession(SessionStatus status) { 797 if (logStore != null && sessionId >= 0) { 798 logStore.endSession(sessionId, status); 799 } 800 } 801 802 /** 803 * Prompts for a password from the console. 804 */ 805 private String promptForPassword(String prompt) { 806 java.io.Console console = System.console(); 807 if (console != null) { 808 // Secure input - password not echoed 809 char[] passwordChars = console.readPassword(prompt); 810 if (passwordChars != null) { 811 return new String(passwordChars); 812 } 813 return null; 814 } else { 815 // Fallback for environments without console (e.g., IDE) 816 System.out.print(prompt); 817 try (java.util.Scanner scanner = new java.util.Scanner(System.in)) { 818 if (scanner.hasNextLine()) { 819 return scanner.nextLine(); 820 } 821 } 822 return null; 823 } 824 } 825 826 /** 827 * Scans the directory recursively for workflow files. 828 */ 829 private void scanWorkflowDirectory(Path dir) { 830 try (Stream<Path> paths = Files.walk(dir)) { 831 paths.filter(Files::isRegularFile) 832 .filter(this::isWorkflowFile) 833 .forEach(this::registerWorkflowFile); 834 } catch (IOException e) { 835 LOG.warning("Error scanning directory: " + e.getMessage()); 836 } 837 } 838 839 /** 840 * Checks if a file is a workflow file (YAML, JSON, or XML). 841 */ 842 private boolean isWorkflowFile(Path path) { 843 String name = path.getFileName().toString().toLowerCase(); 844 return name.endsWith(".yaml") || name.endsWith(".yml") || 845 name.endsWith(".json") || name.endsWith(".xml"); 846 } 847 848 /** 849 * Registers a workflow file in the cache. 850 */ 851 private void registerWorkflowFile(Path path) { 852 File file = path.toFile(); 853 String fileName = file.getName(); 854 855 // Register with full filename 856 workflowCache.put(fileName, file); 857 858 // Register without extension 859 int dotIndex = fileName.lastIndexOf('.'); 860 if (dotIndex > 0) { 861 String baseName = fileName.substring(0, dotIndex); 862 // Only register base name if not already taken by another file 863 workflowCache.putIfAbsent(baseName, file); 864 } 865 } 866 867 /** 868 * Finds a workflow file by name. 869 */ 870 public File findWorkflowFile(String name) { 871 // Try exact match first 872 File file = workflowCache.get(name); 873 if (file != null) { 874 return file; 875 } 876 877 // Try with common extensions 878 String[] extensions = {".yaml", ".yml", ".json", ".xml"}; 879 for (String ext : extensions) { 880 file = workflowCache.get(name + ext); 881 if (file != null) { 882 return file; 883 } 884 } 885 886 return null; 887 } 888 889 /** 890 * Loads the main workflow file with optional overlay support. 891 */ 892 private ActionResult loadMainWorkflow(NodeGroupIIAR nodeGroupActor, File workflowFile, File overlayDir) { 893 LOG.info("Loading workflow: " + workflowFile.getAbsolutePath()); 894 logToDb("cli", LogLevel.INFO, "Loading workflow: " + workflowFile.getName()); 895 896 String loadArg; 897 if (overlayDir != null) { 898 // Pass both workflow path and overlay directory 899 loadArg = "[\"" + workflowFile.getAbsolutePath() + "\", \"" + overlayDir.getAbsolutePath() + "\"]"; 900 LOG.fine("Loading with overlay: " + overlayDir.getAbsolutePath()); 901 } else { 902 // Load without overlay 903 loadArg = "[\"" + workflowFile.getAbsolutePath() + "\"]"; 904 } 905 906 return nodeGroupActor.callByActionName("readYaml", loadArg); 907 } 908 909 private void printWorkflowList() { 910 List<WorkflowDisplay> displays = scanWorkflowsForDisplay(workflowDir); 911 912 if (displays.isEmpty()) { 913 System.out.println("No workflow files found under " 914 + workflowDir.getAbsolutePath()); 915 return; 916 } 917 918 System.out.println("Available workflows (directory: " 919 + workflowDir.getAbsolutePath() + ")"); 920 System.out.println("-".repeat(90)); 921 System.out.printf("%-4s %-25s %-35s %s%n", "#", "File (-w)", "Path", "Workflow Name (in logs)"); 922 System.out.println("-".repeat(90)); 923 int index = 1; 924 for (WorkflowDisplay display : displays) { 925 String wfName = display.workflowName() != null ? display.workflowName() : "(no name)"; 926 System.out.printf("%2d. %-25s %-35s %s%n", 927 index++, display.baseName(), display.relativePath(), wfName); 928 } 929 System.out.println("-".repeat(90)); 930 System.out.println("Use -w <File> with the names shown in the 'File (-w)' column."); 931 } 932 933 private static String getBaseName(String fileName) { 934 int dotIndex = fileName.lastIndexOf('.'); 935 if (dotIndex > 0) { 936 return fileName.substring(0, dotIndex); 937 } 938 return fileName; 939 } 940 941 private static String relativize(Path base, Path target) { 942 try { 943 return base.relativize(target).toString(); 944 } catch (IllegalArgumentException e) { 945 return target.toAbsolutePath().toString(); 946 } 947 } 948 949 private static List<WorkflowDisplay> scanWorkflowsForDisplay(File directory) { 950 if (directory == null) { 951 return List.of(); 952 } 953 954 try (Stream<Path> paths = Files.walk(directory.toPath())) { 955 Map<String, File> uniqueFiles = new LinkedHashMap<>(); 956 paths.filter(Files::isRegularFile) 957 .filter(path -> { 958 String name = path.getFileName().toString().toLowerCase(); 959 return name.endsWith(".yaml") || name.endsWith(".yml") 960 || name.endsWith(".json") || name.endsWith(".xml"); 961 }) 962 .forEach(path -> uniqueFiles.putIfAbsent(path.toFile().getAbsolutePath(), path.toFile())); 963 964 Path basePath = directory.toPath(); 965 return uniqueFiles.values().stream() 966 .map(file -> new WorkflowDisplay( 967 getBaseName(file.getName()), 968 relativize(basePath, file.toPath()), 969 extractWorkflowName(file))) 970 .sorted(Comparator.comparing(WorkflowDisplay::baseName, String.CASE_INSENSITIVE_ORDER)) 971 .collect(Collectors.toList()); 972 } catch (IOException e) { 973 System.err.println("Failed to scan workflows: " + e.getMessage()); 974 return List.of(); 975 } 976 } 977 978 /** 979 * Extracts the workflow name from a YAML/JSON/XML file. 980 */ 981 private static String extractWorkflowName(File file) { 982 String fileName = file.getName().toLowerCase(); 983 if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) { 984 return extractNameFromYaml(file); 985 } else if (fileName.endsWith(".json")) { 986 return extractNameFromJson(file); 987 } 988 return null; 989 } 990 991 /** 992 * Extracts name field from YAML file using simple line parsing. 993 */ 994 private static String extractNameFromYaml(File file) { 995 try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) { 996 String line; 997 while ((line = reader.readLine()) != null) { 998 line = line.trim(); 999 // Look for "name: value" at the top level (not indented) 1000 if (line.startsWith("name:")) { 1001 String value = line.substring(5).trim(); 1002 // Remove quotes if present 1003 if ((value.startsWith("\"") && value.endsWith("\"")) || 1004 (value.startsWith("'") && value.endsWith("'"))) { 1005 value = value.substring(1, value.length() - 1); 1006 } 1007 return value.isEmpty() ? null : value; 1008 } 1009 // Stop if we hit a steps: or other major section 1010 if (line.startsWith("steps:") || line.startsWith("vertices:")) { 1011 break; 1012 } 1013 } 1014 } catch (IOException e) { 1015 // Ignore and return null 1016 } 1017 return null; 1018 } 1019 1020 /** 1021 * Extracts name field from JSON file. 1022 */ 1023 private static String extractNameFromJson(File file) { 1024 try (java.io.FileReader reader = new java.io.FileReader(file)) { 1025 org.json.JSONTokener tokener = new org.json.JSONTokener(reader); 1026 org.json.JSONObject json = new org.json.JSONObject(tokener); 1027 return json.optString("name", null); 1028 } catch (Exception e) { 1029 // Ignore and return null 1030 } 1031 return null; 1032 } 1033 1034 /** 1035 * Record for displaying workflow information. 1036 */ 1037 private static record WorkflowDisplay(String baseName, String relativePath, String workflowName) {} 1038}