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; 019 020import java.io.BufferedReader; 021import java.io.IOException; 022import java.io.InputStreamReader; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.util.HashSet; 026import java.util.Set; 027import java.util.logging.Level; 028import java.util.logging.Logger; 029 030import com.github.ricksbrown.cowsay.Cowsay; 031import com.scivicslab.pojoactor.core.ActionResult; 032import com.scivicslab.pojoactor.workflow.IIActorSystem; 033import com.scivicslab.pojoactor.workflow.Interpreter; 034import com.scivicslab.pojoactor.workflow.Vertex; 035 036/** 037 * Level 3 wrapper that adds workflow capabilities to a Node POJO. 038 * 039 * <p>This class extends {@link Interpreter} to provide workflow execution 040 * capabilities while delegating SSH operations to a wrapped {@link Node} instance.</p> 041 * 042 * <p>This demonstrates the three-level architecture of actor-IaC:</p> 043 * <ul> 044 * <li><strong>Level 1 (POJO):</strong> {@link Node} - pure POJO with SSH functionality</li> 045 * <li><strong>Level 2 (Actor):</strong> ActorRef<Node> - actor wrapper for concurrent execution</li> 046 * <li><strong>Level 3 (Workflow):</strong> NodeInterpreter - workflow capabilities + IIActorRef wrapper</li> 047 * </ul> 048 * 049 * <p><strong>Design principle:</strong> Node remains a pure POJO, independent of ActorSystem. 050 * NodeInterpreter wraps Node to add workflow capabilities without modifying the Node class.</p> 051 * 052 * @author devteam@scivics-lab.com 053 */ 054public class NodeInterpreter extends Interpreter { 055 056 private static final Logger logger = Logger.getLogger(NodeInterpreter.class.getName()); 057 058 /** 059 * The wrapped Node POJO that handles actual SSH operations. 060 */ 061 private final Node node; 062 063 /** 064 * The overlay directory path for YAML overlay feature. 065 */ 066 private String overlayDir; 067 068 /** 069 * The current vertex YAML snippet (first 10 lines) for accumulator reporting. 070 */ 071 private String currentVertexYaml = ""; 072 073 /** 074 * Set of changed document names detected by workflow. 075 * This replaces the /tmp/changed_docs.txt file-based approach. 076 */ 077 private final Set<String> changedDocuments = new HashSet<>(); 078 079 /** 080 * Constructs a NodeInterpreter that wraps the specified Node. 081 * 082 * @param node the {@link Node} instance to wrap 083 * @param system the actor system for workflow execution 084 */ 085 public NodeInterpreter(Node node, IIActorSystem system) { 086 super(); 087 this.node = node; 088 this.system = system; 089 // Initialize parent's logger (used by Interpreter.runWorkflow error handling) 090 super.logger = logger; 091 } 092 093 /** 094 * Executes a command on the remote node via SSH. 095 * 096 * <p>Delegates to the wrapped {@link Node#executeCommand(String)} method.</p> 097 * 098 * @param command the command to execute 099 * @return the result of the command execution 100 * @throws IOException if SSH connection fails 101 */ 102 public Node.CommandResult executeCommand(String command) throws IOException { 103 return node.executeCommand(command); 104 } 105 106 /** 107 * Executes a command with sudo privileges on the remote node. 108 * 109 * <p>Delegates to the wrapped {@link Node#executeSudoCommand(String)} method. 110 * Requires SUDO_PASSWORD environment variable to be set.</p> 111 * 112 * @param command the command to execute with sudo 113 * @return the result of the command execution 114 * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set 115 */ 116 public Node.CommandResult executeSudoCommand(String command) throws IOException { 117 return node.executeSudoCommand(command); 118 } 119 120 /** 121 * Gets the hostname of the node. 122 * 123 * @return the hostname 124 */ 125 public String getHostname() { 126 return node.getHostname(); 127 } 128 129 /** 130 * Gets the username for SSH connections. 131 * 132 * @return the username 133 */ 134 public String getUser() { 135 return node.getUser(); 136 } 137 138 /** 139 * Gets the SSH port. 140 * 141 * @return the SSH port number 142 */ 143 public int getPort() { 144 return node.getPort(); 145 } 146 147 /** 148 * Gets the wrapped Node instance. 149 * 150 * <p>This allows direct access to the underlying POJO when needed.</p> 151 * 152 * @return the wrapped Node 153 */ 154 public Node getNode() { 155 return node; 156 } 157 158 /** 159 * Sets the overlay directory for YAML overlay feature. 160 * 161 * @param overlayDir the path to the overlay directory containing overlay-conf.yaml 162 */ 163 public void setOverlayDir(String overlayDir) { 164 this.overlayDir = overlayDir; 165 } 166 167 /** 168 * Gets the overlay directory path. 169 * 170 * @return the overlay directory path, or null if not set 171 */ 172 public String getOverlayDir() { 173 return overlayDir; 174 } 175 176 /** 177 * Hook called when entering a vertex during workflow execution. 178 * 179 * <p>Displays the workflow name and first 10 lines of the vertex definition 180 * in YAML format using cowsay to provide visual separation between workflow steps.</p> 181 * 182 * @param vertex the vertex being entered 183 */ 184 @Override 185 protected void onEnterVertex(Vertex vertex) { 186 // Get workflow name 187 String workflowName = (getCode() != null && getCode().getName() != null) 188 ? getCode().getName() 189 : "unknown-workflow"; 190 191 // Get YAML-formatted output (first 10 lines) 192 String yamlText = vertex.toYamlString(10).trim(); 193 this.currentVertexYaml = yamlText; 194 195 // Combine workflow name and vertex YAML 196 String displayText = "[" + workflowName + "]\n" + yamlText; 197 String[] cowsayArgs = { displayText }; 198 System.out.println(Cowsay.say(cowsayArgs)); 199 } 200 201 /** 202 * Returns the current vertex YAML snippet for accumulator reporting. 203 * 204 * @return the first 10 lines of the current vertex in YAML format 205 */ 206 public String getCurrentVertexYaml() { 207 return currentVertexYaml; 208 } 209 210 /** 211 * Loads and runs a workflow file to completion with overlay support. 212 * 213 * <p>If overlayDir is set, the workflow is loaded with overlay applied. 214 * Variables defined in overlay-conf.yaml are substituted before execution.</p> 215 * 216 * @param workflowFile the workflow file path (YAML or JSON) 217 * @param maxIterations maximum number of state transitions allowed 218 * @return ActionResult with success=true if completed, false otherwise 219 */ 220 @Override 221 public ActionResult runWorkflow(String workflowFile, int maxIterations) { 222 // If no overlay is set, use parent implementation 223 if (overlayDir == null) { 224 return super.runWorkflow(workflowFile, maxIterations); 225 } 226 227 try { 228 // Reset state for fresh execution 229 reset(); 230 231 // Resolve workflow file path 232 Path workflowPath; 233 if (workflowBaseDir != null) { 234 workflowPath = Path.of(workflowBaseDir, workflowFile); 235 } else { 236 workflowPath = Path.of(workflowFile); 237 } 238 239 // Load workflow with overlay applied 240 Path overlayPath = Path.of(overlayDir); 241 readYaml(workflowPath, overlayPath); 242 243 // Run until end 244 return runUntilEnd(maxIterations); 245 246 } catch (Exception e) { 247 logger.log(Level.SEVERE, "Error running workflow with overlay: " + workflowFile, e); 248 return new ActionResult(false, "Error: " + e.getMessage()); 249 } 250 } 251 252 // ======================================================================== 253 // Document Change Detection API (replaces /tmp file-based approach) 254 // ======================================================================== 255 256 /** 257 * Detects changed documents and stores them in POJO state. 258 * 259 * <p>This method replaces the shell script that wrote to /tmp/changed_docs.txt. 260 * It reads the document list, checks git status for each, and stores changed 261 * document names in the changedDocuments set.</p> 262 * 263 * @param docListPath path to the document list file 264 * @return ActionResult with detection summary 265 * @throws IOException if file operations fail 266 */ 267 public ActionResult detectDocumentChanges(String docListPath) throws IOException { 268 // Clear previous results 269 changedDocuments.clear(); 270 271 // Expand ~ to home directory 272 String expandedPath = docListPath.replace("~", System.getProperty("user.home")); 273 Path listPath = Path.of(expandedPath); 274 275 if (!Files.exists(listPath)) { 276 return new ActionResult(false, "Document list not found: " + docListPath); 277 } 278 279 // Check for FORCE_FULL_BUILD environment variable 280 boolean forceBuild = "true".equalsIgnoreCase(System.getenv("FORCE_FULL_BUILD")); 281 282 StringBuilder summary = new StringBuilder(); 283 284 if (forceBuild) { 285 summary.append("=== FORCE_FULL_BUILD enabled: processing all documents ===\n"); 286 } else { 287 summary.append("=== Detecting changes via git fetch ===\n"); 288 } 289 290 try (BufferedReader reader = Files.newBufferedReader(listPath)) { 291 String line; 292 while ((line = reader.readLine()) != null) { 293 line = line.trim(); 294 295 // Skip comments and empty lines 296 if (line.isEmpty() || line.startsWith("#")) { 297 continue; 298 } 299 300 // Parse: path git_url 301 String[] parts = line.split("\\s+", 2); 302 String path = parts[0].replace("~", System.getProperty("user.home")); 303 String docName = Path.of(path).getFileName().toString(); 304 305 if (forceBuild) { 306 changedDocuments.add(docName); 307 summary.append(" [FORCE] ").append(docName).append("\n"); 308 continue; 309 } 310 311 // Check document status 312 Path docPath = Path.of(path); 313 Path gitPath = docPath.resolve(".git"); 314 315 if (!Files.exists(docPath)) { 316 // New document 317 changedDocuments.add(docName); 318 summary.append(" [NEW] ").append(docName).append("\n"); 319 } else if (!Files.exists(gitPath)) { 320 // Not a git repository 321 changedDocuments.add(docName); 322 summary.append(" [NO-GIT] ").append(docName).append("\n"); 323 } else { 324 // Check for remote changes using git 325 String status = checkGitStatus(docPath); 326 if (status.startsWith("[CHANGED]") || status.startsWith("[UNKNOWN]")) { 327 changedDocuments.add(docName); 328 } 329 summary.append(" ").append(status).append(" ").append(docName).append("\n"); 330 } 331 } 332 } 333 334 summary.append("\n=== Change detection summary ===\n"); 335 if (changedDocuments.isEmpty()) { 336 summary.append("No changes detected. All documents are up to date.\n"); 337 } else { 338 summary.append("Documents to process: ").append(changedDocuments.size()).append("\n"); 339 for (String doc : changedDocuments) { 340 summary.append(doc).append("\n"); 341 } 342 } 343 344 // Print summary (like the original shell script did) 345 System.out.println(summary); 346 347 return new ActionResult(true, "Detected " + changedDocuments.size() + " changed documents"); 348 } 349 350 /** 351 * Checks git status for a document directory. 352 * 353 * @param docPath path to the document directory 354 * @return status string like "[CHANGED]", "[UP-TO-DATE]", or "[UNKNOWN]" 355 */ 356 private String checkGitStatus(Path docPath) { 357 try { 358 // git fetch 359 ProcessBuilder fetchPb = new ProcessBuilder("git", "fetch", "origin"); 360 fetchPb.directory(docPath.toFile()); 361 fetchPb.redirectErrorStream(true); 362 Process fetchProcess = fetchPb.start(); 363 fetchProcess.waitFor(); 364 365 // Get local HEAD 366 String local = runGitCommand(docPath, "git", "rev-parse", "HEAD"); 367 368 // Get remote HEAD (try main first, then master) 369 String remote = runGitCommand(docPath, "git", "rev-parse", "origin/main"); 370 if (remote == null || remote.equals("unknown")) { 371 remote = runGitCommand(docPath, "git", "rev-parse", "origin/master"); 372 } 373 374 if (local == null || remote == null || local.equals("unknown") || remote.equals("unknown")) { 375 return "[UNKNOWN] (cannot determine state)"; 376 } 377 378 if (!local.equals(remote)) { 379 String localShort = local.length() > 7 ? local.substring(0, 7) : local; 380 String remoteShort = remote.length() > 7 ? remote.substring(0, 7) : remote; 381 return "[CHANGED] (local: " + localShort + ", remote: " + remoteShort + ")"; 382 } 383 384 return "[UP-TO-DATE]"; 385 386 } catch (Exception e) { 387 logger.log(Level.WARNING, "Error checking git status for " + docPath, e); 388 return "[UNKNOWN] (error: " + e.getMessage() + ")"; 389 } 390 } 391 392 /** 393 * Runs a git command and returns the output. 394 */ 395 private String runGitCommand(Path workDir, String... command) { 396 try { 397 ProcessBuilder pb = new ProcessBuilder(command); 398 pb.directory(workDir.toFile()); 399 pb.redirectErrorStream(true); 400 Process process = pb.start(); 401 402 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { 403 String line = reader.readLine(); 404 process.waitFor(); 405 return line != null ? line.trim() : "unknown"; 406 } 407 } catch (Exception e) { 408 return "unknown"; 409 } 410 } 411 412 /** 413 * Checks if a specific document is in the changed list. 414 * 415 * @param docName the document name to check 416 * @return true if the document was detected as changed 417 */ 418 public boolean isDocumentChanged(String docName) { 419 return changedDocuments.contains(docName); 420 } 421 422 /** 423 * Gets the number of changed documents. 424 * 425 * @return the count of changed documents 426 */ 427 public int getChangedDocumentsCount() { 428 return changedDocuments.size(); 429 } 430 431 /** 432 * Gets all changed document names. 433 * 434 * @return unmodifiable set of changed document names 435 */ 436 public Set<String> getChangedDocuments() { 437 return Set.copyOf(changedDocuments); 438 } 439 440 /** 441 * Checks if there are any changed documents to process. 442 * 443 * @return true if at least one document needs processing 444 */ 445 public boolean hasChangedDocuments() { 446 return !changedDocuments.isEmpty(); 447 } 448 449 /** 450 * Clears the changed documents list. 451 */ 452 public void clearChangedDocuments() { 453 changedDocuments.clear(); 454 } 455 456 /** 457 * Adds a document to the changed list (for testing or manual override). 458 * 459 * @param docName the document name to add 460 */ 461 public void addChangedDocument(String docName) { 462 changedDocuments.add(docName); 463 } 464 465 /** 466 * Clones changed documents from git. 467 * 468 * <p>Only clones documents that are in the changedDocuments set. 469 * Removes existing directory and does fresh clone to avoid conflicts.</p> 470 * 471 * @param docListPath path to the document list file 472 * @return ActionResult with clone summary 473 * @throws IOException if operations fail 474 */ 475 public ActionResult cloneChangedDocuments(String docListPath) throws IOException { 476 if (changedDocuments.isEmpty()) { 477 System.out.println("=== No documents to clone (all up to date) ==="); 478 return new ActionResult(true, "No documents to clone"); 479 } 480 481 String expandedPath = docListPath.replace("~", System.getProperty("user.home")); 482 Path listPath = Path.of(expandedPath); 483 484 // Ensure ~/works exists 485 node.executeCommand("mkdir -p ~/works"); 486 487 System.out.println("=== Cloning changed documents ==="); 488 int clonedCount = 0; 489 490 try (BufferedReader reader = Files.newBufferedReader(listPath)) { 491 String line; 492 while ((line = reader.readLine()) != null) { 493 line = line.trim(); 494 if (line.isEmpty() || line.startsWith("#")) continue; 495 496 String[] parts = line.split("\\s+", 2); 497 String path = parts[0]; 498 String gitUrl = parts.length > 1 ? parts[1] : null; 499 String docName = Path.of(path).getFileName().toString(); 500 501 if (!changedDocuments.contains(docName)) { 502 System.out.println(" [SKIP] " + docName + " (unchanged)"); 503 continue; 504 } 505 506 if (gitUrl != null && !gitUrl.isEmpty()) { 507 // Remove old and clone fresh 508 System.out.println("=== Cloning: " + gitUrl + " -> " + path + " ==="); 509 node.executeCommand("rm -rf " + path); 510 Node.CommandResult result = node.executeCommand("git clone " + gitUrl + " " + path); 511 if (result.getExitCode() == 0) { 512 clonedCount++; 513 } else { 514 System.err.println("Clone failed: " + result.getStderr()); 515 } 516 } else { 517 System.out.println("=== No git URL specified for: " + path + " ==="); 518 } 519 } 520 } 521 522 return new ActionResult(true, "Cloned " + clonedCount + " documents"); 523 } 524 525 /** 526 * Builds changed Docusaurus documents. 527 * 528 * <p>Only builds documents that are in the changedDocuments set.</p> 529 * 530 * @param docListPath path to the document list file 531 * @return ActionResult with build summary 532 * @throws IOException if operations fail 533 */ 534 public ActionResult buildChangedDocuments(String docListPath) throws IOException { 535 if (changedDocuments.isEmpty()) { 536 System.out.println("=== No documents to build (all up to date) ==="); 537 return new ActionResult(true, "No documents to build"); 538 } 539 540 String expandedPath = docListPath.replace("~", System.getProperty("user.home")); 541 Path listPath = Path.of(expandedPath); 542 543 System.out.println("=== Building changed documents ==="); 544 int builtCount = 0; 545 546 try (BufferedReader reader = Files.newBufferedReader(listPath)) { 547 String line; 548 while ((line = reader.readLine()) != null) { 549 line = line.trim(); 550 if (line.isEmpty() || line.startsWith("#")) continue; 551 552 String[] parts = line.split("\\s+", 2); 553 String path = parts[0]; 554 String docName = Path.of(path).getFileName().toString(); 555 556 if (!changedDocuments.contains(docName)) { 557 System.out.println(" [SKIP] " + docName + " (unchanged)"); 558 continue; 559 } 560 561 System.out.println("=== Building: " + path + " ==="); 562 Node.CommandResult result = node.executeCommand( 563 "cd " + path + " && yarn install && yarn build" 564 ); 565 if (result.getExitCode() == 0) { 566 builtCount++; 567 } else { 568 System.err.println("Build failed for " + docName + ": " + result.getStderr()); 569 } 570 } 571 } 572 573 return new ActionResult(true, "Built " + builtCount + " documents"); 574 } 575 576 /** 577 * Copies changed document builds to public_html. 578 * 579 * <p>Only copies documents that are in the changedDocuments set.</p> 580 * 581 * @param docListPath path to the document list file 582 * @return ActionResult with copy summary 583 * @throws IOException if operations fail 584 */ 585 public ActionResult deployChangedDocuments(String docListPath) throws IOException { 586 // Ensure public_html exists 587 node.executeCommand("mkdir -p ~/public_html"); 588 589 if (changedDocuments.isEmpty()) { 590 System.out.println("=== No documents to copy (all up to date) ==="); 591 node.executeCommand("ls -la ~/public_html/"); 592 return new ActionResult(true, "No documents to copy"); 593 } 594 595 String expandedPath = docListPath.replace("~", System.getProperty("user.home")); 596 Path listPath = Path.of(expandedPath); 597 598 System.out.println("=== Copying changed documents to public_html ==="); 599 int copiedCount = 0; 600 601 try (BufferedReader reader = Files.newBufferedReader(listPath)) { 602 String line; 603 while ((line = reader.readLine()) != null) { 604 line = line.trim(); 605 if (line.isEmpty() || line.startsWith("#")) continue; 606 607 String[] parts = line.split("\\s+", 2); 608 String path = parts[0]; 609 String docName = Path.of(path).getFileName().toString(); 610 611 if (!changedDocuments.contains(docName)) { 612 System.out.println(" [SKIP] " + docName + " (unchanged)"); 613 continue; 614 } 615 616 String buildPath = path + "/build"; 617 String destPath = "~/public_html/" + docName; 618 619 System.out.println("=== Copying " + docName + " to public_html ==="); 620 node.executeCommand("rm -rf " + destPath); 621 Node.CommandResult result = node.executeCommand( 622 "cp -r " + buildPath + " " + destPath 623 ); 624 if (result.getExitCode() == 0) { 625 copiedCount++; 626 } else { 627 System.err.println("Copy failed for " + docName + ": " + result.getStderr()); 628 } 629 } 630 } 631 632 System.out.println("=== public_html contents ==="); 633 node.executeCommand("ls -la ~/public_html/"); 634 635 return new ActionResult(true, "Deployed " + copiedCount + " documents"); 636 } 637}