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}