From: Mathieu Baudier Date: Tue, 2 May 2023 10:06:56 +0000 (+0200) Subject: Introduce CMS JShell X-Git-Tag: v2.3.16~13 X-Git-Url: http://git.argeo.org/?a=commitdiff_plain;h=2c5da70747629282585d5515720dcb1515a0011c;p=lgpl%2Fargeo-commons.git Introduce CMS JShell --- diff --git a/Makefile b/Makefile index 74920833f..3920b644a 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ org.argeo.cms.ee \ org.argeo.cms.lib.jetty \ org.argeo.cms.lib.dbus \ org.argeo.cms.lib.sshd \ +org.argeo.cms.jshell \ org.argeo.cms.cli \ osgi/equinox/org.argeo.cms.lib.equinox \ swt/org.argeo.swt.minidesktop \ diff --git a/org.argeo.api.cms/src/org/argeo/api/cms/CmsState.java b/org.argeo.api.cms/src/org/argeo/api/cms/CmsState.java index 181e4b9c6..8703b6b62 100644 --- a/org.argeo.api.cms/src/org/argeo/api/cms/CmsState.java +++ b/org.argeo.api.cms/src/org/argeo/api/cms/CmsState.java @@ -21,4 +21,6 @@ public interface CmsState { List getDeployProperties(String property); Path getDataPath(String relativePath); + + Path getStatePath(String relativePath); } diff --git a/org.argeo.cms.jshell/.classpath b/org.argeo.cms.jshell/.classpath new file mode 100644 index 000000000..81fe078c2 --- /dev/null +++ b/org.argeo.cms.jshell/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/org.argeo.cms.jshell/.project b/org.argeo.cms.jshell/.project new file mode 100644 index 000000000..99b8eeeb1 --- /dev/null +++ b/org.argeo.cms.jshell/.project @@ -0,0 +1,33 @@ + + + org.argeo.cms.jshell + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + org.eclipse.pde.ds.core.builder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/org.argeo.cms.jshell/OSGI-INF/cmsJShell.xml b/org.argeo.cms.jshell/OSGI-INF/cmsJShell.xml new file mode 100644 index 000000000..05f74a61e --- /dev/null +++ b/org.argeo.cms.jshell/OSGI-INF/cmsJShell.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/org.argeo.cms.jshell/bnd.bnd b/org.argeo.cms.jshell/bnd.bnd new file mode 100644 index 000000000..e575175a8 --- /dev/null +++ b/org.argeo.cms.jshell/bnd.bnd @@ -0,0 +1,6 @@ +Import-Package: \ +org.osgi.framework.namespace, \ +* + +Service-Component:\ +OSGI-INF/cmsJShell.xml diff --git a/org.argeo.cms.jshell/build.properties b/org.argeo.cms.jshell/build.properties new file mode 100644 index 000000000..9bd0cca27 --- /dev/null +++ b/org.argeo.cms.jshell/build.properties @@ -0,0 +1,5 @@ +bin.includes = META-INF/,\ + .,\ + OSGI-INF/cmsJShell.xml +source.. = src/ +output.. = bin/ diff --git a/org.argeo.cms.jshell/src/META-INF/services/jdk.jshell.spi.ExecutionControlProvider b/org.argeo.cms.jshell/src/META-INF/services/jdk.jshell.spi.ExecutionControlProvider new file mode 100644 index 000000000..d21f5baaf --- /dev/null +++ b/org.argeo.cms.jshell/src/META-INF/services/jdk.jshell.spi.ExecutionControlProvider @@ -0,0 +1 @@ +org.argeo.internal.cms.jshell.osgi.OsgiExecutionControlProvider \ No newline at end of file diff --git a/org.argeo.cms.jshell/src/org/argeo/cms/jshell/CmsJShell.java b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/CmsJShell.java new file mode 100644 index 000000000..aec3da1f6 --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/CmsJShell.java @@ -0,0 +1,278 @@ +package org.argeo.cms.jshell; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.argeo.api.cms.CmsLog; +import org.argeo.api.cms.CmsState; +import org.argeo.api.uuid.UuidFactory; +import org.argeo.cms.util.OS; + +public class CmsJShell { + private final static CmsLog log = CmsLog.getLog(CmsJShell.class); + static ClassLoader loader = CmsJShell.class.getClassLoader(); + + public static UuidFactory uuidFactory = null; + + private CmsState cmsState; + + private Map localSessions = new HashMap<>(); + + private Path localBase; + private Path linkedDir; + + public void start() throws Exception { + + // Path localBase = cmsState.getStatePath("org.argeo.cms.jshell/local"); + UUID stateUuid = cmsState.getUuid(); + + // TODO centralise state run dir + Path stateRunDir = OS.getRunDir().resolve(stateUuid.toString()); + localBase = stateRunDir.resolve("jsh"); + Files.createDirectories(localBase); + + linkedDir = Files.createSymbolicLink(cmsState.getStatePath("jsh"), localBase); + + log.info("Local JShell on " + localBase + ", linked to " + linkedDir); + + new Thread(() -> { + try { + WatchService watchService = FileSystems.getDefault().newWatchService(); + + localBase.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE); + + WatchKey key; + while ((key = watchService.take()) != null) { + events: for (WatchEvent event : key.pollEvents()) { +// System.out.println("Event kind:" + event.kind() + ". File affected: " + event.context() + "."); + Path path = localBase.resolve((Path) event.context()); + // sessions + if (Files.isSameFile(localBase, path.getParent())) { + if (StandardWatchEventKinds.ENTRY_CREATE.equals(event.kind())) { + if (!Files.isDirectory(path)) { + log.warn("Ignoring " + path + " as it is not a directory"); + continue events; + } + try { + UUID.fromString(path.getFileName().toString()); + } catch (IllegalArgumentException e) { + log.warn("Ignoring " + path + " as it is not named as UUID"); + continue events; + } + + LocalJShellSession localSession = new LocalJShellSession(path); + localSessions.put(path, localSession); + } else if (StandardWatchEventKinds.ENTRY_DELETE.equals(event.kind())) { + // TODO clean up session + LocalJShellSession localSession = localSessions.remove(path); + localSession.cleanUp(); + } + } else { +// if (StandardWatchEventKinds.ENTRY_CREATE.equals(event.kind())) { +// Path sessionDir = path.getParent(); +// LocalSession session = localSessions.get(sessionDir); +// if (session == null) { +// sessions: for (Path p : localSessions.keySet()) { +// if (Files.isSameFile(sessionDir, p)) { +// session = localSessions.get(p); +// break sessions; +// } +// } +// } +// if (session == null) { +// log.warn("Ignoring " + path + " as its parent is not a registered session"); +// continue events; +// } +// session.addChild(path); +// } + + } + } + key.reset(); + } + } catch (IOException | InterruptedException e) { + e.printStackTrace(); + } + }, "JSChell local sessions watcher").start(); + + // thread context class loader should be where the service is defined +// Thread.currentThread().setContextClassLoader(loader); +// JavaShellToolBuilder builder = JavaShellToolBuilder.builder(); +// +// builder.start("--execution", "osgi:bundle(org.argeo.cms.jshell)"); + + } + +// public void startX(BundleContext bc) { +// uuidFactory = new NoOpUuidFactory(); +// +// List locations = new ArrayList<>(); +// for (Bundle bundle : bc.getBundles()) { +// locations.add(bundle.getLocation()); +//// System.out.println(bundle.getLocation()); +// } +// +// CmsState cmsState = (CmsState) bc.getService(bc.getServiceReference("org.argeo.api.cms.CmsState")); +// System.out.println(cmsState.getDeployProperties(CmsDeployProperty.HTTP_PORT.getProperty())); +// System.out.println(cmsState.getUuid()); +// +// ExecutionControlProvider executionControlProvider = new ExecutionControlProvider() { +// @Override +// public String name() { +// return "name"; +// } +// +// @Override +// public ExecutionControl generate(ExecutionEnv ee, Map map) throws Throwable { +// return new LocalExecutionControl(new WrappingLoaderDelegate(loader)); +//// Thread.currentThread().setContextClassLoader(loader); +//// return new DirectExecutionControl(); +// } +// }; +// +//// Thread.currentThread().setContextClassLoader(loader); +// +// try (JShell js = JShell.builder().executionEngine(executionControlProvider, null).build()) { +// js.addToClasspath("/home/mbaudier/dev/git/unstable/output/a2/org.argeo.cms/org.argeo.api.cms.2.3.jar"); +// js.addToClasspath("/home/mbaudier/dev/git/unstable/output/a2/org.argeo.cms/org.argeo.cms.2.3.jar"); +// js.addToClasspath( +// "/home/mbaudier/dev/git/unstable/output/a2/osgi/equinox/org.argeo.tp.osgi/org.eclipse.osgi.3.18.jar"); +//// do { +// System.out.print("Enter some Java code: "); +// // String input = console.readLine(); +// String imports = """ +// import org.argeo.api.cms.*; +// import org.argeo.cms.*; +// import org.argeo.slc.jshell.*; +// """; +// js.eval(imports); +// String input = """ +// var bc = org.osgi.framework.FrameworkUtil.getBundle(org.argeo.cms.CmsDeployProperty.class).getBundleContext(); +// var cmsState =(org.argeo.api.cms.CmsState) bc.getService(bc.getServiceReference("org.argeo.api.cms.CmsState")); +// System.out.println(cmsState.getDeployProperties(org.argeo.cms.CmsDeployProperty.HTTP_PORT.getProperty())); +// cmsState.getUuid(); +// """; +//// if (input == null) { +//// break; +//// } +// +// input.lines().forEach((l) -> { +// +// List events = js.eval(l); +// for (SnippetEvent e : events) { +// StringBuilder sb = new StringBuilder(); +// if (e.causeSnippet() == null) { +// // We have a snippet creation event +// switch (e.status()) { +// case VALID: +// sb.append("Successful "); +// break; +// case RECOVERABLE_DEFINED: +// sb.append("With unresolved references "); +// break; +// case RECOVERABLE_NOT_DEFINED: +// sb.append("Possibly reparable, failed "); +// break; +// case REJECTED: +// sb.append("Failed "); +// break; +// } +// if (e.previousStatus() == Status.NONEXISTENT) { +// sb.append("addition"); +// } else { +// sb.append("modification"); +// } +// sb.append(" of "); +// sb.append(e.snippet().source()); +// System.out.println(sb); +// if (e.value() != null) { +// System.out.printf("Value is: %s\n", e.value()); +// } +// System.out.flush(); +// } +// } +// }); +//// } while (true); +// } +// } + + public void stop() { + try { + Files.delete(linkedDir); + } catch (IOException e) { + log.error("Cannot remove " + linkedDir); + } + } + + public void setCmsState(CmsState cmsState) { + this.cmsState = cmsState; + } + +// public static void main(String[] args) throws Exception { +// Pipe inPipe = Pipe.open(); +// Pipe outPipe = Pipe.open(); +// +// InputStream in = Channels.newInputStream(inPipe.source()); +// OutputStream out = Channels.newOutputStream(outPipe.sink()); +// JavaShellToolBuilder builder = JavaShellToolBuilder.builder(); +// builder.in(in, null); +// builder.interactiveTerminal(true); +// builder.out(new PrintStream(out)); +// +// UnixDomainSocketAddress ioSocketAddress = JShellClient.ioSocketAddress(); +// Files.deleteIfExists(ioSocketAddress.getPath()); +// +// try (ServerSocketChannel serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { +// serverChannel.bind(ioSocketAddress); +// +// try (SocketChannel channel = serverChannel.accept()) { +// new Thread(() -> { +// +// try { +// ByteBuffer buffer = ByteBuffer.allocate(1024); +// while (true) { +// if (channel.read(buffer) < 0) +// break; +// buffer.flip(); +// inPipe.sink().write(buffer); +// buffer.rewind(); +// } +// } catch (IOException e) { +// e.printStackTrace(); +// } +// }, "Read in").start(); +// +// new Thread(() -> { +// +// try { +// ByteBuffer buffer = ByteBuffer.allocate(1024); +// while (true) { +// if (outPipe.source().read(buffer) < 0) +// break; +// buffer.flip(); +// channel.write(buffer); +// buffer.rewind(); +// } +// } catch (IOException e) { +// e.printStackTrace(); +// } +// }, "Write out").start(); +// +// builder.start(); +// } +// } finally { +// System.out.println("Completed"); +// } +// } + +} diff --git a/org.argeo.cms.jshell/src/org/argeo/cms/jshell/JShellClient.java b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/JShellClient.java new file mode 100644 index 000000000..c86049881 --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/JShellClient.java @@ -0,0 +1,366 @@ +package org.argeo.cms.jshell; + +import java.io.ByteArrayOutputStream; +import java.io.Console; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.SocketChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.UUID; + +public class JShellClient { + public final static String STDIO = "stdio"; + public final static String STDERR = "stderr"; + public final static String CMDIO = "cmdio"; + + private static String ttyConfig; + + public static void main(String[] args) throws IOException, InterruptedException { + try { + Path targetStateDirectory = Paths.get(args[0]); + Path localBase = targetStateDirectory.resolve("jsh"); + if (Files.isSymbolicLink(localBase)) { + localBase = localBase.toRealPath(); + } + + Console console = System.console(); + if (console != null) { + toRawTerminal(); + } + + SocketPipeSource std = new SocketPipeSource(); + std.setInputStream(System.in); + std.setOutputStream(System.out); + + UUID uuid = UUID.randomUUID(); + Path sessionDir = localBase.resolve(uuid.toString()); + Files.createDirectory(sessionDir); + Path stdPath = sessionDir.resolve(JShellClient.STDIO); + + // wait for sockets to be available + WatchService watchService = FileSystems.getDefault().newWatchService(); + sessionDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE); + WatchKey key; + watch: while ((key = watchService.take()) != null) { + for (WatchEvent event : key.pollEvents()) { + Path path = sessionDir.resolve((Path) event.context()); + if (Files.isSameFile(stdPath, path)) { + break watch; + } + } + } + watchService.close(); + + UnixDomainSocketAddress stdSocketAddress = UnixDomainSocketAddress.of(stdPath); + + SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX); + channel.connect(stdSocketAddress); + + std.forward(channel); + } catch (IOException | InterruptedException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } finally { + try { + stty(ttyConfig.trim()); + } catch (Exception e) { + System.err.println("Exception restoring tty config"); + } + } + + } + + private static void toRawTerminal() throws IOException, InterruptedException { + + ttyConfig = stty("-g"); + + // set the console to be character-buffered instead of line-buffered + stty("-icanon min 1"); + + // disable character echoing + stty("-echo"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> toOriginalTerminal(), "Reset terminal")); + } + + private static void toOriginalTerminal() { + if (ttyConfig != null) + try { + stty(ttyConfig.trim()); + } catch (Exception e) { + System.err.println("Exception restoring tty config"); + } + } + + /** + * Execute the stty command with the specified arguments against the current + * active terminal. + */ + private static String stty(final String args) throws IOException, InterruptedException { + String cmd = "stty " + args + " < /dev/tty"; + + return exec(new String[] { "sh", "-c", cmd }); + } + + /** + * Execute the specified command and return the output (both stdout and stderr). + */ + private static String exec(final String[] cmd) throws IOException, InterruptedException { + ByteArrayOutputStream bout = new ByteArrayOutputStream(); + + Process p = Runtime.getRuntime().exec(cmd); + int c; + InputStream in = p.getInputStream(); + + while ((c = in.read()) != -1) { + bout.write(c); + } + + in = p.getErrorStream(); + + while ((c = in.read()) != -1) { + bout.write(c); + } + + p.waitFor(); + + String result = new String(bout.toByteArray()); + return result; + } + +// void pipe() throws IOException { +// // Set up Server Socket and bind to the port 8000 +// ServerSocketChannel server = ServerSocketChannel.open(); +// SocketAddress endpoint = new InetSocketAddress(8000); +// server.socket().bind(endpoint); +// +// server.configureBlocking(false); +// +// // Set up selector so we can run with a single thread but multiplex between 2 +// // channels +// Selector selector = Selector.open(); +// server.register(selector, SelectionKey.OP_ACCEPT); +// +// ByteBuffer buffer = ByteBuffer.allocate(1024); +// +// while (true) { +// // block until data comes in +// selector.select(); +// +// Set keys = selector.selectedKeys(); +// +// for (SelectionKey key : keys) { +// if (!key.isValid()) { +// // not valid or writable so skip +// continue; +// } +// +// if (key.isAcceptable()) { +// // Accept socket channel for client connection +// ServerSocketChannel channel = (ServerSocketChannel) key.channel(); +// SocketChannel accept = channel.accept(); +// setupConnection(selector, accept); +// } else if (key.isReadable()) { +// try { +// // Read into the buffer from the socket and then write the buffer into the +// // attached socket. +// SocketChannel recv = (SocketChannel) key.channel(); +// SocketChannel send = (SocketChannel) key.attachment(); +// recv.read(buffer); +// buffer.flip(); +// send.write(buffer); +// buffer.rewind(); +// } catch (IOException e) { +// e.printStackTrace(); +// +// // Close sockets +// if (key.channel() != null) +// key.channel().close(); +// if (key.attachment() != null) +// ((SocketChannel) key.attachment()).close(); +// } +// } else if (key.isWritable()) { +// +// } +// } +// +// // Clear keys for next select +// keys.clear(); +// } +// +// } + +// public static void mainX(String[] args) throws IOException, InterruptedException { +// toRawTerminal(); +// try { +// boolean client = true; +// if (client) { +// ReadableByteChannel inChannel; +// WritableByteChannel outChannel; +// inChannel = Channels.newChannel(System.in); +// outChannel = Channels.newChannel(System.out); +// +// SocketChannel channel = SocketChannel.open(StandardProtocolFamily.UNIX); +// channel.connect(ioSocketAddress()); +// +// new Thread(() -> { +// +// try { +// ByteBuffer buffer = ByteBuffer.allocate(1024); +// while (true) { +// if (channel.read(buffer) < 0) +// break; +// buffer.flip(); +// outChannel.write(buffer); +// buffer.rewind(); +// } +// System.exit(0); +// } catch (IOException e) { +// e.printStackTrace(); +// } +// }, "Read out").start(); +// +// ByteBuffer buffer = ByteBuffer.allocate(1); +// while (channel.isConnected()) { +// if (inChannel.read(buffer) < 0) +// break; +// buffer.flip(); +// channel.write(buffer); +// buffer.rewind(); +// } +// +// } else { +// ServerSocketChannel serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); +// serverChannel.bind(ioSocketAddress()); +// +// SocketChannel channel = serverChannel.accept(); +// +// while (true) { +// readSocketMessage(channel).ifPresent(message -> System.out.printf("[Client message] %s", message)); +// Thread.sleep(100); +// } +// } +// } finally { +// toOriginalTerminal(); +// } +// } +// +// private static Optional readSocketMessage(SocketChannel channel) throws IOException { +// ByteBuffer buffer = ByteBuffer.allocate(1024); +// int bytesRead = channel.read(buffer); +// if (bytesRead < 0) +// return Optional.empty(); +// +// byte[] bytes = new byte[bytesRead]; +// buffer.flip(); +// buffer.get(bytes); +// String message = new String(bytes); +// return Optional.of(message); +// } +// +// public static void setupConnection(Selector selector, SocketChannel client) throws IOException { +// // Connect to the remote server +// SocketAddress address = new InetSocketAddress("192.168.1.74", 8000); +// SocketChannel remote = SocketChannel.open(address); +// +// // Make sockets non-blocking (should be better performance) +// client.configureBlocking(false); +// remote.configureBlocking(false); +// +// client.register(selector, SelectionKey.OP_READ, remote); +// remote.register(selector, SelectionKey.OP_READ, client); +// } +// +// static UnixDomainSocketAddress ioSocketAddress() throws IOException { +// String system = "default"; +// String bundleSn = "org.argeo.slc.jshell"; +// +// String xdgRunDir = System.getenv("XDG_RUNTIME_DIR"); +// Path baseRunDir = Paths.get(xdgRunDir); +// Path jshellSocketBase = baseRunDir.resolve("jshell").resolve(system).resolve(bundleSn); +// +// Files.createDirectories(jshellSocketBase); +// +// Path ioSocketPath = jshellSocketBase.resolve("io"); +// +// UnixDomainSocketAddress ioSocketAddress = UnixDomainSocketAddress.of(ioSocketPath); +// System.out.println(ioSocketAddress); +// return ioSocketAddress; +// } + +} + +class SocketPipeSource { + private ReadableByteChannel inChannel; + private WritableByteChannel outChannel; + + private Thread readOutThread; + + public void forward(SocketChannel channel) throws IOException { + readOutThread = new Thread(() -> { + + try { + ByteBuffer buffer = ByteBuffer.allocate(1024); + while (true) { + if (channel.read(buffer) < 0) + break; + buffer.flip(); + outChannel.write(buffer); + buffer.rewind(); + } + System.exit(0); + } catch (IOException e) { + e.printStackTrace(); + } + }, "Read out"); + readOutThread.start(); + + // TODO make it smarter than a 1 byte buffer + // we should recognize control characters + // e.g ^C +// int c = System.in.read(); +// if (c == 0x1B) { +// break; +// } + + ByteBuffer buffer = ByteBuffer.allocate(1); + while (channel.isConnected()) { + if (inChannel.read(buffer) < 0) + break; + buffer.flip(); + channel.write(buffer); + buffer.rewind(); + } + + // end + // TODO make it more robust + try { + // TODO add timeout + readOutThread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + public void setInputStream(InputStream in) { + inChannel = Channels.newChannel(in); + } + + public void setOutputStream(OutputStream out) { + outChannel = Channels.newChannel(out); + } +} diff --git a/org.argeo.cms.jshell/src/org/argeo/cms/jshell/LocalJShellSession.java b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/LocalJShellSession.java new file mode 100644 index 000000000..fce330deb --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/LocalJShellSession.java @@ -0,0 +1,126 @@ +package org.argeo.cms.jshell; + +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.net.StandardProtocolFamily; +import java.net.URI; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.StringJoiner; +import java.util.UUID; + +import org.argeo.api.cms.CmsLog; +import org.argeo.internal.cms.jshell.osgi.OsgiExecutionControlProvider; + +import jdk.jshell.tool.JavaShellToolBuilder; + +class LocalJShellSession implements Runnable { + private final static CmsLog log = CmsLog.getLog(LocalJShellSession.class); + + private UUID uuid; + private Path sessionDir; + + private String fromBundle = "org.argeo.cms.jshell"; + + private Path stdioPath; + private Path stderrPath; + private Path cmdioPath; + + private Thread replThread; + + LocalJShellSession(Path sessionDir) { + this.sessionDir = sessionDir; + this.uuid = UUID.fromString(sessionDir.getFileName().toString()); + + stdioPath = sessionDir.resolve(JShellClient.STDIO); + + replThread = new Thread(this, "JShell " + sessionDir); + replThread.start(); + } + + public void run() { + log.debug(() -> "Started JShell session " + sessionDir); + try (SocketPipeMirror std = new SocketPipeMirror()) { + // prepare jshell tool builder + JavaShellToolBuilder builder = JavaShellToolBuilder.builder(); + builder.in(std.getInputStream(), null); + builder.interactiveTerminal(true); + builder.out(new PrintStream(std.getOutputStream())); + + // UnixDomainSocketAddress ioSocketAddress = JSchellClient.ioSocketAddress(); + // Files.deleteIfExists(ioSocketAddress.getPath()); + UnixDomainSocketAddress stdSocketAddress = UnixDomainSocketAddress.of(stdioPath); + + try (ServerSocketChannel stdServerChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { + stdServerChannel.bind(stdSocketAddress); + try (SocketChannel channel = stdServerChannel.accept()) { + std.open(channel); + + String frameworkLocation = System.getProperty("osgi.framework"); + StringJoiner classpath = new StringJoiner(File.pathSeparator); + classpath.add(Paths.get(URI.create(frameworkLocation)).toAbsolutePath().toString()); + + ClassLoader cmsJShellBundleCL = OsgiExecutionControlProvider.class.getClassLoader(); + ClassLoader currentContextClassLoader = Thread.currentThread().getContextClassLoader(); + try { + // we need our own class loader so that Java service loader + // finds our ExecutionControlProvider implementation + Thread.currentThread().setContextClassLoader(cmsJShellBundleCL); + // + // START JSHELL + // + int exitCode = builder.start("--execution", "osgi:bundle(" + fromBundle + ")", "--class-path", + classpath.toString()); + // + log.debug("JShell " + sessionDir + " completed with exit code " + exitCode); + } finally { + Thread.currentThread().setContextClassLoader(currentContextClassLoader); + } + } + } + } catch (Exception e) { + throw new RuntimeException("JShell " + sessionDir + " failed", e); + } finally { + cleanUp(); + } + } + + void cleanUp() { + try { + if (Files.exists(stdioPath)) + Files.delete(stdioPath); + if (Files.exists(sessionDir)) + Files.delete(sessionDir); + } catch (IOException e) { + log.error("Cannot clean up JShell " + sessionDir, e); + } + } + +// void addChild(Path p) throws IOException { +// if (replThread != null) +// throw new IllegalStateException("JShell " + sessionDir + " is already started"); +// +// if (STDIO.equals(p.getFileName().toString())) { +// stdioPath = p; +// } else if (STDERR.equals(p.getFileName().toString())) { +// stderrPath = p; +// } else if (CMDIO.equals(p.getFileName().toString())) { +// cmdioPath = p; +// } else { +// log.warn("Unkown file name " + p.getFileName() + " in " + sessionDir); +// } +// +// // check that all paths are available +// // if (stdioPath != null && stderrPath != null && cmdioPath != null) { +// if (stdioPath != null) { +// replThread = new Thread(this, "JShell " + sessionDir); +// replThread.start(); +// } +// } + +} diff --git a/org.argeo.cms.jshell/src/org/argeo/cms/jshell/SocketPipeMirror.java b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/SocketPipeMirror.java new file mode 100644 index 000000000..f763d54b6 --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/cms/jshell/SocketPipeMirror.java @@ -0,0 +1,86 @@ +package org.argeo.cms.jshell; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.AsynchronousCloseException; +import java.nio.channels.Channels; +import java.nio.channels.Pipe; +import java.nio.channels.SocketChannel; + +class SocketPipeMirror implements Closeable { + private final Pipe inPipe; + private final Pipe outPipe; + + private final InputStream in; + private final OutputStream out; + + private Thread readInThread; + private Thread writeOutThread; + + public SocketPipeMirror() throws IOException { + inPipe = Pipe.open(); + outPipe = Pipe.open(); + in = Channels.newInputStream(inPipe.source()); + out = Channels.newOutputStream(outPipe.sink()); + } + + public void open(SocketChannel channel) { + readInThread = new Thread(() -> { + + try { + ByteBuffer buffer = ByteBuffer.allocate(1024); + while (!readInThread.isInterrupted() && channel.isConnected()) { + if (channel.read(buffer) < 0) + break; + buffer.flip(); + inPipe.sink().write(buffer); + buffer.rewind(); + } + } catch (AsynchronousCloseException e) { + // ignore + // TODO make it cleaner + } catch (IOException e) { + e.printStackTrace(); + } + }, "Read in"); + readInThread.start(); + + writeOutThread = new Thread(() -> { + + try { + ByteBuffer buffer = ByteBuffer.allocate(1024); + while (!writeOutThread.isInterrupted() && channel.isConnected()) { + if (outPipe.source().read(buffer) < 0) + break; + buffer.flip(); + channel.write(buffer); + buffer.rewind(); + } + } catch (IOException e) { + e.printStackTrace(); + } + }, "Write out"); + writeOutThread.start(); + + } + + @Override + public void close() throws IOException { + // TODO make it more robust + readInThread.interrupt(); + writeOutThread.interrupt(); + in.close(); + out.close(); + } + + public InputStream getInputStream() { + return in; + } + + public OutputStream getOutputStream() { + return out; + } +} diff --git a/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/OsgiExecutionControlProvider.java b/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/OsgiExecutionControlProvider.java new file mode 100644 index 000000000..673233cde --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/OsgiExecutionControlProvider.java @@ -0,0 +1,90 @@ +package org.argeo.internal.cms.jshell.osgi; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.argeo.api.cms.CmsLog; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.Version; +import org.osgi.framework.namespace.PackageNamespace; +import org.osgi.framework.wiring.BundleRevision; +import org.osgi.framework.wiring.BundleWire; +import org.osgi.framework.wiring.BundleWiring; + +import jdk.jshell.execution.DirectExecutionControl; +import jdk.jshell.spi.ExecutionControl; +import jdk.jshell.spi.ExecutionControlProvider; +import jdk.jshell.spi.ExecutionEnv; + +public class OsgiExecutionControlProvider implements ExecutionControlProvider { + private final static CmsLog log = CmsLog.getLog(OsgiExecutionControlProvider.class); + + public final static String PROVIDER_NAME = "osgi"; + public final static String BUNDLE_PARAMETER = "bundle"; + + @Override + public String name() { + return PROVIDER_NAME; + } + + @Override + public Map defaultParameters() { + Map defaultParameters = new HashMap<>(); + defaultParameters.put(BUNDLE_PARAMETER, null); + return defaultParameters; + } + + @Override + public ExecutionControl generate(ExecutionEnv env, Map parameters) throws Throwable { + // TODO find a better way to get a default bundle context + // NOTE: the related default bundle has to be started + BundleContext bc = FrameworkUtil.getBundle(OsgiExecutionControlProvider.class).getBundleContext(); + + String symbolicName = parameters.get(BUNDLE_PARAMETER); + Objects.requireNonNull(symbolicName); + NavigableMap bundles = new TreeMap(); + for (Bundle b : bc.getBundles()) { + if (symbolicName.equals(b.getSymbolicName())) + bundles.put(b.getVersion(), b); + } + Bundle fromBundle = bundles.lastEntry().getValue(); + + BundleWiring fromBundleWiring = fromBundle.adapt(BundleWiring.class); + ClassLoader fromBundleClassLoader = fromBundleWiring.getClassLoader(); + + Set packagesToImport = new TreeSet<>(); + Set bundlesToAddToCompileClasspath = new TreeSet<>(); + + // from bundle + bundlesToAddToCompileClasspath.add(fromBundle); + // from bundle packages + for (Package pkg : fromBundleClassLoader.getDefinedPackages()) { + packagesToImport.add(pkg.getName()); + } + +// System.out.println(Arrays.asList(fromBundleClassLoader.getDefinedPackages())); + List bundleWires = fromBundleWiring.getRequiredWires(BundleRevision.PACKAGE_NAMESPACE); + for (BundleWire bw : bundleWires) { +// System.out.println(bw.getCapability().getAttributes().get(PackageNamespace.PACKAGE_NAMESPACE)); + bundlesToAddToCompileClasspath.add(bw.getProviderWiring().getBundle()); + packagesToImport.add(bw.getCapability().getAttributes().get(PackageNamespace.PACKAGE_NAMESPACE).toString()); + } + log.debug("JShell from " + fromBundle.getSymbolicName() + "_" + fromBundle.getVersion() + " [" + + fromBundle.getBundleId() + "]"); + log.debug(" required packages " + packagesToImport); + log.debug(" required bundles " + bundlesToAddToCompileClasspath); + + ExecutionControl executionControl = new DirectExecutionControl( + new WrappingLoaderDelegate(fromBundleClassLoader)); + return executionControl; + } + +} diff --git a/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/WrappingLoaderDelegate.java b/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/WrappingLoaderDelegate.java new file mode 100644 index 000000000..f013a19cb --- /dev/null +++ b/org.argeo.cms.jshell/src/org/argeo/internal/cms/jshell/osgi/WrappingLoaderDelegate.java @@ -0,0 +1,228 @@ +package org.argeo.internal.cms.jshell.osgi; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.security.CodeSource; +import java.security.SecureClassLoader; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import jdk.jshell.execution.LoaderDelegate; +import jdk.jshell.spi.ExecutionControl.ClassBytecodes; +import jdk.jshell.spi.ExecutionControl.ClassInstallException; +import jdk.jshell.spi.ExecutionControl.EngineTerminationException; + +/** A {@link LoaderDelegate} using a parent {@link ClassLoader}. */ +class WrappingLoaderDelegate implements LoaderDelegate { + private final WrappingClassloader loader; + private final Map> klasses = new HashMap<>(); + + private static class WrappingClassloader extends SecureClassLoader { + + private final Map classFiles = new HashMap<>(); + + public WrappingClassloader(ClassLoader parent) { + super(parent); + } + + private class ResourceURLStreamHandler extends URLStreamHandler { + + private final String name; + + ResourceURLStreamHandler(String name) { + this.name = name; + } + + @Override + protected URLConnection openConnection(URL u) throws IOException { + return new URLConnection(u) { + private InputStream in; + private Map> fields; + private List fieldNames; + + @Override + public void connect() { + if (connected) { + return; + } + connected = true; + ClassFile file = classFiles.get(name); + in = new ByteArrayInputStream(file.data); + fields = new LinkedHashMap<>(); + fields.put("content-length", List.of(Integer.toString(file.data.length))); + Instant instant = new Date(file.timestamp).toInstant(); + ZonedDateTime time = ZonedDateTime.ofInstant(instant, ZoneId.of("GMT")); + String timeStamp = DateTimeFormatter.RFC_1123_DATE_TIME.format(time); + fields.put("date", List.of(timeStamp)); + fields.put("last-modified", List.of(timeStamp)); + fieldNames = new ArrayList<>(fields.keySet()); + } + + @Override + public InputStream getInputStream() throws IOException { + connect(); + return in; + } + + @Override + public String getHeaderField(String name) { + connect(); + return fields.getOrDefault(name, List.of()).stream().findFirst().orElse(null); + } + + @Override + public Map> getHeaderFields() { + connect(); + return fields; + } + + @Override + public String getHeaderFieldKey(int n) { + return n < fieldNames.size() ? fieldNames.get(n) : null; + } + + @Override + public String getHeaderField(int n) { + String name = getHeaderFieldKey(n); + + return name != null ? getHeaderField(name) : null; + } + + }; + } + } + + void declare(String name, byte[] bytes) { + classFiles.put(toResourceString(name), new ClassFile(bytes, System.currentTimeMillis())); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + ClassFile file = classFiles.get(toResourceString(name)); + if (file == null) { + return super.findClass(name); + } + return super.defineClass(name, file.data, 0, file.data.length, (CodeSource) null); + } + + @Override + public URL findResource(String name) { + URL u = doFindResource(name); + return u != null ? u : super.findResource(name); + } + + @Override + public Enumeration findResources(String name) throws IOException { + URL u = doFindResource(name); + Enumeration sup = super.findResources(name); + + if (u == null) { + return sup; + } + + List result = new ArrayList<>(); + + while (sup.hasMoreElements()) { + result.add(sup.nextElement()); + } + + result.add(u); + + return Collections.enumeration(result); + } + + private URL doFindResource(String name) { + if (classFiles.containsKey(name)) { + try { + return new URL(null, new URI("jshell", null, "/" + name, null).toString(), + new ResourceURLStreamHandler(name)); + } catch (MalformedURLException | URISyntaxException ex) { + throw new InternalError(ex); + } + } + + return null; + } + + private String toResourceString(String className) { + return className.replace('.', '/') + ".class"; + } + + private static class ClassFile { + public final byte[] data; + public final long timestamp; + + ClassFile(byte[] data, long timestamp) { + this.data = data; + this.timestamp = timestamp; + } + + } + } + + public WrappingLoaderDelegate(ClassLoader parentClassLoader) { + this.loader = new WrappingClassloader(parentClassLoader); + + Thread.currentThread().setContextClassLoader(loader); + } + + @Override + public void load(ClassBytecodes[] cbcs) throws ClassInstallException, EngineTerminationException { + boolean[] loaded = new boolean[cbcs.length]; + try { + for (ClassBytecodes cbc : cbcs) { + loader.declare(cbc.name(), cbc.bytecodes()); + } + for (int i = 0; i < cbcs.length; ++i) { + ClassBytecodes cbc = cbcs[i]; + Class klass = loader.loadClass(cbc.name()); + klasses.put(cbc.name(), klass); + loaded[i] = true; + // Get class loaded to the point of, at least, preparation + klass.getDeclaredMethods(); + } + } catch (Throwable ex) { + throw new ClassInstallException("load: " + ex.getMessage(), loaded); + } + } + + @Override + public void classesRedefined(ClassBytecodes[] cbcs) { + for (ClassBytecodes cbc : cbcs) { + loader.declare(cbc.name(), cbc.bytecodes()); + } + } + + @Override + public void addToClasspath(String cp) { + // ignore + } + + @Override + public Class findClass(String name) throws ClassNotFoundException { + Class klass = klasses.get(name); + if (klass == null) { + throw new ClassNotFoundException(name + " not found"); + } else { + return klass; + } + } + +} diff --git a/org.argeo.cms/src/org/argeo/cms/internal/runtime/CmsStateImpl.java b/org.argeo.cms/src/org/argeo/cms/internal/runtime/CmsStateImpl.java index c1f92deb4..5dc857009 100644 --- a/org.argeo.cms/src/org/argeo/cms/internal/runtime/CmsStateImpl.java +++ b/org.argeo.cms/src/org/argeo/cms/internal/runtime/CmsStateImpl.java @@ -373,6 +373,11 @@ public class CmsStateImpl implements CmsState { return KernelUtils.getOsgiInstancePath(relativePath); } + @Override + public Path getStatePath(String relativePath) { + return KernelUtils.getOsgiConfigurationPath(relativePath); + } + @Override public Long getAvailableSince() { return availableSince; diff --git a/org.argeo.cms/src/org/argeo/cms/internal/runtime/KernelUtils.java b/org.argeo.cms/src/org/argeo/cms/internal/runtime/KernelUtils.java index 6e47873b3..943c06f4e 100644 --- a/org.argeo.cms/src/org/argeo/cms/internal/runtime/KernelUtils.java +++ b/org.argeo.cms/src/org/argeo/cms/internal/runtime/KernelUtils.java @@ -65,6 +65,13 @@ class KernelUtils implements KernelConstants { return Paths.get(uri); } + public static Path getOsgiConfigurationPath(String relativePath) { + URI uri = getOsgiConfigurationUri(relativePath); + if (uri == null) // no data area available + return null; + return Paths.get(uri); + } + public static URI getOsgiInstanceUri(String relativePath) { String osgiInstanceBaseUri = getFrameworkProp(OSGI_INSTANCE_AREA); if (osgiInstanceBaseUri == null) // no data area available @@ -75,6 +82,16 @@ class KernelUtils implements KernelConstants { return safeUri(osgiInstanceBaseUri + (relativePath != null ? relativePath : "")); } + public static URI getOsgiConfigurationUri(String relativePath) { + String osgiInstanceBaseUri = getFrameworkProp(OSGI_CONFIGURATION_AREA); + if (osgiInstanceBaseUri == null) // no data area available + return null; + + if (!osgiInstanceBaseUri.endsWith("/")) + osgiInstanceBaseUri = osgiInstanceBaseUri + "/"; + return safeUri(osgiInstanceBaseUri + (relativePath != null ? relativePath : "")); + } + static String getFrameworkProp(String key, String def) { String value; if (CmsActivator.getBundleContext() != null) diff --git a/sdk/cms-e4-rap.properties b/sdk/cms-e4-rap.properties index e83bc24d4..708ab5340 100644 --- a/sdk/cms-e4-rap.properties +++ b/sdk/cms-e4-rap.properties @@ -13,6 +13,8 @@ org.argeo.cms.lib.sshd,\ org.argeo.cms.lib.equinox,\ org.argeo.cms.lib.jetty,\ +#argeo.osgi.start.5=\ +#org.argeo.cms.jshell # Local argeo.node.repo.type=h2