]> git.argeo.org Git - lgpl/argeo-commons.git/blob - org.argeo.cms.jshell/src/org/argeo/cms/jshell/JShellClient.java
Merge tag 'v2.3.27' into testing
[lgpl/argeo-commons.git] / org.argeo.cms.jshell / src / org / argeo / cms / jshell / JShellClient.java
1 package org.argeo.cms.jshell;
2
3 import static java.lang.System.Logger.Level.ERROR;
4 import static java.lang.System.Logger.Level.TRACE;
5 import static java.net.StandardProtocolFamily.UNIX;
6 import static java.nio.charset.StandardCharsets.UTF_8;
7
8 import java.io.BufferedReader;
9 import java.io.File;
10 import java.io.IOException;
11 import java.io.InputStream;
12 import java.io.InputStreamReader;
13 import java.io.OutputStream;
14 import java.io.PrintStream;
15 import java.lang.System.Logger;
16 import java.lang.management.ManagementFactory;
17 import java.net.StandardSocketOptions;
18 import java.net.UnixDomainSocketAddress;
19 import java.nio.ByteBuffer;
20 import java.nio.channels.AsynchronousCloseException;
21 import java.nio.channels.Channels;
22 import java.nio.channels.ClosedByInterruptException;
23 import java.nio.channels.ReadableByteChannel;
24 import java.nio.channels.SocketChannel;
25 import java.nio.channels.WritableByteChannel;
26 import java.nio.file.Files;
27 import java.nio.file.Path;
28 import java.nio.file.Paths;
29 import java.util.ArrayList;
30 import java.util.HashMap;
31 import java.util.List;
32 import java.util.Map;
33 import java.util.UUID;
34
35 /** A JShell client to a local CMS node. */
36 public class JShellClient {
37 private final static Logger logger = System.getLogger(JShellClient.class.getName());
38
39 public final static String STD = "std";
40 public final static String CTL = "ctl";
41
42 public final static String JSH = "jsh";
43 public final static String JTERM = "jterm";
44
45 private static String sttyExec = "/usr/bin/stty";
46
47 /** Benchmark based on uptime. */
48 private static boolean benchmark = false;
49
50 /**
51 * The real path (following symbolic links) to the directory were to create
52 * sessions.
53 */
54 private Path localBase;
55
56 /** The symbolic name of the bundle from which to run. */
57 private String symbolicName;
58
59 /** The script to run. */
60 private Path script;
61 /** Additional arguments of the script */
62 private List<String> scriptArgs;
63
64 private String ttyConfig;
65 private boolean terminal;
66
67 /** Workaround to be able to test in Eclipse console */
68 private boolean inEclipse = false;
69
70 public JShellClient(Path targetStateDirectory, String symbolicName, Path script, List<String> scriptArgs) {
71 try {
72 this.terminal = System.console() != null && script == null;
73 if (inEclipse && script == null)
74 terminal = true;
75 if (terminal) {
76 localBase = targetStateDirectory.resolve(JTERM);
77 } else {
78 localBase = targetStateDirectory.resolve(JSH);
79 }
80 if (Files.isSymbolicLink(localBase)) {
81 localBase = localBase.toRealPath();
82 }
83 this.symbolicName = symbolicName;
84 this.script = script;
85 this.scriptArgs = scriptArgs == null ? new ArrayList<>() : scriptArgs;
86 } catch (IOException e) {
87 throw new IllegalStateException("Cannot initialise client", e);
88 }
89 }
90
91 public void run() {
92 try {
93 if (terminal)
94 toRawTerminal();
95 SocketPipeSource std = new SocketPipeSource(STD, script != null);
96 std.setInputStream(System.in);
97 std.setOutputStream(System.out);
98
99 SocketPipeSource ctl = new SocketPipeSource(CTL, false);
100 ctl.setOutputStream(System.err);
101
102 Runtime.getRuntime().addShutdownHook(new Thread(() -> {
103 // System.out.println("\nShutting down...");
104 toOriginalTerminal();
105 std.shutdown();
106 ctl.shutdown();
107 }, "Shut down JShell client"));
108
109 Path bundleSnDir = localBase.resolve(symbolicName);
110 if (!Files.exists(bundleSnDir))
111 Files.createDirectory(bundleSnDir);
112 UUID uuid = UUID.randomUUID();
113 Path sessionDir = bundleSnDir.resolve(uuid.toString());
114
115 // creating the directory will trigger opening of the session on server side
116 Files.createDirectory(sessionDir);
117
118 Path stdPath = sessionDir.resolve(JShellClient.STD);
119 Path ctlPath = sessionDir.resolve(JShellClient.CTL);
120
121 while (!(Files.exists(stdPath) && Files.exists(ctlPath))) {
122 // TODO timeout
123 try {
124 Thread.sleep(1);
125 } catch (InterruptedException e) {
126 // silent
127 }
128 }
129
130 UnixDomainSocketAddress stdSocketAddress = UnixDomainSocketAddress.of(stdPath.toRealPath());
131 UnixDomainSocketAddress ctlSocketAddress = UnixDomainSocketAddress.of(ctlPath.toRealPath());
132
133 try (SocketChannel stdChannel = SocketChannel.open(UNIX);
134 SocketChannel ctlChannel = SocketChannel.open(UNIX);) {
135 ctlChannel.connect(ctlSocketAddress);
136 ctl.process(ctlChannel);
137 if (script != null) {
138 new ScriptThread(ctlChannel).start();
139 }
140 stdChannel.connect(stdSocketAddress);
141 std.process(stdChannel);
142
143 while (!std.isCompleted() && !ctl.isCompleted()) {
144 // isCompleted() will block
145 }
146 }
147 if (benchmark)
148 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
149 std.shutdown();
150 ctl.shutdown();
151 } catch (IOException e) {
152 e.printStackTrace();
153 } finally {
154 toOriginalTerminal();
155 }
156
157 }
158
159 public static void main(String[] args) {
160 try {
161 if (benchmark)
162 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
163 List<String> plainArgs = new ArrayList<>();
164 Map<String, List<String>> options = new HashMap<>();
165 String currentOption = null;
166 for (int i = 0; i < args.length; i++) {
167 if (args[i].startsWith("-")) {
168 currentOption = args[i];
169 if ("-h".equals(currentOption) || "--help".equals(currentOption)) {
170 printHelp(System.out);
171 return;
172 }
173 if (!options.containsKey(currentOption))
174 options.put(currentOption, new ArrayList<>());
175 i++;
176 options.get(currentOption).add(args[i]);
177 } else {
178 plainArgs.add(args[i]);
179 }
180 }
181
182 List<String> dir = opt(options, "-d", "--sockets-dir");
183 if (dir.size() > 1)
184 throw new IllegalArgumentException("Only one run directory can be specified");
185 Path targetStateDirectory;
186 if (dir.isEmpty())
187 targetStateDirectory = Paths.get(System.getProperty("user.dir"));
188 else {
189 targetStateDirectory = Paths.get(dir.get(0));
190 if (!Files.exists(targetStateDirectory)) {
191 // we assume argument is the application id
192 targetStateDirectory = getRunDir().resolve(dir.get(0));
193 }
194 }
195
196 List<String> bundle = opt(options, "-b", "--bundle");
197 if (bundle.size() > 1)
198 throw new IllegalArgumentException("Only one bundle can be specified");
199 String symbolicName = bundle.isEmpty() ? "org.argeo.cms.cli" : bundle.get(0);
200
201 Path script = plainArgs.isEmpty() ? null : Paths.get(plainArgs.get(0));
202 List<String> scriptArgs = new ArrayList<>();
203 for (int i = 1; i < plainArgs.size(); i++)
204 scriptArgs.add(plainArgs.get(i));
205
206 JShellClient client = new JShellClient(targetStateDirectory, symbolicName, script, scriptArgs);
207 client.run();
208 } catch (Exception e) {
209 e.printStackTrace();
210 printHelp(System.err);
211 }
212 }
213
214 /** Guaranteed to return a non-null list (which may be empty). */
215 private static List<String> opt(Map<String, List<String>> options, String shortOpt, String longOpt) {
216 List<String> res = new ArrayList<>();
217 if (options.get(shortOpt) != null)
218 res.addAll(options.get(shortOpt));
219 if (options.get(longOpt) != null)
220 res.addAll(options.get(longOpt));
221 return res;
222 }
223
224 public static void printHelp(PrintStream out) {
225 out.println("Start a JShell terminal or execute a JShell script in a local Argeo CMS instance");
226 out.println("Usage: jshc -d <sockets directory> -b <bundle> [JShell script] [script arguments...]");
227 out.println(" -d, --sockets-dir app directory with UNIX sockets (default to current dir)");
228 out.println(" -b, --bundle bundle to activate and use as context (default to org.argeo.cms.cli)");
229 out.println(" -h, --help this help message");
230 }
231
232 // Copied from org.argeo.cms.util.OS
233 private static Path getRunDir() {
234 Path runDir;
235 String xdgRunDir = System.getenv("XDG_RUNTIME_DIR");
236 if (xdgRunDir != null) {
237 // TODO support multiple names
238 runDir = Paths.get(xdgRunDir);
239 } else {
240 String username = System.getProperty("user.name");
241 if (username.equals("root")) {
242 runDir = Paths.get("/run");
243 } else {
244 Path homeDir = Paths.get(System.getProperty("user.home"));
245 if (!Files.isWritable(homeDir)) {
246 // typically, dameon's home (/usr/sbin) is not writable
247 runDir = Paths.get("/tmp/" + username + "/run");
248 } else {
249 runDir = homeDir.resolve(".cache/argeo");
250 }
251 }
252 }
253 return runDir;
254 }
255
256 /*
257 * TERMINAL
258 */
259 /** Set the terminal to raw mode. */
260 protected synchronized void toRawTerminal() {
261 boolean isWindows = File.separatorChar == '\\';
262 if (isWindows)
263 return;
264 if (inEclipse)
265 return;
266 // save current configuration
267 ttyConfig = stty("-g");
268 if (ttyConfig == null)
269 return;
270 ttyConfig.trim();
271 // set the console to be character-buffered instead of line-buffered
272 stty("-icanon min 1");
273 // disable character echoing
274 stty("-echo");
275 }
276
277 /** Restore original terminal configuration. */
278 protected synchronized void toOriginalTerminal() {
279 if (ttyConfig == null)
280 return;
281 try {
282 stty(ttyConfig);
283 } catch (Exception e) {
284 e.printStackTrace();
285 }
286 ttyConfig = null;
287 }
288
289 /**
290 * Execute the stty command with the specified arguments against the current
291 * active terminal.
292 */
293 protected String stty(String args) {
294 List<String> cmd = new ArrayList<>();
295 cmd.add("/bin/sh");
296 cmd.add("-c");
297 cmd.add(sttyExec + " " + args + " < /dev/tty");
298
299 logger.log(TRACE, () -> cmd.toString());
300
301 try {
302 ProcessBuilder pb = new ProcessBuilder(cmd);
303 Process p = pb.start();
304 String firstLine = new BufferedReader(new InputStreamReader(p.getInputStream())).readLine();
305 p.waitFor();
306 logger.log(TRACE, () -> firstLine);
307 return firstLine;
308 } catch (IOException | InterruptedException e) {
309 e.printStackTrace();
310 return null;
311 }
312 }
313
314 /*
315 * SCRIPT
316 */
317 private class ScriptThread extends Thread {
318 private SocketChannel channel;
319
320 public ScriptThread(SocketChannel channel) {
321 super("JShell script writer");
322 this.channel = channel;
323 }
324
325 @Override
326 public void run() {
327 try {
328 if (benchmark)
329 System.err.println(ManagementFactory.getRuntimeMXBean().getUptime());
330 StringBuilder sb = new StringBuilder();
331 if (!scriptArgs.isEmpty()) {
332 // additional arguments as $1, $2, etc.
333 for (String arg : scriptArgs)
334 sb.append('\"').append(arg).append('\"').append(";\n");
335 }
336 if (sb.length() > 0)
337 writeLine(sb);
338
339 try (BufferedReader reader = Files.newBufferedReader(script)) {
340 String line;
341 lines: while ((line = reader.readLine()) != null) {
342 if (line.startsWith("#"))
343 continue lines;
344 writeLine(line);
345 }
346 }
347
348 // exit
349 if (channel.isConnected())
350 writeLine("/exit");
351 } catch (IOException e) {
352 logger.log(ERROR, "Cannot execute " + script, e);
353 }
354 }
355
356 /** Not optimal, but performance is not critical here. */
357 private void writeLine(Object obj) throws IOException {
358 channel.write(ByteBuffer.wrap((obj + "\n").getBytes(UTF_8)));
359 }
360 }
361 }
362
363 /** Pipe streams to a channel. */
364 class SocketPipeSource {
365 private ReadableByteChannel inChannel;
366 private WritableByteChannel outChannel;
367
368 private Thread readThread;
369 private Thread forwardThread;
370
371 private int inBufferSize = 1;
372 private int outBufferSize = 1024;
373
374 private final String id;
375 private final boolean batch;
376
377 private boolean completed = false;
378
379 public SocketPipeSource(String id, boolean batch) {
380 this.id = id;
381 this.batch = batch;
382 }
383
384 public void process(SocketChannel channel) throws IOException {
385 if (batch) {
386 Integer socketRcvBuf = channel.getOption(StandardSocketOptions.SO_RCVBUF);
387 inBufferSize = socketRcvBuf;
388 outBufferSize = socketRcvBuf;
389 }
390
391 readThread = new Thread(() -> {
392
393 try {
394 ByteBuffer buffer = ByteBuffer.allocate(outBufferSize);
395 while (true) {
396 if (channel.read(buffer) < 0)
397 break;
398 buffer.flip();
399 outChannel.write(buffer);
400 buffer.rewind();
401 }
402 } catch (ClosedByInterruptException e) {
403 // silent
404 } catch (AsynchronousCloseException e) {
405 // silent
406 } catch (IOException e) {
407 e.printStackTrace();
408 }
409 markCompleted();
410 }, "JShell read " + id);
411 readThread.start();
412
413 // TODO make it smarter than a 1 byte buffer
414 // we should recognize control characters
415 // e.g ^C
416 // int c = System.in.read();
417 // if (c == 0x1B) {
418 // break;
419 // }
420
421 if (inChannel != null) {
422 forwardThread = new Thread(() -> {
423 try {
424 ByteBuffer buffer = ByteBuffer.allocate(inBufferSize);
425 while (channel.isConnected()) {
426 if (inChannel.read(buffer) < 0) {
427 System.err.println("in EOF");
428 channel.shutdownOutput();
429 break;
430 }
431 // int b = (int) buffer.get(0);
432 // if (b == 0x1B) {
433 // System.out.println("Ctrl+C");
434 // }
435
436 buffer.flip();
437 channel.write(buffer);
438 buffer.rewind();
439 }
440 } catch (IOException e) {
441 e.printStackTrace();
442 }
443 }, "JShell write " + id);
444 forwardThread.setDaemon(true);
445 forwardThread.start();
446 // end
447 // TODO make it more robust
448 // we want to be asynchronous when read only
449 // try {
450 // // TODO add timeout
451 // readThread.join();
452 // } catch (InterruptedException e) {
453 // e.printStackTrace();
454 // }
455
456 }
457 }
458
459 public synchronized boolean isCompleted() {
460 if (!completed)
461 try {
462 wait();
463 } catch (InterruptedException e) {
464 // silent
465 }
466 return completed;
467 }
468
469 protected synchronized void markCompleted() {
470 completed = true;
471 notifyAll();
472 }
473
474 public void shutdown() {
475 if (inChannel != null)
476 try {
477 inChannel.close();
478 } catch (IOException e) {
479 e.printStackTrace();
480 }
481 try {
482 outChannel.close();
483 } catch (IOException e) {
484 e.printStackTrace();
485 }
486 // if (inChannel != null)
487 // forwardThread.interrupt();
488 // readThread.interrupt();
489 }
490
491 public void setInputStream(InputStream in) {
492 inChannel = Channels.newChannel(in);
493 }
494
495 public void setOutputStream(OutputStream out) {
496 outChannel = Channels.newChannel(out);
497 }
498 }