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.mixin;
019
020import java.io.BufferedReader;
021import java.io.IOException;
022import java.io.InputStreamReader;
023import java.net.InetAddress;
024import java.util.concurrent.TimeUnit;
025
026import com.scivicslab.actoriac.Node;
027
028/**
029 * Command executor that executes commands on the local machine.
030 *
031 * <p>Used by NodeGroupInterpreter to execute commands locally when running
032 * on the control node itself.</p>
033 *
034 * @author devteam@scivicslab.com
035 * @since 2.15.0
036 */
037public class LocalCommandExecutor implements CommandExecutor {
038
039    private static final long DEFAULT_TIMEOUT_SECONDS = 300;
040
041    private final String hostname;
042
043    /**
044     * Constructs a local command executor.
045     */
046    public LocalCommandExecutor() {
047        String h;
048        try {
049            h = InetAddress.getLocalHost().getHostName();
050        } catch (Exception e) {
051            h = "localhost";
052        }
053        this.hostname = h;
054    }
055
056    @Override
057    public Node.CommandResult execute(String command) throws IOException {
058        return execute(command, null);
059    }
060
061    @Override
062    public Node.CommandResult execute(String command, Node.OutputCallback callback) throws IOException {
063        ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", command);
064        pb.redirectErrorStream(false);
065
066        Process process = pb.start();
067
068        StringBuilder stdout = new StringBuilder();
069        StringBuilder stderr = new StringBuilder();
070
071        // Read stdout
072        Thread stdoutThread = new Thread(() -> {
073            try (BufferedReader reader = new BufferedReader(
074                    new InputStreamReader(process.getInputStream()))) {
075                String line;
076                while ((line = reader.readLine()) != null) {
077                    stdout.append(line).append("\n");
078                    if (callback != null) {
079                        callback.onStdout(line);
080                    }
081                }
082            } catch (IOException e) {
083                // Ignore
084            }
085        });
086
087        // Read stderr
088        Thread stderrThread = new Thread(() -> {
089            try (BufferedReader reader = new BufferedReader(
090                    new InputStreamReader(process.getErrorStream()))) {
091                String line;
092                while ((line = reader.readLine()) != null) {
093                    stderr.append(line).append("\n");
094                    if (callback != null) {
095                        callback.onStderr(line);
096                    }
097                }
098            } catch (IOException e) {
099                // Ignore
100            }
101        });
102
103        stdoutThread.start();
104        stderrThread.start();
105
106        try {
107            boolean finished = process.waitFor(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
108            if (!finished) {
109                process.destroyForcibly();
110                return new Node.CommandResult(stdout.toString(), "Command timed out", -1);
111            }
112
113            stdoutThread.join(1000);
114            stderrThread.join(1000);
115
116            int exitCode = process.exitValue();
117            return new Node.CommandResult(stdout.toString().trim(), stderr.toString().trim(), exitCode);
118
119        } catch (InterruptedException e) {
120            Thread.currentThread().interrupt();
121            process.destroyForcibly();
122            return new Node.CommandResult(stdout.toString(), "Interrupted: " + e.getMessage(), -1);
123        }
124    }
125
126    @Override
127    public Node.CommandResult executeSudo(String command) throws IOException {
128        return executeSudo(command, null);
129    }
130
131    @Override
132    public Node.CommandResult executeSudo(String command, Node.OutputCallback callback) throws IOException {
133        String sudoPassword = System.getenv("SUDO_PASSWORD");
134        if (sudoPassword == null || sudoPassword.isEmpty()) {
135            throw new IOException("SUDO_PASSWORD environment variable is not set");
136        }
137
138        // Use sudo with stdin password
139        String sudoCommand = String.format("echo '%s' | sudo -S %s",
140            sudoPassword.replace("'", "'\\''"), command);
141
142        return execute(sudoCommand, callback);
143    }
144
145    @Override
146    public String getIdentifier() {
147        return hostname;
148    }
149}