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}