/* * Copyright 2017 ~ 2025 the original author or authors. <[email protected], [email protected]> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.wl4g.devops.components.shell.handler; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; import org.jline.terminal.TerminalBuilder; import org.jline.utils.AttributedString; import java.io.EOFException; import java.io.File; import java.io.IOException; import java.io.ObjectInputStream; import java.net.Socket; import java.net.SocketException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.function.Function; import static java.lang.String.format; import static java.lang.String.valueOf; import static java.lang.System.*; import static java.util.Objects.nonNull; import com.wl4g.devops.components.shell.command.DefaultBuiltInCommand; import com.wl4g.devops.components.shell.config.Configuration; import com.wl4g.devops.components.shell.config.DynamicCompleter; import com.wl4g.devops.components.shell.handler.AbstractShellHandler; import com.wl4g.devops.components.shell.handler.ShellMessageChannel; import com.wl4g.devops.components.shell.registry.ShellHandlerRegistrar; import com.wl4g.devops.components.shell.signal.*; import static com.wl4g.devops.components.shell.annotation.ShellOption.GNU_CMD_LONG; import static com.wl4g.devops.components.shell.cli.BuiltInCommand.INTERNAL_HE; import static com.wl4g.devops.components.shell.cli.BuiltInCommand.INTERNAL_HELP; import static com.wl4g.devops.components.shell.config.DefaultShellHandlerRegistrar.*; import static com.wl4g.devops.components.shell.utils.LineUtils.clean; import static com.wl4g.devops.components.shell.utils.LineUtils.parse; import static org.apache.commons.lang3.StringUtils.*; import static org.apache.commons.lang3.SystemUtils.USER_HOME; import static org.apache.commons.lang3.exception.ExceptionUtils.getRootCauseMessage; import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace; import static org.jline.reader.LineReader.HISTORY_FILE; import static com.wl4g.devops.tool.common.lang.Assert2.*; /** * Abstract shell component runner * * @author Wangl.sir <[email protected]> * @version v1.0 2019年4月14日 * @since */ public abstract class AbstractClientShellHandler extends AbstractShellHandler implements CliShellHandler { /** * Used to get the name of the set target service (that is, the server that * the current shell client will connect to) (because the same computer may * start many different shell services) */ final public static String ARG_SERV_NAME = "servname"; /** * IBid, note that this priority is higher than ARG_SERV_PIDS */ final public static String ARG_SERV_POINT = "servpoint"; /** * Commands prompt string. */ final public static String ARG_PROMPT = "prompt"; /** * Enable debugging */ final public static long TIMEOUT = Long.parseLong(getProperty("timeout", valueOf(180_000L))); /** * Attributed string */ final public static AttributedString DEFAULT_ATTRIBUTED = new AttributedString("console> "); /** * Shell configuration */ final protected Configuration config; /** * Line reader */ final protected LineReader lineReader; /** * Shell client handler */ private ClientShellMessageChannel clientChannel; /** * Current process exception statcktrace as strings. */ private String stacktraceAsString; public AbstractClientShellHandler(Configuration config) { super(config, getSingle()); notNull(config, "configuration is null, please check configure"); this.config = config; // Init create lineReader this.lineReader = createLineReader(); // Initialization try { initialize(); } catch (Throwable t) { printError(EMPTY, t); shutdown(); } } public final ShellHandlerRegistrar getRegistrar() { return registrar; } public final LineReader getLineReader() { return lineReader; } @Override public void shutdown() { try { out.println("Shutting down, bye..."); // Close client closeQuietly(); } catch (Throwable e) { printError("Shutdown failure.", e); } // Gracefully halt exit(0); } @Override protected void printError(String abnormal, Throwable th) { stacktraceAsString = getStackTrace(th); super.printError(abnormal, th); } /** * Get last abnormal stack-trace string * * @return */ public String getLastStacktrace() { return stacktraceAsString; } /** * Submission stdin message to remote * * @param line * @throws IOException */ protected void writeStdin(Object stdin) { try { boolean isRemoteCommand = true; if (stdin instanceof String) { String cmd = (String) stdin; List<String> cmds = parse(cmd); if (!cmds.isEmpty()) { // $> [help|clear|history...] if (registrar.contains(cmds.get(0))) { // Local command? isRemoteCommand = false; DefaultBuiltInCommand.senseLine(cmd); process(cmd); return; } // help command? [MARK0] $> add --help else if (cmds.size() > 1 && equalsAny(cmds.get(1), (GNU_CMD_LONG + INTERNAL_HELP), (GNU_CMD_LONG + INTERNAL_HE))) { isRemoteCommand = false; // e.g: '$> help add' cmd = clean(INTERNAL_HELP) + " " + cmds.get(0); // Set current line DefaultBuiltInCommand.senseLine(cmd); process(cmd); return; } } // Wrap string command stdin = new StdinSignal(cmd); } // Check connect & send to server. if (isRemoteCommand) { ensureClient(); clientChannel.writeFlush(stdin); } } catch (IOException e) { printError(EMPTY, e); } } /** * Get line attributed. * * @return */ protected AttributedString getAttributed() { String prompt = getProperty(ARG_PROMPT); prompt = isBlank(prompt) ? getProperty(ARG_SERV_NAME) : prompt; return isBlank(prompt) ? DEFAULT_ATTRIBUTED : new AttributedString(format("%s> ", prompt)); } /** * Create {@link LineReader}. * * @return */ private LineReader createLineReader() { try { return LineReaderBuilder.builder().appName("Devops Shell Cli").completer(new DynamicCompleter(getSingle())) .terminal(TerminalBuilder.terminal()).build(); } catch (IOException e) { throw new IllegalStateException(e); } } /** * Initialization runner * * @throws IOException */ private void initialize() throws IOException { // Register commands registrar.register(new DefaultBuiltInCommand(this)); // Initialize remote register commands writeStdin(new MetaSignal()); // Set history persist file File file = new File(USER_HOME + "/.devops/shell/history"); if (!file.getParentFile().exists()) { state(file.getParentFile().mkdirs(), format("Failed to create, for directory: '%s'", file.getParentFile().getAbsolutePath())); } if (!file.exists()) { String errmsg = format("Failed to create, for file: '%s'", file.getAbsolutePath()); try { state(file.createNewFile(), errmsg); } catch (IOException e) { throw new IllegalStateException(errmsg); } } lineReader.setVariable(HISTORY_FILE, file.getAbsolutePath()); // Print banners banner(); } /** * Print banner */ private void banner() { out.println(format("%s", config.getBanner())); String v = getClass().getPackage().getImplementationVersion(); out.println(format("Version: %s", isBlank(v) ? "unknown" : v)); out.println(format("Time: %s", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()))); out.println(); } /** * Ensure client handler * * @throws IOException */ @SuppressWarnings("resource") private void ensureClient() throws IOException { boolean create = false; if (clientChannel == null) { create = true; } else if (!clientChannel.isActive()) { create = true; closeQuietly(); } if (create) { Object[] point = determineServPoint(); printDebug(format("Connecting to %s:%s ... \n", point[0], point[1])); Socket s = null; try { s = new Socket((String) point[0], (int) point[1]); } catch (IOException e) { String errmsg = format("Connecting to '%s'(%s) failure! cause by: %s", getProperty(ARG_SERV_NAME), point[1], getRootCauseMessage(e)); throw new IllegalStateException(errmsg); } clientChannel = new ClientShellMessageChannel(this, s, result -> null).starting(); } } /** * Determine the corresponding server port (identified by PID) of the * current client * * @return */ private Object[] determineServPoint() { String servName = getProperty(ARG_SERV_NAME); String servPoint = getProperty(ARG_SERV_POINT); if (isBlank(servName) && isBlank(servPoint)) { throw new IllegalArgumentException(format( "JVM startup argument -D%s(e.g. -D%s=8080) and -D%s(e.g. -D%s=myapp1) must be one of the two, and only -D%s are adopted when both exist", ARG_SERV_POINT, ARG_SERV_POINT, ARG_SERV_NAME, ARG_SERV_NAME, ARG_SERV_POINT)); } // // Direct use of specified point.</br> // Can be used to connect to remote service console. // if (isNotBlank(servPoint)) { isTrue(contains(servPoint, ":") && servPoint.length() > 8, format("Invalid server point. e.g. -D%s=10.0.0.11", ARG_SERV_POINT)); String[] parts = servPoint.split(":"); isTrue(isNumeric(parts[1]), format("Invalid server port is %s", servPoint)); int port = Integer.parseInt(parts[1]); isTrue((port > 1024 && port < 65535), format("Server port must be between 1024 and 65535, actual is %s", servPoint)); return new Object[] { parts[0], port }; } // // Obtain port according to PIDS.</br> // Can only be used to connect to the local service console. // return new Object[] { config.getServer(), ensureDetermineServPort(servName) }; } /** * Quietly client close * * @throws IOException */ private void closeQuietly() { try { if (clientChannel != null) { clientChannel.close(); } } catch (IOException e) { printError("Failed to close channel", e); } } /** * Check client channel is active. * * @return */ protected boolean isActive() { return nonNull(clientChannel) && clientChannel.isActive(); } /** * Client shell message channel handler * * @author Wangl.sir <[email protected]> * @version v1.0 2019年5月2日 * @since */ class ClientShellMessageChannel extends ShellMessageChannel { /** * Line process runner. */ final private AbstractClientShellHandler runner; /** * Boot boss thread */ private Thread boss; public ClientShellMessageChannel(AbstractClientShellHandler runner, Socket client, Function<String, Object> function) { super(runner.getRegistrar(), client, function); this.runner = runner; } public ClientShellMessageChannel starting() { this.boss = new Thread(this); this.boss.start(); return this; } @Override public void run() { while (!boss.isInterrupted() && isActive()) { try { // Read a string command process result Object input = new ObjectInputStream(_in).readObject(); // Post process postHandleOutput(input); } catch (SocketException | EOFException e) { err.println("Connection tunnel closed!"); boss.interrupt(); try { close(); } catch (IOException e1) { runner.printError(EMPTY, e); } } catch (Throwable e) { runner.printError(EMPTY, e); } } } } }