001/* 002 * Copyright 2025 devteam@scivicslab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac; 019 020import java.io.BufferedReader; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.InputStreamReader; 024 025import com.jcraft.jsch.ChannelExec; 026import com.jcraft.jsch.JSch; 027import com.jcraft.jsch.JSchException; 028import com.jcraft.jsch.Session; 029 030/** 031 * Represents a single node in the infrastructure as a pure POJO. 032 * 033 * <p>This is a pure POJO class that provides SSH-based command execution 034 * capabilities. It has NO dependency on ActorSystem or workflow components.</p> 035 * 036 * <h2>Three Levels of Usage</h2> 037 * 038 * <h3>Level 1: Pure POJO (Synchronous)</h3> 039 * <pre>{@code 040 * Node node = new Node("192.168.1.1", "admin"); 041 * CommandResult result = node.executeCommand("show version"); 042 * System.out.println(result.getStdout()); 043 * }</pre> 044 * 045 * <h3>Level 2: Actor-based (Asynchronous, Parallel)</h3> 046 * <pre>{@code 047 * ActorSystem system = new ActorSystem("iac", 4); 048 * ActorRef<Node> nodeActor = system.actorOf("node1", node); 049 * CompletableFuture<CommandResult> future = nodeActor.ask(n -> n.executeCommand("show version")); 050 * }</pre> 051 * 052 * <h3>Level 3: Workflow-based (YAML/JSON/XML)</h3> 053 * <pre>{@code 054 * // Use NodeInterpreter instead for workflow capabilities 055 * NodeInterpreter nodeInterpreter = new NodeInterpreter(node, system); 056 * IIActorRef<NodeInterpreter> nodeActor = new IIActorRef<>("node1", nodeInterpreter, system); 057 * }</pre> 058 * 059 * <p>Uses ssh-agent for SSH key authentication. Make sure ssh-agent is running 060 * and your SSH key is added before using this class.</p> 061 * 062 * @author devteam@scivicslab.com 063 */ 064public class Node { 065 066 private final String hostname; 067 private final String user; 068 private final int port; 069 private final boolean localMode; 070 private final String password; 071 072 // Jump host session (kept open for the duration of the connection) 073 private Session jumpHostSession = null; 074 075 /** 076 * Constructs a Node with the specified connection parameters (POJO constructor). 077 * 078 * @param hostname the hostname or IP address of the node 079 * @param user the SSH username 080 * @param port the SSH port (typically 22) 081 */ 082 public Node(String hostname, String user, int port) { 083 this(hostname, user, port, false, null); 084 } 085 086 /** 087 * Constructs a Node with the specified connection parameters and local mode. 088 * 089 * @param hostname the hostname or IP address of the node 090 * @param user the SSH username 091 * @param port the SSH port (typically 22) 092 * @param localMode if true, execute commands locally instead of via SSH 093 */ 094 public Node(String hostname, String user, int port, boolean localMode) { 095 this(hostname, user, port, localMode, null); 096 } 097 098 /** 099 * Constructs a Node with all connection parameters including password. 100 * 101 * @param hostname the hostname or IP address of the node 102 * @param user the SSH username 103 * @param port the SSH port (typically 22) 104 * @param localMode if true, execute commands locally instead of via SSH 105 * @param password the SSH password (null to use ssh-agent key authentication) 106 */ 107 public Node(String hostname, String user, int port, boolean localMode, String password) { 108 this.hostname = hostname; 109 this.user = user; 110 this.port = port; 111 this.localMode = localMode; 112 this.password = password; 113 } 114 115 /** 116 * Constructs a Node with default port 22 (POJO constructor). 117 * 118 * @param hostname the hostname or IP address of the node 119 * @param user the SSH username 120 */ 121 public Node(String hostname, String user) { 122 this(hostname, user, 22, false, null); 123 } 124 125 /** 126 * Checks if this node is in local execution mode. 127 * 128 * @return true if commands are executed locally, false for SSH 129 */ 130 public boolean isLocalMode() { 131 return localMode; 132 } 133 134 /** 135 * Executes a command on the node. 136 * 137 * <p>If localMode is true, executes the command locally using ProcessBuilder. 138 * Otherwise, executes via SSH using JSch.</p> 139 * 140 * @param command the command to execute 141 * @return the execution result containing stdout, stderr, and exit code 142 * @throws IOException if command execution fails 143 */ 144 public CommandResult executeCommand(String command) throws IOException { 145 return executeCommand(command, null); 146 } 147 148 /** 149 * Executes a command on the node with real-time output callback. 150 * 151 * <p>If localMode is true, executes the command locally using ProcessBuilder. 152 * Otherwise, executes via SSH using JSch.</p> 153 * 154 * <p>The callback receives stdout and stderr lines as they are produced, 155 * enabling real-time forwarding to accumulators.</p> 156 * 157 * @param command the command to execute 158 * @param callback the callback for real-time output (may be null) 159 * @return the execution result containing stdout, stderr, and exit code 160 * @throws IOException if command execution fails 161 */ 162 public CommandResult executeCommand(String command, OutputCallback callback) throws IOException { 163 if (localMode) { 164 return executeLocalCommand(command, callback); 165 } 166 return executeRemoteCommand(command, callback); 167 } 168 169 /** 170 * Executes a command locally using ProcessBuilder with real-time streaming. 171 * 172 * <p>Output is streamed via callback (if provided) in real-time as it becomes available, 173 * while also being captured for the CommandResult.</p> 174 * 175 * @param command the command to execute 176 * @param callback the callback for real-time output (may be null) 177 * @return the execution result 178 * @throws IOException if command execution fails 179 */ 180 private CommandResult executeLocalCommand(String command, OutputCallback callback) throws IOException { 181 ProcessBuilder pb = new ProcessBuilder("bash", "-c", command); 182 Process process = pb.start(); 183 184 StringBuilder stdoutBuilder = new StringBuilder(); 185 StringBuilder stderrBuilder = new StringBuilder(); 186 187 // Read stderr in separate thread to avoid deadlock 188 Thread stderrThread = new Thread(() -> { 189 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { 190 String line; 191 while ((line = reader.readLine()) != null) { 192 synchronized (stderrBuilder) { 193 stderrBuilder.append(line).append("\n"); 194 } 195 if (callback != null) { 196 callback.onStderr(line); 197 } 198 } 199 } catch (IOException e) { 200 // Ignore 201 } 202 }); 203 stderrThread.start(); 204 205 // Read stdout with real-time streaming 206 try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { 207 String line; 208 while ((line = reader.readLine()) != null) { 209 stdoutBuilder.append(line).append("\n"); 210 if (callback != null) { 211 callback.onStdout(line); 212 } 213 } 214 } 215 216 try { 217 stderrThread.join(); 218 int exitCode = process.waitFor(); 219 return new CommandResult( 220 stdoutBuilder.toString().trim(), 221 stderrBuilder.toString().trim(), 222 exitCode 223 ); 224 } catch (InterruptedException e) { 225 Thread.currentThread().interrupt(); 226 throw new IOException("Command execution interrupted", e); 227 } 228 } 229 230 /** 231 * Executes a command on the remote node via SSH using JSch with real-time streaming. 232 * 233 * <p>Output is streamed via callback (if provided) in real-time as it becomes available, 234 * while also being captured for the CommandResult.</p> 235 * 236 * @param command the command to execute 237 * @param callback the callback for real-time output (may be null) 238 * @return the execution result containing stdout, stderr, and exit code 239 * @throws IOException if SSH connection or command execution fails 240 */ 241 private CommandResult executeRemoteCommand(String command, OutputCallback callback) throws IOException { 242 Session session = null; 243 ChannelExec channel = null; 244 245 try { 246 // Create JSch session 247 session = createSession(); 248 session.connect(); 249 250 // Open exec channel 251 channel = (ChannelExec) session.openChannel("exec"); 252 channel.setCommand(command); 253 254 // Get streams before connecting 255 InputStream stdout = channel.getInputStream(); 256 InputStream stderr = channel.getErrStream(); 257 258 // Connect channel 259 channel.connect(); 260 261 StringBuilder stdoutBuilder = new StringBuilder(); 262 StringBuilder stderrBuilder = new StringBuilder(); 263 264 // Read stderr in separate thread to avoid deadlock 265 final InputStream stderrFinal = stderr; 266 Thread stderrThread = new Thread(() -> { 267 try (BufferedReader reader = new BufferedReader(new InputStreamReader(stderrFinal))) { 268 String line; 269 while ((line = reader.readLine()) != null) { 270 synchronized (stderrBuilder) { 271 stderrBuilder.append(line).append("\n"); 272 } 273 if (callback != null) { 274 callback.onStderr(line); 275 } 276 } 277 } catch (IOException e) { 278 // Ignore 279 } 280 }); 281 stderrThread.start(); 282 283 // Read stdout with real-time streaming 284 try (BufferedReader reader = new BufferedReader(new InputStreamReader(stdout))) { 285 String line; 286 while ((line = reader.readLine()) != null) { 287 stdoutBuilder.append(line).append("\n"); 288 if (callback != null) { 289 callback.onStdout(line); 290 } 291 } 292 } 293 294 // Wait for stderr thread 295 stderrThread.join(); 296 297 // Wait for channel to close 298 while (!channel.isClosed()) { 299 try { 300 Thread.sleep(100); 301 } catch (InterruptedException e) { 302 Thread.currentThread().interrupt(); 303 throw new IOException("Command execution interrupted", e); 304 } 305 } 306 307 int exitCode = channel.getExitStatus(); 308 309 return new CommandResult( 310 stdoutBuilder.toString().trim(), 311 stderrBuilder.toString().trim(), 312 exitCode 313 ); 314 315 } catch (JSchException e) { 316 String message = e.getMessage(); 317 if (message != null && message.contains("USERAUTH fail")) { 318 throw new IOException(String.format( 319 "SSH authentication failed for %s@%s:%d.%n" + 320 "%n" + 321 "[~/.ssh/id_ed25519 or ~/.ssh/id_rsa]%n" + 322 " ssh-add || { eval \"$(ssh-agent -s)\" && ssh-add; }%n" + 323 "%n" + 324 "[Custom key, e.g. ~/.ssh/mykey]%n" + 325 " ssh-add ~/.ssh/mykey || { eval \"$(ssh-agent -s)\" && ssh-add ~/.ssh/mykey; }%n" + 326 "%n" + 327 "Test: ssh %s@%s echo OK", 328 user, hostname, port, user, hostname), e); 329 } else if (message != null && message.contains("Auth fail")) { 330 throw new IOException(String.format( 331 "SSH authentication failed for %s@%s:%d.%n" + 332 "%n" + 333 "[~/.ssh/id_ed25519 or ~/.ssh/id_rsa]%n" + 334 " ssh-add || { eval \"$(ssh-agent -s)\" && ssh-add; }%n" + 335 "%n" + 336 "[Custom key, e.g. ~/.ssh/mykey]%n" + 337 " ssh-add ~/.ssh/mykey || { eval \"$(ssh-agent -s)\" && ssh-add ~/.ssh/mykey; }%n" + 338 "%n" + 339 "Test: ssh %s@%s echo OK", 340 user, hostname, port, user, hostname), e); 341 } else if (message != null && (message.contains("Connection refused") || message.contains("connect timed out"))) { 342 throw new IOException(String.format( 343 "SSH connection failed to %s:%d - %s. " + 344 "Verify the host is reachable and SSH service is running.", 345 hostname, port, message), e); 346 } else if (message != null && message.contains("UnknownHostException")) { 347 throw new IOException(String.format( 348 "SSH connection failed: Unknown host '%s'. " + 349 "Check the hostname or IP address in inventory.", 350 hostname), e); 351 } 352 throw new IOException("SSH connection failed to " + hostname + ": " + message, e); 353 } catch (InterruptedException e) { 354 Thread.currentThread().interrupt(); 355 throw new IOException("Command execution interrupted", e); 356 } finally { 357 if (channel != null && channel.isConnected()) { 358 channel.disconnect(); 359 } 360 if (session != null && session.isConnected()) { 361 session.disconnect(); 362 } 363 // Clean up jump host session if used 364 if (jumpHostSession != null && jumpHostSession.isConnected()) { 365 jumpHostSession.disconnect(); 366 jumpHostSession = null; 367 } 368 } 369 } 370 371 private static final String SUDO_PASSWORD_ENV = "SUDO_PASSWORD"; 372 373 /** 374 * Executes a command with sudo privileges on the remote node. 375 * 376 * <p>Reads the sudo password from the SUDO_PASSWORD environment variable. 377 * If the environment variable is not set, throws an IOException.</p> 378 * 379 * <p>Multi-line scripts are properly handled by wrapping them in bash -c.</p> 380 * 381 * @param command the command to execute with sudo 382 * @return the execution result containing stdout, stderr, and exit code 383 * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set 384 */ 385 public CommandResult executeSudoCommand(String command) throws IOException { 386 return executeSudoCommand(command, null); 387 } 388 389 /** 390 * Executes a command with sudo privileges on the remote node with real-time output callback. 391 * 392 * <p>Reads the sudo password from the SUDO_PASSWORD environment variable. 393 * If the environment variable is not set, throws an IOException.</p> 394 * 395 * <p>Multi-line scripts are properly handled by wrapping them in bash -c.</p> 396 * 397 * @param command the command to execute with sudo 398 * @param callback the callback for real-time output (may be null) 399 * @return the execution result containing stdout, stderr, and exit code 400 * @throws IOException if SSH connection fails or SUDO_PASSWORD is not set 401 */ 402 public CommandResult executeSudoCommand(String command, OutputCallback callback) throws IOException { 403 String sudoPassword = System.getenv(SUDO_PASSWORD_ENV); 404 if (sudoPassword == null || sudoPassword.isEmpty()) { 405 throw new IOException("SUDO_PASSWORD environment variable is not set"); 406 } 407 408 // Escape single quotes in command for bash -c 409 String escapedCommand = command.replace("'", "'\"'\"'"); 410 411 // Use sudo -S to read password from stdin, wrap command in bash -c for multi-line support 412 String sudoCommand = String.format("echo '%s' | sudo -S bash -c '%s'", 413 sudoPassword.replace("'", "'\\''"), escapedCommand); 414 415 return executeCommand(sudoCommand, callback); 416 } 417 418 /** 419 * Creates a JSch SSH session with configured credentials. 420 * Supports ProxyJump for connections through a jump host. 421 * 422 * @return configured but not yet connected Session 423 * @throws JSchException if session creation fails 424 * @throws IOException if SSH key file operations fail 425 */ 426 private Session createSession() throws JSchException, IOException { 427 JSch jsch = new JSch(); 428 429 // Load OpenSSH config file first (for user/hostname/port/IdentityFile/ProxyJump) 430 com.jcraft.jsch.ConfigRepository configRepository = null; 431 String identityFileFromConfig = null; 432 String proxyJump = null; 433 try { 434 String sshConfigPath = System.getProperty("user.home") + "/.ssh/config"; 435 java.io.File configFile = new java.io.File(sshConfigPath); 436 if (configFile.exists()) { 437 com.jcraft.jsch.OpenSSHConfig openSSHConfig = 438 com.jcraft.jsch.OpenSSHConfig.parseFile(sshConfigPath); 439 configRepository = openSSHConfig; 440 441 // Get IdentityFile and ProxyJump from config for this host 442 com.jcraft.jsch.ConfigRepository.Config hostConfig = openSSHConfig.getConfig(hostname); 443 if (hostConfig != null) { 444 identityFileFromConfig = hostConfig.getValue("IdentityFile"); 445 // Expand ~ to home directory 446 if (identityFileFromConfig != null && identityFileFromConfig.startsWith("~")) { 447 identityFileFromConfig = System.getProperty("user.home") + 448 identityFileFromConfig.substring(1); 449 } 450 // Get ProxyJump setting 451 proxyJump = hostConfig.getValue("ProxyJump"); 452 } 453 } 454 } catch (Exception e) { 455 // If config loading fails, continue without it 456 } 457 458 // Setup authentication 459 setupAuthentication(jsch, identityFileFromConfig); 460 461 // Get effective connection parameters from SSH config 462 String effectiveUser = user; 463 String effectiveHostname = hostname; 464 int effectivePort = port; 465 466 if (configRepository != null) { 467 com.jcraft.jsch.ConfigRepository.Config config = configRepository.getConfig(hostname); 468 if (config != null) { 469 // Override with SSH config values if present 470 String configUser = config.getUser(); 471 if (configUser != null) { 472 effectiveUser = configUser; 473 } 474 String configHostname = config.getHostname(); 475 if (configHostname != null) { 476 effectiveHostname = configHostname; 477 } 478 String configPort = config.getValue("Port"); 479 if (configPort != null) { 480 try { 481 effectivePort = Integer.parseInt(configPort); 482 } catch (NumberFormatException e) { 483 // Keep original port 484 } 485 } 486 } 487 } 488 489 Session session; 490 491 // Handle ProxyJump if configured 492 if (proxyJump != null && !proxyJump.isEmpty()) { 493 session = createSessionViaProxyJump(jsch, proxyJump, effectiveUser, effectiveHostname, effectivePort); 494 } else { 495 // Direct connection 496 session = jsch.getSession(effectiveUser, effectiveHostname, effectivePort); 497 } 498 499 // Set password if using password authentication 500 if (password != null && !password.isEmpty()) { 501 session.setPassword(password); 502 } 503 504 // Disable strict host key checking (for convenience) 505 session.setConfig("StrictHostKeyChecking", "no"); 506 507 return session; 508 } 509 510 /** 511 * Sets up authentication for JSch. 512 */ 513 private void setupAuthentication(JSch jsch, String identityFileFromConfig) throws IOException, JSchException { 514 if (password != null && !password.isEmpty()) { 515 // Password authentication - no ssh-agent needed 516 return; 517 } 518 519 boolean authConfigured = false; 520 521 // Priority 1: Try ssh-agent first (supports Ed25519 and other modern key types) 522 try { 523 com.jcraft.jsch.IdentityRepository repo = 524 new com.jcraft.jsch.AgentIdentityRepository(new com.jcraft.jsch.SSHAgentConnector()); 525 jsch.setIdentityRepository(repo); 526 authConfigured = true; 527 } catch (Exception e) { 528 // ssh-agent not available, will try key files directly 529 } 530 531 // Priority 2: Use IdentityFile from ~/.ssh/config (for RSA/ECDSA keys without passphrase) 532 if (!authConfigured && identityFileFromConfig != null) { 533 java.io.File keyFile = new java.io.File(identityFileFromConfig); 534 if (keyFile.exists() && keyFile.canRead()) { 535 try { 536 jsch.addIdentity(identityFileFromConfig); 537 authConfigured = true; 538 } catch (JSchException ex) { 539 // Key file may require passphrase or be unsupported type 540 } 541 } 542 } 543 544 // Priority 3: Fallback to default key files (for RSA/ECDSA keys without passphrase) 545 if (!authConfigured) { 546 String home = System.getProperty("user.home"); 547 String[] keyFiles = { 548 home + "/.ssh/id_rsa", 549 home + "/.ssh/id_ecdsa", 550 home + "/.ssh/id_dsa" 551 // Note: id_ed25519 requires ssh-agent, so not included here 552 }; 553 554 for (String keyFile : keyFiles) { 555 java.io.File f = new java.io.File(keyFile); 556 if (f.exists() && f.canRead()) { 557 try { 558 jsch.addIdentity(keyFile); 559 authConfigured = true; 560 break; 561 } catch (JSchException ex) { 562 // Key file may require passphrase, try next 563 } 564 } 565 } 566 567 if (!authConfigured) { 568 throw new IOException("SSH authentication failed: No usable authentication method found.\n" + 569 "\n" + 570 "[~/.ssh/id_ed25519 or ~/.ssh/id_rsa]\n" + 571 " ssh-add || { eval \"$(ssh-agent -s)\" && ssh-add; }\n" + 572 "\n" + 573 "[Custom key, e.g. ~/.ssh/mykey]\n" + 574 " ssh-add ~/.ssh/mykey || { eval \"$(ssh-agent -s)\" && ssh-add ~/.ssh/mykey; }\n" + 575 "\n" + 576 "[Password authentication]\n" + 577 " Use --ask-pass option"); 578 } 579 } 580 } 581 582 /** 583 * Creates a session through a jump host using ProxyJump. 584 * Format: user@host or user@host:port 585 */ 586 private Session createSessionViaProxyJump(JSch jsch, String proxyJump, 587 String targetUser, String targetHost, int targetPort) throws JSchException, IOException { 588 589 // Parse ProxyJump: user@host or user@host:port 590 String jumpUser; 591 String jumpHost; 592 int jumpPort = 22; 593 594 String[] atParts = proxyJump.split("@", 2); 595 if (atParts.length == 2) { 596 jumpUser = atParts[0]; 597 String hostPart = atParts[1]; 598 if (hostPart.contains(":")) { 599 String[] hostPortParts = hostPart.split(":", 2); 600 jumpHost = hostPortParts[0]; 601 try { 602 jumpPort = Integer.parseInt(hostPortParts[1]); 603 } catch (NumberFormatException e) { 604 jumpHost = hostPart; 605 } 606 } else { 607 jumpHost = hostPart; 608 } 609 } else { 610 // No user specified, use current user 611 jumpUser = user; 612 String hostPart = proxyJump; 613 if (hostPart.contains(":")) { 614 String[] hostPortParts = hostPart.split(":", 2); 615 jumpHost = hostPortParts[0]; 616 try { 617 jumpPort = Integer.parseInt(hostPortParts[1]); 618 } catch (NumberFormatException e) { 619 jumpHost = hostPart; 620 } 621 } else { 622 jumpHost = hostPart; 623 } 624 } 625 626 // Create and connect to jump host 627 jumpHostSession = jsch.getSession(jumpUser, jumpHost, jumpPort); 628 jumpHostSession.setConfig("StrictHostKeyChecking", "no"); 629 if (password != null && !password.isEmpty()) { 630 jumpHostSession.setPassword(password); 631 } 632 jumpHostSession.connect(); 633 634 // Set up port forwarding through jump host 635 // Find an available local port 636 int localPort = jumpHostSession.setPortForwardingL(0, targetHost, targetPort); 637 638 // Create session to target via the forwarded port 639 Session targetSession = jsch.getSession(targetUser, "127.0.0.1", localPort); 640 641 return targetSession; 642 } 643 644 /** 645 * Cleans up resources used by this Node. 646 * Closes any open jump host sessions. 647 */ 648 public void cleanup() { 649 if (jumpHostSession != null && jumpHostSession.isConnected()) { 650 jumpHostSession.disconnect(); 651 jumpHostSession = null; 652 } 653 } 654 655 /** 656 * Gets the hostname of this node. 657 * 658 * @return the hostname 659 */ 660 public String getHostname() { 661 return hostname; 662 } 663 664 /** 665 * Gets the SSH username for this node. 666 * 667 * @return the username 668 */ 669 public String getUser() { 670 return user; 671 } 672 673 /** 674 * Gets the SSH port for this node. 675 * 676 * @return the port number 677 */ 678 public int getPort() { 679 return port; 680 } 681 682 @Override 683 public String toString() { 684 return String.format("Node{hostname='%s', user='%s', port=%d}", 685 hostname, user, port); 686 } 687 688 /** 689 * Callback interface for real-time output streaming. 690 * 691 * <p>Implementations receive stdout and stderr lines as they are produced, 692 * enabling real-time forwarding to accumulators without blocking.</p> 693 */ 694 public interface OutputCallback { 695 /** 696 * Called when a stdout line is read. 697 * 698 * @param line the stdout line (without newline) 699 */ 700 void onStdout(String line); 701 702 /** 703 * Called when a stderr line is read. 704 * 705 * @param line the stderr line (without newline) 706 */ 707 void onStderr(String line); 708 } 709 710 /** 711 * Represents the result of a command execution. 712 */ 713 public static class CommandResult { 714 private final String stdout; 715 private final String stderr; 716 private final int exitCode; 717 718 public CommandResult(String stdout, String stderr, int exitCode) { 719 this.stdout = stdout; 720 this.stderr = stderr; 721 this.exitCode = exitCode; 722 } 723 724 public String getStdout() { 725 return stdout; 726 } 727 728 public String getStderr() { 729 return stderr; 730 } 731 732 public int getExitCode() { 733 return exitCode; 734 } 735 736 public boolean isSuccess() { 737 return exitCode == 0; 738 } 739 740 @Override 741 public String toString() { 742 return String.format("CommandResult{exitCode=%d, stdout='%s', stderr='%s'}", 743 exitCode, stdout, stderr); 744 } 745 } 746}