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.IOException;
021
022import org.json.JSONArray;
023
024import com.scivicslab.actoriac.Node;
025import com.scivicslab.pojoactor.core.Action;
026import com.scivicslab.pojoactor.core.ActionResult;
027
028/**
029 * Mixin interface providing command execution actions via @Action annotations.
030 *
031 * <p>This interface demonstrates the mixin pattern in Java using interface default methods
032 * with {@link Action} annotations. Classes implementing this interface automatically gain
033 * the ability to execute commands from workflow YAML without duplicating code.</p>
034 *
035 * <h2>Usage</h2>
036 *
037 * <p>Implement this interface and provide a {@link CommandExecutor}:</p>
038 * <pre>{@code
039 * public class NodeInterpreter extends Interpreter implements CommandExecutable {
040 *     private final CommandExecutor executor;
041 *
042 *     public NodeInterpreter(Node node, IIActorSystem system) {
043 *         this.executor = new SshCommandExecutor(node);
044 *     }
045 *
046 *     @Override
047 *     public CommandExecutor getCommandExecutor() {
048 *         return executor;
049 *     }
050 * }
051 * }</pre>
052 *
053 * <h2>Workflow YAML</h2>
054 *
055 * <p>Once implemented, the following actions become available in workflow YAML:</p>
056 * <pre>{@code
057 * steps:
058 *   - states: ["0", "1"]
059 *     actions:
060 *       - actor: this
061 *         method: executeCommand
062 *         arguments: ["ls -la"]
063 *
064 *   - states: ["1", "2"]
065 *     actions:
066 *       - actor: this
067 *         method: executeSudoCommand
068 *         arguments: ["apt-get update"]
069 * }</pre>
070 *
071 * <h2>Design Rationale</h2>
072 *
073 * <p>This interface solves the problem of code duplication between NodeInterpreter
074 * and NodeGroupInterpreter. Previously, each class needed its own implementation of
075 * command execution actions. With this mixin approach:</p>
076 * <ul>
077 *   <li>Both classes implement the same interface</li>
078 *   <li>The @Action methods are defined once in the interface</li>
079 *   <li>Each class provides its own CommandExecutor (SSH or local)</li>
080 *   <li>IIActorRef discovers the @Action methods via reflection</li>
081 * </ul>
082 *
083 * @author devteam@scivicslab.com
084 * @since 2.15.0
085 * @see CommandExecutor
086 * @see SshCommandExecutor
087 * @see LocalCommandExecutor
088 */
089public interface CommandExecutable {
090
091    /**
092     * Returns the command executor for this instance.
093     *
094     * <p>Implementing classes must provide an appropriate executor:</p>
095     * <ul>
096     *   <li>{@link SshCommandExecutor} for remote node execution</li>
097     *   <li>{@link LocalCommandExecutor} for local execution</li>
098     * </ul>
099     *
100     * @return the command executor
101     */
102    CommandExecutor getCommandExecutor();
103
104    /**
105     * Returns an optional output callback for command execution.
106     *
107     * <p>When not null, command output is streamed to this callback in real-time.
108     * Default implementation returns null (no streaming).</p>
109     *
110     * @return the output callback, or null
111     */
112    default Node.OutputCallback getOutputCallback() {
113        return null;
114    }
115
116    /**
117     * Executes a command and returns the result (action handler).
118     *
119     * <p>This action is callable from workflow YAML as:</p>
120     * <pre>{@code
121     * - actor: this
122     *   method: executeCommand
123     *   arguments: ["your-command-here"]
124     * }</pre>
125     *
126     * <p>Note: The Java method name is different from the action name to avoid
127     * conflicts with existing methods on implementing classes that have different
128     * return types.</p>
129     *
130     * @param args JSON array containing the command as the first element
131     * @return ActionResult with success status and command output
132     */
133    @Action("executeCommand")
134    default ActionResult doExecuteCommand(String args) {
135        try {
136            String command = extractCommand(args);
137            Node.OutputCallback callback = getOutputCallback();
138            Node.CommandResult result = getCommandExecutor().execute(command, callback);
139            return toActionResult(result);
140        } catch (IOException e) {
141            return new ActionResult(false, "Error: " + e.getMessage());
142        }
143    }
144
145    /**
146     * Executes a command with sudo privileges (action handler).
147     *
148     * <p>This action is callable from workflow YAML as:</p>
149     * <pre>{@code
150     * - actor: this
151     *   method: executeSudoCommand
152     *   arguments: ["your-command-here"]
153     * }</pre>
154     *
155     * <p>Requires SUDO_PASSWORD environment variable to be set.</p>
156     *
157     * <p>Note: The Java method name is different from the action name to avoid
158     * conflicts with existing methods on implementing classes that have different
159     * return types.</p>
160     *
161     * @param args JSON array containing the command as the first element
162     * @return ActionResult with success status and command output
163     */
164    @Action("executeSudoCommand")
165    default ActionResult doExecuteSudoCommand(String args) {
166        try {
167            String command = extractCommand(args);
168            Node.OutputCallback callback = getOutputCallback();
169            Node.CommandResult result = getCommandExecutor().executeSudo(command, callback);
170            return toActionResult(result);
171        } catch (IOException e) {
172            String hostname = getCommandExecutor().getIdentifier();
173            if (e.getMessage() != null && e.getMessage().contains("SUDO_PASSWORD")) {
174                return new ActionResult(false, "%" + hostname + ": [FAIL] SUDO_PASSWORD not set");
175            }
176            return new ActionResult(false, "Error: " + e.getMessage());
177        }
178    }
179
180    /**
181     * Extracts the command string from JSON array arguments.
182     *
183     * @param args JSON array string (e.g., '["ls -la"]')
184     * @return the extracted command string
185     */
186    private static String extractCommand(String args) {
187        try {
188            JSONArray jsonArray = new JSONArray(args);
189            if (jsonArray.length() == 0) {
190                throw new IllegalArgumentException("Command arguments cannot be empty");
191            }
192            return jsonArray.getString(0);
193        } catch (Exception e) {
194            throw new IllegalArgumentException(
195                "Invalid command argument format. Expected JSON array with command string: " + args, e);
196        }
197    }
198
199    /**
200     * Converts a Node.CommandResult to ActionResult.
201     *
202     * @param result the command result
203     * @return the action result
204     */
205    private static ActionResult toActionResult(Node.CommandResult result) {
206        String output = result.getStdout().trim();
207        String stderr = result.getStderr().trim();
208        if (!stderr.isEmpty()) {
209            output = output.isEmpty() ? stderr : output + "\n[stderr]\n" + stderr;
210        }
211        return new ActionResult(result.isSuccess(), output);
212    }
213}