From 713dd6a42a319baf4a6a2e932aecd75c85cd2379 Mon Sep 17 00:00:00 2001 From: Francois Onimus Date: Fri, 23 Oct 2020 17:56:09 +0200 Subject: [PATCH 1/3] Add possibility to put script in background --- README.md | 5 + .../fonimus/ssh/shell/basic/DemoCommand.java | 18 ++ .../fonimus/ssh/shell/ExtendedShell.java | 57 ++++- .../github/fonimus/ssh/shell/SshContext.java | 11 +- .../fonimus/ssh/shell/SshShellHelper.java | 33 ++- .../fonimus/ssh/shell/SshShellRunnable.java | 2 +- .../ssh/shell/commands/ScriptCommand.java | 213 ++++++++++++++++++ .../shell/interactive/InteractiveInputIO.java | 39 ++++ .../StoppableInteractiveInput.java | 43 ++++ .../TypePostProcessorResultHandler.java | 8 +- .../provided/SavePostProcessor.java | 12 +- .../fonimus/ssh/shell/ExtendedShellTest.java | 4 +- .../postprocess/SavePostProcessorTest.java | 10 +- .../TypePostProcessorResultHandlerTest.java | 20 +- starter/src/test/resources/script.txt | 31 +++ 15 files changed, 466 insertions(+), 40 deletions(-) create mode 100644 starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java create mode 100644 starter/src/main/java/com/github/fonimus/ssh/shell/interactive/InteractiveInputIO.java create mode 100644 starter/src/main/java/com/github/fonimus/ssh/shell/interactive/StoppableInteractiveInput.java create mode 100644 starter/src/test/resources/script.txt diff --git a/README.md b/README.md index 65ef47d0..797d01ab 100755 --- a/README.md +++ b/README.md @@ -748,6 +748,11 @@ public class ApplicationTest {} ## Release notes +### 1.5.3 + +* Rewrite script command to be usable in background with result file (options added to default command) +* Add ``StoppableInteractiveInput`` to be able to stop the interactive mode with specific condition + ### 1.5.2 * Add option to create group commands ``ssh.shell.commands..create``, which is true by default. diff --git a/samples/basic/src/main/java/com/github/fonimus/ssh/shell/basic/DemoCommand.java b/samples/basic/src/main/java/com/github/fonimus/ssh/shell/basic/DemoCommand.java index 1d8b9692..b1711b79 100755 --- a/samples/basic/src/main/java/com/github/fonimus/ssh/shell/basic/DemoCommand.java +++ b/samples/basic/src/main/java/com/github/fonimus/ssh/shell/basic/DemoCommand.java @@ -24,6 +24,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; import org.springframework.shell.standard.ShellMethod; @@ -37,6 +38,7 @@ /** * Demo command for example */ +@Slf4j @SshShellComponent public class DemoCommand { @@ -62,6 +64,22 @@ public String echo(String message, @ShellOption(defaultValue = ShellOption.NULL) return message; } + /** + * Wait for some time + * + * @param waitInMillis wait time + * @return message + */ + @ShellMethod(key = "wait", value = "Wait command") + public String waitCmd(long waitInMillis) { + try { + Thread.sleep(waitInMillis); + } catch (InterruptedException e) { + LOGGER.warn("Got interrupted"); + } + return "Waited " + waitInMillis + " milliseconds"; + } + /** * Pojo command *

Try the post processors like pretty, grep with it

diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java b/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java index 0816c73c..0dad5963 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java @@ -22,10 +22,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; +import org.springframework.shell.ExitRequest; import org.springframework.shell.Input; +import org.springframework.shell.InputProvider; import org.springframework.shell.ResultHandler; import org.springframework.shell.Shell; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -44,6 +47,8 @@ public class ExtendedShell extends Shell { + private final ResultHandler resultHandler; + private final List postProcessorNames = new ArrayList<>(); /** @@ -54,22 +59,57 @@ public class ExtendedShell */ public ExtendedShell(ResultHandler resultHandler, List postProcessors) { super(resultHandler); + this.resultHandler = resultHandler; if (postProcessors != null) { postProcessorNames.addAll(postProcessors.stream().map(PostProcessor::getName).collect(Collectors.toList())); } } + + @Override + public void run(InputProvider inputProvider) throws IOException { + run(inputProvider, () -> false); + } + + @SuppressWarnings("unchecked") + public void run(InputProvider inputProvider, ShellNotifier shellNotifier) throws IOException { + Object result = null; + // Handles ExitRequest thrown from Quit command + while (!(result instanceof ExitRequest) && !shellNotifier.shouldStop()) { + Input input; + try { + input = inputProvider.readInput(); + } catch (Exception e) { + if (e instanceof ExitRequest) { // Handles ExitRequest thrown from hitting CTRL-C + break; + } + resultHandler.handleResult(e); + continue; + } + if (input == null) { + break; + } + + result = evaluate(input); + if (result != NO_INPUT && !(result instanceof ExitRequest)) { + resultHandler.handleResult(result); + } + } + } + @Override public Object evaluate(Input input) { List words = input.words(); Object toReturn = super.evaluate(new ExtendedInput(input)); SshContext ctx = SSH_THREAD_CONTEXT.get(); if (ctx != null) { - ctx.setPostProcessorsList(null); + if (!ctx.isBackground()) { + // clear potential post processors from previous commands + ctx.getPostProcessorsList().clear(); + } if (isKeyCharInList(words)) { List indexes = IntStream.range(0, words.size()).filter(i -> KEY_CHARS.contains(words.get(i))).boxed().collect(Collectors.toList()); - List postProcessors = new ArrayList<>(); for (Integer index : indexes) { if (words.size() > index + 1) { String keyChar = words.get(index); @@ -83,15 +123,14 @@ public Object evaluate(Input input) { currentIndex++; word = words.size() > index + currentIndex ? words.get(index + currentIndex) : null; } - postProcessors.add(new PostProcessorObject(postProcessorName, params)); + ctx.getPostProcessorsList().add(new PostProcessorObject(postProcessorName, params)); } else if (keyChar.equals(ARROW)) { - postProcessors.add(new PostProcessorObject(SavePostProcessor.SAVE, + ctx.getPostProcessorsList().add(new PostProcessorObject(SavePostProcessor.SAVE, Collections.singletonList(words.get(index + 1)))); } } } - LOGGER.debug("Found {} post processors", postProcessors.size()); - ctx.setPostProcessorsList(postProcessors); + LOGGER.debug("Found {} post processors", ctx.getPostProcessorsList().size()); } } return toReturn; @@ -113,4 +152,10 @@ private static boolean isKeyCharInList(List strList) { } return false; } + + @FunctionalInterface + public interface ShellNotifier { + + boolean shouldStop(); + } } diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/SshContext.java b/starter/src/main/java/com/github/fonimus/ssh/shell/SshContext.java index 0e9dc6bd..2accc3df 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/SshContext.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/SshContext.java @@ -25,6 +25,7 @@ import org.jline.reader.LineReader; import org.jline.terminal.Terminal; +import java.util.ArrayList; import java.util.List; /** @@ -41,8 +42,12 @@ public class SshContext { private SshAuthentication authentication; + private final List postProcessorsList = new ArrayList<>(); + @Setter - private List postProcessorsList; + private boolean background; + + private long backgroundCount = 0; public SshContext() { } @@ -89,4 +94,8 @@ public ServerSession getSshSession() { public Environment getSshEnv() { return isLocalPrompt() ? null : sshShellRunnable.getSshEnv(); } + + public void incrementBackgroundCount() { + this.backgroundCount++; + } } diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellHelper.java b/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellHelper.java index 4c1d45d2..fd1283bd 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellHelper.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellHelper.java @@ -19,8 +19,11 @@ import com.github.fonimus.ssh.shell.auth.SshAuthentication; import com.github.fonimus.ssh.shell.interactive.Interactive; import com.github.fonimus.ssh.shell.interactive.InteractiveInput; +import com.github.fonimus.ssh.shell.interactive.InteractiveInputIO; import com.github.fonimus.ssh.shell.interactive.KeyBinding; import com.github.fonimus.ssh.shell.interactive.KeyBindingInput; +import com.github.fonimus.ssh.shell.interactive.StoppableInteractiveInput; +import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.apache.sshd.server.Environment; import org.apache.sshd.server.session.ServerSession; @@ -570,7 +573,7 @@ public void interactive(Interactive interactive) { if (size.getColumns() < previous) { display.clear(); } - maxLines[0] = display(interactive.getInput(), display, size, refreshDelay[0]); + maxLines[0] = display(interactive.getInput(), display, size, refreshDelay[0]).getLines(); }); Attributes attr = terminal.enterRawMode(); try { @@ -641,7 +644,8 @@ public void interactive(Interactive interactive) { String op; do { - maxLines[0] = display(interactive.getInput(), display, size, refreshDelay[0]); + DisplayResult result = display(interactive.getInput(), display, size, refreshDelay[0]); + maxLines[0] = result.getLines(); checkInterrupted(); long delta = ((System.currentTimeMillis() - t0) / refreshDelay[0] + 1) @@ -654,6 +658,8 @@ public void interactive(Interactive interactive) { op = EXIT; } else if (ch != NonBlockingReader.READ_EXPIRED) { op = bindingReader.readBinding(keys, null, false); + } else if (result.isStop()) { + op = EXIT; } if (op == null) { continue; @@ -712,11 +718,20 @@ public void interactive(InteractiveInput input, long delay, boolean fullScreen, interactive(Interactive.builder().input(input).refreshDelay(delay).fullScreen(fullScreen).size(size).build()); } - private int display(InteractiveInput input, Display display, Size size, long currentDelay) { + private DisplayResult display(InteractiveInput input, Display display, Size size, long currentDelay) { display.resize(size.getRows(), size.getColumns()); - List lines = input.getLines(size, currentDelay); + DisplayResult result = new DisplayResult(); + List lines; + if (input instanceof StoppableInteractiveInput) { + InteractiveInputIO io = ((StoppableInteractiveInput) input).getIO(size, currentDelay); + result.setStop(io.isStop()); + lines = io.getLines(); + } else { + lines = input.getLines(size, currentDelay); + } display.update(lines, 0); - return lines.size(); + result.setLines(lines.size()); + return result; } private void checkInterrupted() throws InterruptedException { @@ -741,4 +756,12 @@ private LineReader reader() { } return sshContext.getLineReader(); } + + @Data + public static class DisplayResult { + + private int lines; + + private boolean stop; + } } diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellRunnable.java b/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellRunnable.java index c998911e..e8533894 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellRunnable.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/SshShellRunnable.java @@ -269,7 +269,7 @@ public SshShellInputProvider(LineReader lineReader, PromptProvider promptProvide public Input readInput() { SshContext ctx = SSH_THREAD_CONTEXT.get(); if (ctx != null) { - ctx.setPostProcessorsList(null); + ctx.getPostProcessorsList().clear(); } try { return super.readInput(); diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java b/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java new file mode 100644 index 00000000..371ffd61 --- /dev/null +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2020 François Onimus + * + * 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.github.fonimus.ssh.shell.commands; + +import com.github.fonimus.ssh.shell.ExtendedShell; +import com.github.fonimus.ssh.shell.SshContext; +import com.github.fonimus.ssh.shell.SshShellHelper; +import com.github.fonimus.ssh.shell.interactive.Interactive; +import com.github.fonimus.ssh.shell.interactive.InteractiveInputIO; +import com.github.fonimus.ssh.shell.interactive.StoppableInteractiveInput; +import com.github.fonimus.ssh.shell.postprocess.PostProcessorObject; +import com.github.fonimus.ssh.shell.postprocess.provided.SavePostProcessor; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.jline.reader.Parser; +import org.jline.utils.AttributedString; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.shell.jline.FileInputProvider; +import org.springframework.shell.standard.ShellCommandGroup; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; +import org.springframework.shell.standard.commands.Script; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.github.fonimus.ssh.shell.SshShellCommandFactory.SSH_THREAD_CONTEXT; + +/** + * Override history command to get history per user if not shared + */ +@Slf4j +@SshShellComponent +@ShellCommandGroup("Built-In Commands") +public class ScriptCommand + implements Script.Command, DisposableBean { + + private final ExtendedShell shell; + + private final Parser parser; + + private final SshShellHelper helper; + + private ExecutorService executor; + + private ScriptStatus status; + + public ScriptCommand(ExtendedShell shell, Parser parser, SshShellHelper helper) { + this.shell = shell; + this.parser = parser; + this.helper = helper; + } + + @Override + public void destroy() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @ShellMethod(value = "Read and execute commands from a file.") + public void script( + @ShellOption(value = {"-f", "--file"}, help = "File to run commands from", defaultValue = + ShellOption.NULL) File file, + @ShellOption(value = {"-o", "--output"}, help = "File to write results to", defaultValue = ShellOption.NULL) + File output, + @ShellOption(value = {"-b", "--background"}, help = "File to run commands from", defaultValue = "false") + boolean background, + @ShellOption(value = {"-a", "--action"}, help = "Action : execute, stop, status (default is execute)", + defaultValue = "execute") ScriptAction action, + @ShellOption(value = {"-n", "--not-interactive"}, help = "Do not launch status directly to get " + + "interactive process", defaultValue = "false") boolean notInteractive + ) throws IOException { + if (action == ScriptAction.execute) { + if (file == null) { + throw new IllegalArgumentException("File is mandatory"); + } + if (!background) { + run(file); + } else { + if (output == null) { + throw new IllegalArgumentException("Cannot use background option without output option for " + + " commands results"); + } else if (output.isDirectory()) { + throw new IllegalArgumentException("Cannot use given output : it is a directory [" + output.getAbsolutePath() + "]"); + } else if (!output.exists() && !output.createNewFile()) { + throw new IllegalArgumentException("Cannot use given output : unable to create file [" + output.getAbsolutePath() + "]"); + } + if (status != null && !status.getFuture().isDone()) { + helper.printWarning("Script already running in background. Aborting."); + return; + } + try (Stream lines = Files.lines(file.toPath())) { + long count = lines.count(); + SshContext ctx = new SshContext(); + ctx.setBackground(true); + ctx.getPostProcessorsList().add(new PostProcessorObject(SavePostProcessor.SAVE, + Collections.singletonList(output.getAbsolutePath()))); + status = new ScriptStatus(executor().submit(() -> { + SSH_THREAD_CONTEXT.set(ctx); + try { + run(file); + } catch (IOException e) { + LOGGER.warn("Unable to run script command : {}", e.getMessage(), e); + } + }), output, count, ctx); + helper.print("Script from file starting un background. Please check results at " + output.getAbsolutePath() + "."); + if (!notInteractive) { + progress(status); + } + } + } + } else if (action == ScriptAction.stop) { + if (status != null && !status.getFuture().isDone()) { + status.getFuture().cancel(true); + } + printStatus(status); + } else { + // script status + if (status != null && !status.getFuture().isDone()) { + progress(status); + } else { + printStatus(status); + } + } + } + + private void printStatus(ScriptStatus status) { + if (status == null) { + helper.print("No script running in background."); + } else if (status.getFuture().isDone()) { + String doneOrCancelled = status.getFuture().isCancelled() ? "stopped" : "done"; + helper.print("Script " + doneOrCancelled + ". " + status.getCount() + " commands executed."); + } + } + + private void progress(ScriptStatus status) { + helper.interactive(Interactive.builder().input((StoppableInteractiveInput) (size, currentDelay) -> { + List lines = new ArrayList<>(); + lines.add("Script still running. " + status.getCount() + "/" + status.getTotal() + " " + + "commands executed so far."); + lines.add(helper.progress((int) status.getCount(), (int) status.getTotal())); + lines.add(SshShellHelper.INTERACTIVE_LONG_MESSAGE + "\n"); + return new InteractiveInputIO(status.getFuture().isDone() || status.getFuture().isCancelled(), + lines.stream().map(AttributedString::new).collect(Collectors.toList())); + + }).fullScreen(false).refreshDelay(1000).build()); + if (status.getFuture().isDone() || status.getFuture().isCancelled()) { + helper.print("Script done. " + status.getTotal() + " commands executed."); + } + } + + private void run(File file) throws IOException { + Reader reader = new FileReader(file); + try (FileInputProvider inputProvider = new FileInputProvider(reader, parser)) { + shell.run(inputProvider, () -> status != null && status.getFuture().isCancelled()); + } + } + + private ExecutorService executor() { + if (executor == null) { + executor = Executors.newSingleThreadExecutor(); + } + return executor; + } + + public enum ScriptAction { + execute, stop, status + } + + @Data + @AllArgsConstructor + public static class ScriptStatus { + + private Future future; + + private File result; + + private long total; + + private SshContext sshContext; + + public long getCount() { + return sshContext.getBackgroundCount(); + } + } +} diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/InteractiveInputIO.java b/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/InteractiveInputIO.java new file mode 100644 index 00000000..ed302146 --- /dev/null +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/InteractiveInputIO.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 François Onimus + * + * 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.github.fonimus.ssh.shell.interactive; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.jline.utils.AttributedString; + +import java.util.List; + +/** + * Interface to give to interactive command to provide lines + */ +@Data +@RequiredArgsConstructor +@AllArgsConstructor +public class InteractiveInputIO { + + private boolean stop; + + @NonNull + private List lines; +} diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/StoppableInteractiveInput.java b/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/StoppableInteractiveInput.java new file mode 100644 index 00000000..f825d2a0 --- /dev/null +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/interactive/StoppableInteractiveInput.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2020 François Onimus + * + * 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.github.fonimus.ssh.shell.interactive; + +import org.jline.terminal.Size; +import org.jline.utils.AttributedString; + +import java.util.List; + +/** + * Interface to give to interactive command to provide lines + */ +@FunctionalInterface +public interface StoppableInteractiveInput extends InteractiveInput { + + /** + * Get lines to write + * + * @param size terminal size + * @param currentDelay current refresh delay + * @return bean containing lines + */ + InteractiveInputIO getIO(Size size, long currentDelay); + + @Override + default List getLines(Size size, long currentDelay) { + return getIO(size, currentDelay).getLines(); + } +} diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandler.java b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandler.java index 58f69042..6884051c 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandler.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandler.java @@ -89,7 +89,13 @@ public void handleResult(Object result) { } } } - resultHandler.handleResult(obj); + if (ctx == null || !ctx.isBackground()) { + // do not display anything if is background script + resultHandler.handleResult(obj); + } + if (ctx != null && ctx.isBackground()) { + ctx.incrementBackgroundCount(); + } } private void printLogWarn(String warn) { diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java index 1f598922..246bf22a 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java @@ -26,6 +26,9 @@ import java.nio.file.Files; import java.util.List; +import static java.nio.file.StandardOpenOption.APPEND; +import static java.nio.file.StandardOpenOption.CREATE; + @Slf4j public class SavePostProcessor implements PostProcessor { @@ -51,12 +54,9 @@ public String process(String result, List parameters) throws PostProcess } File file = new File(path); try { - if (file.exists()) { - throw new PostProcessorException("File already exists: " + file.getAbsolutePath()); - } - String toWrite = result; - toWrite = toWrite.replaceAll("(\\x1b\\x5b|\\x9b)[\\x30-\\x3f]*[\\x20-\\x2f]*[\\x40-\\x7e]", ""); - Files.write(file.toPath(), toWrite.getBytes(StandardCharsets.UTF_8)); + String toWrite = + result.replaceAll("(\\x1b\\x5b|\\x9b)[\\x30-\\x3f]*[\\x20-\\x2f]*[\\x40-\\x7e]", "") + "\n"; + Files.write(file.toPath(), toWrite.getBytes(StandardCharsets.UTF_8), CREATE, APPEND); return "Result saved to file: " + file.getAbsolutePath(); } catch (IOException e) { LOGGER.debug("Unable to write to file: " + file.getAbsolutePath(), e); diff --git a/starter/src/test/java/com/github/fonimus/ssh/shell/ExtendedShellTest.java b/starter/src/test/java/com/github/fonimus/ssh/shell/ExtendedShellTest.java index 41feb4a3..e6602775 100644 --- a/starter/src/test/java/com/github/fonimus/ssh/shell/ExtendedShellTest.java +++ b/starter/src/test/java/com/github/fonimus/ssh/shell/ExtendedShellTest.java @@ -22,12 +22,12 @@ import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static com.github.fonimus.ssh.shell.SshShellCommandFactory.SSH_THREAD_CONTEXT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; class ExtendedShellTest { @@ -39,7 +39,7 @@ void evaluate() { // do nothing }, new ArrayList<>()); shell.evaluate(() -> "one two three"); - assertNull(SSH_THREAD_CONTEXT.get().getPostProcessorsList()); + assertEquals(Collections.emptyList(), SSH_THREAD_CONTEXT.get().getPostProcessorsList()); shell.evaluate(() -> "one two three | grep test > /tmp/file"); List postProcessors = SSH_THREAD_CONTEXT.get().getPostProcessorsList(); diff --git a/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/SavePostProcessorTest.java b/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/SavePostProcessorTest.java index 490a4bd3..6310ae7b 100644 --- a/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/SavePostProcessorTest.java +++ b/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/SavePostProcessorTest.java @@ -57,13 +57,7 @@ void process() throws Exception { assertThrows(PostProcessorException.class, () -> processor.process(TEST, Collections.singletonList( "target/not-existing-dir/file.txt"))) .getMessage().startsWith("Unable to write to file:")); - assertTrue(processor.process(TEST, Collections.singletonList("target/test.txt")).startsWith("Result saved to " + - "file:")); - assertTrue(assertThrows(PostProcessorException.class, () -> processor.process(TEST, - Collections.singletonList("target/test.txt"))).getMessage() - .startsWith("File already exists:")); - assertTrue(assertThrows(PostProcessorException.class, () -> processor.process(TEST, Arrays.asList("target" + - "/test.txt", "dont-care"))).getMessage() - .startsWith("File already exists:")); + assertTrue(processor.process(TEST, Arrays.asList("target/test.txt", "other param ignored")).startsWith( + "Result saved to file:")); } } diff --git a/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandlerTest.java b/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandlerTest.java index 3261d093..2fab7a63 100644 --- a/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandlerTest.java +++ b/starter/src/test/java/com/github/fonimus/ssh/shell/postprocess/TypePostProcessorResultHandlerTest.java @@ -64,9 +64,9 @@ void handleResultThrowable() { @Test void handleResultUnknownPostProcessor() { - SSH_THREAD_CONTEXT.get().setPostProcessorsList(Collections.singletonList( - new PostProcessorObject("unknown") - )); + SSH_THREAD_CONTEXT.get().getPostProcessorsList().add( + new PostProcessorObject("unknown" + )); rh.handleResult("result"); assertEquals(2, captor.getAllValues().size()); @@ -76,9 +76,9 @@ void handleResultUnknownPostProcessor() { @Test void handleResultWrongPostProcessorArgument() { - SSH_THREAD_CONTEXT.get().setPostProcessorsList(Collections.singletonList( - new PostProcessorObject("grep") - )); + SSH_THREAD_CONTEXT.get().getPostProcessorsList().add( + new PostProcessorObject("grep" + )); Object obj = new PostProcessorObject("test"); rh.handleResult(obj); @@ -89,8 +89,8 @@ void handleResultWrongPostProcessorArgument() { @Test void handleResultPostProcessorError() { - SSH_THREAD_CONTEXT.get().setPostProcessorsList(Collections.singletonList( - new PostProcessorObject("save")) + SSH_THREAD_CONTEXT.get().getPostProcessorsList().add( + new PostProcessorObject("save") ); rh.handleResult("result"); assertEquals(1, captor.getAllValues().size()); @@ -99,8 +99,8 @@ void handleResultPostProcessorError() { @Test void handleResultNominal() { - SSH_THREAD_CONTEXT.get().setPostProcessorsList(Collections.singletonList( - new PostProcessorObject("grep", Collections.singletonList("result"))) + SSH_THREAD_CONTEXT.get().getPostProcessorsList().add( + new PostProcessorObject("grep", Collections.singletonList("result")) ); rh.handleResult("result"); assertEquals(1, captor.getAllValues().size()); diff --git a/starter/src/test/resources/script.txt b/starter/src/test/resources/script.txt new file mode 100644 index 00000000..03e635bc --- /dev/null +++ b/starter/src/test/resources/script.txt @@ -0,0 +1,31 @@ +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 700 +wait 700 +wait 500 +wait 500 +wait 500 +wait 500 +wait 700 +wait 700 +wait 500 +wait 700 +wait 700 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 +wait 500 From 7132a5d8f9fb27bb12797fc1bead2abed4cb37e3 Mon Sep 17 00:00:00 2001 From: Francois Onimus Date: Sat, 24 Oct 2020 13:28:06 +0200 Subject: [PATCH 2/3] Add possibility to put script in background : add unit tests --- .../fonimus/ssh/shell/ExtendedShell.java | 11 +- .../ssh/shell/commands/ScriptCommand.java | 4 +- .../fonimus/ssh/shell/SshShellHelperTest.java | 12 +- .../ssh/shell/commands/ScriptCommandTest.java | 200 ++++++++++++++++++ 4 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 starter/src/test/java/com/github/fonimus/ssh/shell/commands/ScriptCommandTest.java diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java b/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java index 0dad5963..e29954c6 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/ExtendedShell.java @@ -28,7 +28,6 @@ import org.springframework.shell.ResultHandler; import org.springframework.shell.Shell; -import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -67,22 +66,22 @@ public ExtendedShell(ResultHandler resultHandler, List postProces @Override - public void run(InputProvider inputProvider) throws IOException { + public void run(InputProvider inputProvider) { run(inputProvider, () -> false); } @SuppressWarnings("unchecked") - public void run(InputProvider inputProvider, ShellNotifier shellNotifier) throws IOException { + public void run(InputProvider inputProvider, ShellNotifier shellNotifier) { Object result = null; // Handles ExitRequest thrown from Quit command while (!(result instanceof ExitRequest) && !shellNotifier.shouldStop()) { Input input; try { input = inputProvider.readInput(); + } catch (ExitRequest e) { + // Handles ExitRequest thrown from hitting CTRL-C + break; } catch (Exception e) { - if (e instanceof ExitRequest) { // Handles ExitRequest thrown from hitting CTRL-C - break; - } resultHandler.handleResult(e); continue; } diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java b/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java index 371ffd61..967a9c9a 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/commands/ScriptCommand.java @@ -106,7 +106,7 @@ public void script( } else { if (output == null) { throw new IllegalArgumentException("Cannot use background option without output option for " + - " commands results"); + "commands results"); } else if (output.isDirectory()) { throw new IllegalArgumentException("Cannot use given output : it is a directory [" + output.getAbsolutePath() + "]"); } else if (!output.exists() && !output.createNewFile()) { @@ -172,7 +172,7 @@ private void progress(ScriptStatus status) { }).fullScreen(false).refreshDelay(1000).build()); if (status.getFuture().isDone() || status.getFuture().isCancelled()) { - helper.print("Script done. " + status.getTotal() + " commands executed."); + helper.print("Script done. " + status.getCount() + " commands executed."); } } diff --git a/starter/src/test/java/com/github/fonimus/ssh/shell/SshShellHelperTest.java b/starter/src/test/java/com/github/fonimus/ssh/shell/SshShellHelperTest.java index 9750149b..63ed3d17 100644 --- a/starter/src/test/java/com/github/fonimus/ssh/shell/SshShellHelperTest.java +++ b/starter/src/test/java/com/github/fonimus/ssh/shell/SshShellHelperTest.java @@ -18,7 +18,9 @@ import com.github.fonimus.ssh.shell.interactive.Interactive; import com.github.fonimus.ssh.shell.interactive.InteractiveInput; +import com.github.fonimus.ssh.shell.interactive.InteractiveInputIO; import com.github.fonimus.ssh.shell.interactive.KeyBinding; +import com.github.fonimus.ssh.shell.interactive.StoppableInteractiveInput; import org.jline.reader.impl.completer.ArgumentCompleter; import org.jline.terminal.Size; import org.jline.utils.AttributedString; @@ -218,21 +220,19 @@ void interactive() throws Exception { }).build(); - InteractiveInput input = (size, currentDelay) -> { + InteractiveInput input = (StoppableInteractiveInput) (size, currentDelay) -> { count[0]++; - return Collections.singletonList(AttributedString.EMPTY); + return new InteractiveInputIO(false, Collections.singletonList(AttributedString.EMPTY)); }; - assertEquals(0, count[0]); + h.interactive(Interactive.builder().binding(binding).binding(failingBinding).input(input).build()); assertEquals(4, count[0]); h.interactive(Interactive.builder().input(input).fullScreen(false).build()); - assertEquals(5, count[0]); - h.interactive(input); - + h.interactive(Interactive.builder().input(input).fullScreen(true).build()); assertEquals(6, count[0]); } diff --git a/starter/src/test/java/com/github/fonimus/ssh/shell/commands/ScriptCommandTest.java b/starter/src/test/java/com/github/fonimus/ssh/shell/commands/ScriptCommandTest.java new file mode 100644 index 00000000..457fa767 --- /dev/null +++ b/starter/src/test/java/com/github/fonimus/ssh/shell/commands/ScriptCommandTest.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2020 François Onimus + * + * 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.github.fonimus.ssh.shell.commands; + +import com.github.fonimus.ssh.shell.ExtendedShell; +import com.github.fonimus.ssh.shell.SshShellHelper; +import com.github.fonimus.ssh.shell.interactive.Interactive; +import org.jline.reader.Parser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.shell.jline.FileInputProvider; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class ScriptCommandTest { + + private static final File FILE = new File("src/test/resources/script.txt"); + private ExtendedShell shell; + private Parser parser; + private SshShellHelper sshHelper; + private ScriptCommand cmd; + private File output; + + @BeforeEach + void setUp() { + shell = mock(ExtendedShell.class); + parser = mock(Parser.class); + sshHelper = mock(SshShellHelper.class); + cmd = new ScriptCommand(shell, parser, sshHelper); + output = new File("target/result-" + UUID.randomUUID().toString() + ".txt"); + } + + @Test + void testNoBackground() throws Exception { + cmd.script(FILE, null, false, ScriptCommand.ScriptAction.execute, true); + verify(shell, times(1)).run(any(FileInputProvider.class), any()); + } + + @Test + void testBackgroundExecuteFileNull() { + assertThrows(IllegalArgumentException.class, () -> cmd.script(null, null, true, + ScriptCommand.ScriptAction.execute, true)); + verify(shell, never()).run(any(FileInputProvider.class), any()); + } + + @Test + void testBackgroundExecuteNoOutput() { + assertThrows(IllegalArgumentException.class, () -> cmd.script(FILE, null, true, + ScriptCommand.ScriptAction.execute, true)); + verify(shell, never()).run(any(FileInputProvider.class), any()); + } + + @Test + void testBackgroundExecuteInvalidOutput() { + assertThrows(IllegalArgumentException.class, () -> cmd.script(FILE, new File("target"), true, + ScriptCommand.ScriptAction.execute, true)); + verify(shell, never()).run(any(FileInputProvider.class), any()); + } + + @Test + void testBackgroundExecuteInvalidOutputFile() { + assertThrows(IOException.class, () -> cmd.script(FILE, new File(UUID.randomUUID().toString(), + UUID.randomUUID().toString()), true, ScriptCommand.ScriptAction.execute, true)); + verify(shell, never()).run(any(FileInputProvider.class), any()); + } + + @Test + void testBackgroundExecute() throws Exception { + launchShellOk(); + } + + @Test + void testBackgroundExecuteInteractive() throws Exception { + launchShellOk(false); + } + + @Test + void testBackgroundExecuteAlreadyRunning() throws Exception { + mockLongProcess(); + + launchShellOk(); + + launch(ScriptCommand.ScriptAction.execute); + // only one run : previous one + verify(shell, times(1)).run(any(FileInputProvider.class), any()); + verify(sshHelper, times(1)).printWarning(eq("Script already running in background. Aborting.")); + } + + @Test + void testBackgroundStatusNotRunning() throws Exception { + launch(ScriptCommand.ScriptAction.status); + verify(shell, never()).run(any(FileInputProvider.class), any()); + verify(sshHelper, times(1)).print(eq("No script running in background.")); + } + + @Test + void testBackgroundStatusDone() throws Exception { + launchShellOk(); + launch(ScriptCommand.ScriptAction.status); + verify(sshHelper, times(1)).print(eq("Script done. 0 commands executed.")); + } + + @Test + void testBackgroundStatusRunning() throws Exception { + mockLongProcess(); + + launchShellOk(); + + launch(ScriptCommand.ScriptAction.status); + // only one run : previous one + verify(shell, times(1)).run(any(FileInputProvider.class), any()); + // interactive called : it means progress is shown + verify(sshHelper, times(1)).interactive(any(Interactive.class)); + } + + @Test + void testBackgroundStopNotRunning() throws Exception { + launch(ScriptCommand.ScriptAction.stop); + verify(shell, never()).run(any(FileInputProvider.class), any()); + verify(sshHelper, times(1)).print(eq("No script running in background.")); + } + + @Test + void testBackgroundStopRunning() throws Exception { + mockLongProcess(); + + launchShellOk(); + + launch(ScriptCommand.ScriptAction.stop); + // only one run : previous one + verify(shell, times(1)).run(any(FileInputProvider.class), any()); + verify(sshHelper, times(1)).print(eq("Script stopped. 0 commands executed.")); + } + + @Test + void testBackgroundStopDone() throws Exception { + launchShellOk(); + launch(ScriptCommand.ScriptAction.stop); + verify(sshHelper, times(1)).print(eq("Script done. 0 commands executed.")); + } + + private void launchShellOk() throws Exception { + launchShellOk(true); + } + + private void launchShellOk(boolean notInteractive) throws Exception { + launch(ScriptCommand.ScriptAction.execute, notInteractive); + Thread.sleep(200); + verify(shell, times(1)).run(any(FileInputProvider.class), any()); + verify(sshHelper, times(1)).print(eq("Script from file starting un background. Please check results at " + output.getAbsolutePath() + ".")); + } + + private void launch(ScriptCommand.ScriptAction action) throws Exception { + launch(action, true); + } + + private void launch(ScriptCommand.ScriptAction action, boolean notInteractive) throws Exception { + reset(sshHelper); + cmd.script(FILE, output, true, action, notInteractive); + } + + private void mockLongProcess() { + doAnswer(invocationOnMock -> { + System.err.println("executing commands..."); + Thread.sleep(1000); + return null; + }).when(shell).run(any(), any()); + doAnswer(invocationOnMock -> { + System.err.println("executing commands..."); + Thread.sleep(1000); + return null; + }).when(sshHelper).interactive(any(Interactive.class)); + } +} From 03c39d996dc8c38cfbb6a5beafbed4f36558d630 Mon Sep 17 00:00:00 2001 From: Francois Onimus Date: Sat, 24 Oct 2020 17:14:59 +0200 Subject: [PATCH 3/3] Add possibility to put script in background : update save post processor to serialize every objects --- .../provided/SavePostProcessor.java | 21 +++++++++++++++---- starter/src/test/resources/script.txt | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java index 246bf22a..83f22017 100644 --- a/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java +++ b/starter/src/main/java/com/github/fonimus/ssh/shell/postprocess/provided/SavePostProcessor.java @@ -31,17 +31,19 @@ @Slf4j public class SavePostProcessor - implements PostProcessor { + implements PostProcessor { public static final String SAVE = "save"; + private static final String REPLACE_REGEX = "(\\x1b\\x5b|\\x9b)[\\x30-\\x3f]*[\\x20-\\x2f]*[\\x40-\\x7e]"; + @Override public String getName() { return SAVE; } @Override - public String process(String result, List parameters) throws PostProcessorException { + public String process(Object result, List parameters) throws PostProcessorException { if (parameters == null || parameters.isEmpty()) { throw new PostProcessorException("Cannot save without file path !"); } else { @@ -54,8 +56,7 @@ public String process(String result, List parameters) throws PostProcess } File file = new File(path); try { - String toWrite = - result.replaceAll("(\\x1b\\x5b|\\x9b)[\\x30-\\x3f]*[\\x20-\\x2f]*[\\x40-\\x7e]", "") + "\n"; + String toWrite = string(result).replaceAll(REPLACE_REGEX, "") + "\n"; Files.write(file.toPath(), toWrite.getBytes(StandardCharsets.UTF_8), CREATE, APPEND); return "Result saved to file: " + file.getAbsolutePath(); } catch (IOException e) { @@ -64,4 +65,16 @@ public String process(String result, List parameters) throws PostProcess } } } + + private String string(Object result) { + if (result instanceof String) { + return (String) result; + } else if (result instanceof Throwable) { + return ((Throwable) result).getClass().getName() + ": " + ((Throwable) result).getMessage(); + } else if (result != null) { + return result.toString(); + } else { + return ""; + } + } } diff --git a/starter/src/test/resources/script.txt b/starter/src/test/resources/script.txt index 03e635bc..677ef37d 100644 --- a/starter/src/test/resources/script.txt +++ b/starter/src/test/resources/script.txt @@ -29,3 +29,5 @@ wait 500 wait 500 wait 500 wait 500 +xxx +yyy