diff --git a/api/src/org/labkey/api/remoterunner/RemoteRunnerService.java b/api/src/org/labkey/api/remoterunner/RemoteRunnerService.java new file mode 100644 index 00000000000..758ce598110 --- /dev/null +++ b/api/src/org/labkey/api/remoterunner/RemoteRunnerService.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +package org.labkey.api.remoterunner; + +import org.jetbrains.annotations.Nullable; +import org.labkey.api.services.ServiceRegistry; +import org.labkey.vfs.FileLike; + +import java.io.FileFilter; +import java.io.IOException; + +/** + * Runs a script in a remote container over HTTP. + * + * The working directory is staged to object storage and handed to the runner as presigned URLs, so the runner holds no + * credentials: each URL is scoped to a single object and expires. Implemented by the cloudServices module. + */ +public interface RemoteRunnerService +{ + static @Nullable RemoteRunnerService get() + { + return ServiceRegistry.get().getService(RemoteRunnerService.class); + } + + static void setInstance(RemoteRunnerService impl) + { + ServiceRegistry.get().registerService(RemoteRunnerService.class, impl); + } + + /** True when a runner endpoint and a staging bucket are both configured. */ + boolean isEnabled(); + + /** + * Tar {@code localWorkingDir}, run {@code scriptFile} against it in the remote runner, and unpack the result back + * over {@code localWorkingDir}. + * + * The runner picks an interpreter from the script's extension, so this is not R-specific; an extension it does not + * recognize is an error rather than a default. + * + * @param scriptFile the script to run, already written into the working directory + * @param localWorkingDir working directory on this server + * @param remoteWorkingDir path the runner will see, used for path mapping + * @param inputFiles which files in the working directory to send + */ + void execute(FileLike scriptFile, String localWorkingDir, String remoteWorkingDir, @Nullable FileFilter inputFiles) + throws IOException; +} diff --git a/api/src/org/labkey/api/reports/ExternalScriptEngineDefinition.java b/api/src/org/labkey/api/reports/ExternalScriptEngineDefinition.java index ab489dc7b03..c8590508b03 100644 --- a/api/src/org/labkey/api/reports/ExternalScriptEngineDefinition.java +++ b/api/src/org/labkey/api/reports/ExternalScriptEngineDefinition.java @@ -83,6 +83,13 @@ enum Type void setDocker(boolean docker); boolean isDocker(); + /** + * True when a remote definition targets the HTTP script runner rather than Rserve. Both are "remote", so + * this is what separates them. + */ + void setRemoteRunner(boolean remoteRunner); + boolean isRemoteRunner(); + void setDockerImageRowId(Integer rowId); Integer getDockerImageRowId(); String getDockerImageConfig(); diff --git a/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngine.java b/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngine.java new file mode 100644 index 00000000000..3f32fe1b893 --- /dev/null +++ b/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngine.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +package org.labkey.api.reports.report.r; + +import org.apache.commons.io.FilenameUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.miniprofiler.CustomTiming; +import org.labkey.api.miniprofiler.MiniProfiler; +import org.labkey.api.pipeline.file.PathMapper; +import org.labkey.api.pipeline.file.PathMapperImpl; +import org.labkey.api.query.ValidationException; +import org.labkey.api.remoterunner.RemoteRunnerService; +import org.labkey.api.reports.ExternalScriptEngineDefinition; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.vfs.FileLike; + +import javax.script.ScriptContext; +import javax.script.ScriptException; +import java.io.File; +import java.io.FileFilter; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; + +/** + * Runs R in a remote container reached over HTTP, with the working directory staged through object storage. + * + * Shaped after {@link RDockerScriptEngine}: the engine only maps paths and delegates, the transport lives in the + * injected service. + */ +public class RRemoteScriptEngine extends RScriptEngine +{ + private static final Logger LOG = LogHelper.getLogger(RRemoteScriptEngine.class, "Remote R script engine"); + + /** Fixed because the runner unpacks the working directory to the same place every run. */ + public static final String REMOTE_WORKING_DIR = "/work"; + + private final RemoteRunnerService _service; + + public RRemoteScriptEngine(@NotNull ExternalScriptEngineDefinition def, @Nullable RemoteRunnerService service) + { + super(def); + _service = service; + + def.setPathMapper(new PathMapperImpl() + { + void setMapping() + { + String wd = getWorkingDir(getContext()).toNioPathForRead().toFile().getAbsolutePath() + .replace("\\", "/").replace("/./", "/"); + super.setPathMap(Collections.singletonMap( + REMOTE_WORKING_DIR, + new File(wd).toURI().toString())); + } + + @Override + public String remoteToLocal(String remoteURI) + { + setMapping(); + return super.remoteToLocal(remoteURI); + } + + @Override + public String localToRemote(String localURI) + { + setMapping(); + return super.localToRemote(localURI); + } + + @Override + public ValidationException getValidationErrors() + { + setMapping(); + return super.getValidationErrors(); + } + }); + } + + @Override + protected Object eval(FileLike scriptFile, ScriptContext context) throws ScriptException + { + if (null == _service) + throw new ScriptException("Script evaluation attempted with no RemoteRunnerService instance available."); + + StringBuffer output = new StringBuffer(); + try (CustomTiming t = MiniProfiler.custom("remoteRunner", "execute r in remote runner")) + { + _service.execute(scriptFile, getRWorkingDir(context), REMOTE_WORKING_DIR, inputFiles()); + appendConsoleOutput(context, output); + } + catch (Exception e) + { + // Message only. The chain can carry a presigned URL, which is a bearer credential for that object until + // it expires, and this text renders in the report pane for anyone who can run the report. + LOG.error("Remote runner failed for script '{}'", scriptFile.getName(), e); + throw new ScriptException("An error occurred when running the script '" + scriptFile.getName() + + "': " + e.getMessage()); + } + + String scriptOut = output.toString(); + // R CMD BATCH writes to .Rout rather than failing the process, so the only error signal is in the output. + if (scriptOut.contains("Execution halted")) + throw new ScriptException("An error occurred when running the script '" + scriptFile.getName() + "'.\n" + scriptOut); + return scriptOut; + } + + /** + * The base class names this file by the appserver's working directory, which does not exist in the container, so + * the write silently fails and no packages are ever recorded. The runner runs the script from the unpacked job + * directory, so a bare name lands beside the script and returns in the result tar. + */ + @Override + protected @Nullable String getPackageCaptureEpilog(ScriptContext context) + { + if (getKnitrFormat(context) != RReportDescriptor.KnitrFormat.None) + return null; + + return """ + # --- LabKey R package usage capture --- + tryCatch(writeLines(sort(loadedNamespaces()), "%s"), error = function(e) invisible(NULL)) + """.formatted(PACKAGES_FILE); + } + + private static FileFilter inputFiles() + { + return pathname -> + pathname.isFile() && + (RScriptEngineFactory.isRScriptEngine(new String[]{FilenameUtils.getExtension(pathname.getName())}) + || RReport.DATA_INPUT.equals(pathname.getName())); + } + + @Override + public String getRemotePath(FileLike localFile) + { + URI localUri = FileUtil.getAbsoluteCaseSensitiveFile(localFile).toURI(); + URI remote = RserveScriptEngine.makeLocalToRemotePath(_def, getWorkingDir(getContext()), localUri); + return PathMapper.uriToPath(remote); + } + + @Override + public String getRemotePath(String local) + { + try + { + URI localUri = PathMapper.pathToUri(local); + URI remote = RserveScriptEngine.makeLocalToRemotePath(_def, getWorkingDir(getContext()), localUri); + return PathMapper.uriToPath(remote); + } + catch (URISyntaxException e) + { + throw UnexpectedException.wrap(e); + } + } +} diff --git a/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngineFactory.java b/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngineFactory.java new file mode 100644 index 00000000000..a2c8bfab643 --- /dev/null +++ b/api/src/org/labkey/api/reports/report/r/RRemoteScriptEngineFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +package org.labkey.api.reports.report.r; + +import org.labkey.api.remoterunner.RemoteRunnerService; +import org.labkey.api.reports.ExternalScriptEngineDefinition; +import org.labkey.api.reports.ExternalScriptEngineFactory; + +import javax.script.ScriptEngine; + +public class RRemoteScriptEngineFactory extends ExternalScriptEngineFactory +{ + public RRemoteScriptEngineFactory(ExternalScriptEngineDefinition def) + { + super(def); + } + + @Override + public synchronized ScriptEngine getScriptEngine() + { + RemoteRunnerService service = RemoteRunnerService.get(); + if (null != service && service.isEnabled()) + return new RRemoteScriptEngine(_def, service); + return null; + } +} diff --git a/api/src/org/labkey/api/util/DirectoryArchive.java b/api/src/org/labkey/api/util/DirectoryArchive.java new file mode 100644 index 00000000000..adcc103cc74 --- /dev/null +++ b/api/src/org/labkey/api/util/DirectoryArchive.java @@ -0,0 +1,441 @@ +/* + * Copyright (c) 2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced + * in any form or by any electronic or mechanical means without written permission from LabKey Corporation. + */ +package org.labkey.api.util; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileFilter; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Tar a working directory and untar it again. Equivalent to the archive handling inside DockerServiceImpl, lifted out + * so remote script execution can reuse it over transports other than the Docker API. + */ +public class DirectoryArchive +{ + private DirectoryArchive() + { + } + + /** Tar {@code directory} into {@code target}, with entries named relative to it. */ + public static void create(File directory, @Nullable FileFilter filter, File target) throws IOException + { + try (OutputStream fos = new FileOutputStream(target); + TarArchiveOutputStream tar = new TarArchiveOutputStream(fos)) + { + // Long working-directory paths are routine, and the default format silently truncates past 100 chars. + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + addDirectory(tar, directory, filter, ""); + tar.finish(); + } + } + + private static void addDirectory(TarArchiveOutputStream tar, File dir, @Nullable FileFilter filter, String prefix) throws IOException + { + File[] children = dir.listFiles(); + if (children == null) + return; + + for (File child : children) + { + // Top level is unprefixed; joining unconditionally would name it "/script.r", which extract rejects as an + // absolute path. The prefix only accumulates as recursion descends into subdirectories. + String entryName = prefix.isEmpty() ? child.getName() : prefix + "/" + child.getName(); + boolean link = Files.isSymbolicLink(child.toPath()); + + if (child.isDirectory()) + { + // File.isDirectory() follows the link, so recursing here would walk out of the working directory -- + // and a link to any ancestor would recurse until the stack or the disk gave out. + if (link) + continue; + addDirectory(tar, child, filter, entryName); + continue; + } + // A link to a file is still dereferenced below, which preserves the content the script expects. A broken + // one resolves to nothing, and opening it would fail the whole job. + if (link && !child.isFile()) + continue; + if (filter != null && !filter.accept(child)) + continue; + + TarArchiveEntry entry = new TarArchiveEntry(child, entryName); + tar.putArchiveEntry(entry); + try (InputStream in = new FileInputStream(child)) + { + IOUtils.copy(in, tar); + } + tar.closeArchiveEntry(); + } + } + + /** + * The script author decides how much the run writes, so the archive is bounded here rather than trusted. Without + * a cap one report can fill the appserver's disk, which takes down every application on the host. Override for a + * deployment that legitimately produces more. + */ + public static final long MAX_EXTRACTED_BYTES = Long.getLong("labkey.directoryArchive.maxBytes", 1L << 30); + public static final int MAX_EXTRACTED_ENTRIES = Integer.getInteger("labkey.directoryArchive.maxEntries", 10_000); + + /** + * Untar {@code in} beneath {@code destination}, reproducing the archived directory. + * + * Entries resolving outside the destination are rejected, absolute names and links included, and the total is + * capped. The tar arrives from a remote runner that executed customer script code, so both its entry names and + * its size are untrusted input. + */ + public static void extract(InputStream in, File destination) throws IOException + { + Path root = destination.toPath().toAbsolutePath().normalize(); + long budget = MAX_EXTRACTED_BYTES; + int entries = 0; + + try (TarArchiveInputStream tar = new TarArchiveInputStream(new BufferedInputStream(in))) + { + TarArchiveEntry entry; + while ((entry = tar.getNextEntry()) != null) + { + String name = entry.getName(); + if (name.isEmpty()) + continue; + + if (++entries > MAX_EXTRACTED_ENTRIES) + throw new IOException("Refusing to extract more than " + MAX_EXTRACTED_ENTRIES + " entries"); + + // A link would let a later entry write through it to anywhere the appserver can reach. + if (entry.isSymbolicLink() || entry.isLink()) + throw new IOException("Refusing to extract link entry: " + name); + + Path resolved = root.resolve(name).normalize(); + if (!resolved.startsWith(root)) + throw new IOException("Refusing to extract entry outside the destination directory: " + name); + + File target = resolved.toFile(); + if (entry.isDirectory()) + { + FileUtil.mkdirs(target); + continue; + } + + FileUtil.mkdirs(target.getParentFile()); + // normalize() above is lexical, so it cannot see a symlink already sitting in the destination. + // Re-checking the created parent's real path closes that, and costs one stat per entry. + requireInside(root, target.getParentFile().toPath()); + + try (OutputStream out = new FileOutputStream(target)) + { + budget -= copyBounded(tar, out, budget); + } + } + } + } + + private static void requireInside(Path root, Path parent) throws IOException + { + Path real = parent.toRealPath(); + if (!real.startsWith(root.toRealPath())) + throw new IOException("Refusing to write through a link out of the destination directory: " + parent); + } + + /** Copies at most {@code budget} bytes, failing rather than truncating so a clipped result is never mistaken for a whole one. */ + private static long copyBounded(InputStream in, OutputStream out, long budget) throws IOException + { + byte[] buffer = new byte[8192]; + long written = 0; + int read; + while ((read = in.read(buffer)) != -1) + { + written += read; + if (written > budget) + throw new IOException("Refusing to extract more than " + MAX_EXTRACTED_BYTES + " bytes"); + out.write(buffer, 0, read); + } + return written; + } + + public static class TestCase extends org.junit.Assert + { + @org.junit.Test + public void roundTripPreservesContentAndBinary() throws IOException + { + File src = FileUtil.createTempDirectory("arch-src").toFile(); + File dest = FileUtil.createTempDirectory("arch-dest").toFile(); + try + { + Files.writeString(new File(src, "script.R").toPath(), "cat(\"hi\")"); + File nested = new File(src, "sub"); + FileUtil.mkdirs(nested); + byte[] binary = new byte[]{0, 1, 2, (byte) 0xFF, 0x7F}; + Files.write(new File(nested, "plot.png").toPath(), binary); + + File tar = File.createTempFile("arch", ".tar"); + create(src, null, tar); + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + } + + assertEquals("cat(\"hi\")", Files.readString(new File(dest, "script.R").toPath())); + assertArrayEquals(binary, Files.readAllBytes(new File(dest, "sub/plot.png").toPath())); + } + finally + { + FileUtil.deleteDir(src); + FileUtil.deleteDir(dest); + } + } + + @org.junit.Test + public void filterExcludesUnwantedFiles() throws IOException + { + File src = FileUtil.createTempDirectory("arch-filter").toFile(); + File dest = FileUtil.createTempDirectory("arch-filter-out").toFile(); + try + { + Files.writeString(new File(src, "keep.R").toPath(), "keep"); + Files.writeString(new File(src, "drop.tmp").toPath(), "drop"); + + File tar = File.createTempFile("arch", ".tar"); + create(src, f -> f.getName().endsWith(".R"), tar); + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + } + + assertTrue(new File(dest, "keep.R").exists()); + assertFalse(new File(dest, "drop.tmp").exists()); + } + finally + { + FileUtil.deleteDir(src); + FileUtil.deleteDir(dest); + } + } + + /** The runner returns a flat tar. Skipping those entries loses every result without reporting anything. */ + @org.junit.Test + public void extractsFlatEntriesFromAForeignTar() throws IOException + { + File dest = FileUtil.createTempDirectory("arch-flat").toFile(); + File tar = File.createTempFile("flat", ".tar"); + try + { + writeSingleEntryTar(tar, "script.r.Rout", "console output"); + + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + } + + assertEquals("console output", Files.readString(new File(dest, "script.r.Rout").toPath())); + } + finally + { + FileUtil.deleteDir(dest); + } + } + + /** An absolute entry would otherwise escape the destination entirely. */ + @org.junit.Test + public void rejectsAbsoluteEntry() throws IOException + { + File dest = FileUtil.createTempDirectory("arch-absolute").toFile(); + File tar = File.createTempFile("absolute", ".tar"); + try + { + writeSingleEntryTar(tar, "/etc/passwd", "pwned"); + + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + fail("Expected absolute entry to be rejected"); + } + catch (IOException expected) + { + assertTrue(expected.getMessage().contains("outside the destination")); + } + } + finally + { + FileUtil.deleteDir(dest); + } + } + + private static void writeSingleEntryTar(File tar, String entryName, String content) throws IOException + { + try (OutputStream fos = new FileOutputStream(tar); + TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) + { + out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + // preserveAbsolutePath, or the constructor strips the leading slash and the absolute case cannot be + // written at all. Python's tarfile preserves it, so the runner can produce one. + TarArchiveEntry entry = new TarArchiveEntry(entryName, true); + byte[] payload = content.getBytes(); + entry.setSize(payload.length); + out.putArchiveEntry(entry); + out.write(payload); + out.closeArchiveEntry(); + out.finish(); + } + } + + /** The result tar comes back from a container that ran customer script code. */ + @org.junit.Test + public void rejectsPathTraversal() throws IOException + { + File dest = FileUtil.createTempDirectory("arch-traversal").toFile(); + File tar = File.createTempFile("evil", ".tar"); + try + { + try (OutputStream fos = new FileOutputStream(tar); + TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) + { + TarArchiveEntry entry = new TarArchiveEntry("work/../../escaped.txt"); + byte[] payload = "pwned".getBytes(); + entry.setSize(payload.length); + out.putArchiveEntry(entry); + out.write(payload); + out.closeArchiveEntry(); + out.finish(); + } + + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + fail("Expected traversal entry to be rejected"); + } + catch (IOException expected) + { + assertTrue(expected.getMessage().contains("outside the destination")); + } + } + finally + { + FileUtil.deleteDir(dest); + } + } + + /** A link entry would let a later entry write through it to anywhere the appserver can reach. */ + @org.junit.Test + public void rejectsLinkEntry() throws IOException + { + File dest = FileUtil.createTempDirectory("arch-link").toFile(); + File tar = File.createTempFile("link", ".tar"); + try + { + try (OutputStream fos = new FileOutputStream(tar); + TarArchiveOutputStream out = new TarArchiveOutputStream(fos)) + { + TarArchiveEntry entry = new TarArchiveEntry("escape", TarArchiveEntry.LF_SYMLINK); + entry.setLinkName("/etc"); + out.putArchiveEntry(entry); + out.closeArchiveEntry(); + out.finish(); + } + + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + fail("Expected a symlink entry to be rejected"); + } + catch (IOException expected) + { + assertTrue(expected.getMessage().contains("link entry")); + } + } + finally + { + FileUtil.deleteDir(dest); + } + } + + /** + * The script author decides how much the run writes, so an unbounded extract lets one report fill the disk + * and take down every application on the host. + */ + @org.junit.Test + public void rejectsContentOverTheByteCap() throws IOException + { + // MAX_EXTRACTED_BYTES is read into a static at class load, so the budget is exercised directly rather + // than by building a gigabyte of tar. + try (InputStream in = new java.io.ByteArrayInputStream("x".repeat(4096).getBytes()); + OutputStream sink = OutputStream.nullOutputStream()) + { + copyBounded(in, sink, 100); + fail("Expected the byte cap to be enforced"); + } + catch (IOException expected) + { + assertTrue(expected.getMessage().contains("Refusing to extract more than")); + } + } + + /** + * File.isDirectory() follows a link, so without the guard the cyclic link below recurses until the stack or + * the disk gives out. A link to a file still round-trips as that file's content. + */ + @org.junit.Test + public void doesNotFollowSymlinksOutOfTheTree() throws IOException + { + File src = FileUtil.createTempDirectory("arch-symlink-src").toFile(); + File dest = FileUtil.createTempDirectory("arch-symlink-dest").toFile(); + File tar = File.createTempFile("symlink", ".tar"); + try + { + Files.writeString(new File(src, "real.txt").toPath(), "content"); + Files.createSymbolicLink(new File(src, "link.txt").toPath(), new File(src, "real.txt").toPath()); + Files.createSymbolicLink(new File(src, "loop").toPath(), src.toPath()); + Files.createSymbolicLink(new File(src, "broken").toPath(), new File(src, "gone").toPath()); + + create(src, null, tar); + + try (InputStream in = new FileInputStream(tar)) + { + extract(in, dest); + } + + assertEquals("content", Files.readString(new File(dest, "real.txt").toPath())); + assertEquals("a link to a file should arrive as that file's content", + "content", Files.readString(new File(dest, "link.txt").toPath())); + assertFalse("a link to a directory must not be followed", new File(dest, "loop").exists()); + assertFalse("a broken link must not fail the archive", new File(dest, "broken").exists()); + } + catch (UnsupportedOperationException | FileSystemException e) + { + // Creating symlinks needs a privilege this filesystem does not grant; nothing to assert. + } + finally + { + FileUtil.deleteDir(src); + FileUtil.deleteDir(dest); + tar.delete(); + } + } + + @org.junit.Test + public void copiesUpToTheBudget() throws IOException + { + byte[] payload = "hello".getBytes(); + try (InputStream in = new java.io.ByteArrayInputStream(payload); + OutputStream sink = OutputStream.nullOutputStream()) + { + assertEquals(payload.length, copyBounded(in, sink, payload.length)); + } + } + } +} diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 80282aae94b..37827e4fee2 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -1497,6 +1497,7 @@ public TabDisplayMode getTabDisplayMode() public @NotNull Set> getUnitTests() { return Set.of( + org.labkey.api.util.DirectoryArchive.TestCase.class, AdminController.FileRootPermissionTestCase.class, ApiJsonWriter.TestCase.class, ClassLoaderTestCase.class, diff --git a/core/src/org/labkey/core/reports/ExternalScriptEngineDefinitionImpl.java b/core/src/org/labkey/core/reports/ExternalScriptEngineDefinitionImpl.java index 4556fca4b6d..4d0c064dab5 100644 --- a/core/src/org/labkey/core/reports/ExternalScriptEngineDefinitionImpl.java +++ b/core/src/org/labkey/core/reports/ExternalScriptEngineDefinitionImpl.java @@ -80,6 +80,7 @@ public class ExternalScriptEngineDefinitionImpl extends Entity implements Extern private boolean _external; private boolean _remote; private boolean _docker; + private boolean _remoteRunner; private boolean _pandocEnabled; private boolean _default; private boolean _sandboxed; @@ -169,6 +170,7 @@ public void updateConfiguration() addIfNotNull(json, "external", isExternal()); addIfNotNull(json, "remote", isRemote()); addIfNotNull(json, "docker", isDocker()); + addIfNotNull(json, "remoteRunner", isRemoteRunner()); addIfNotNull(json, "pandocEnabled", isPandocEnabled()); addIfNotNull(json, "fileExchange", getFileExchange()); addIfNotNull(json, "pathMap", _pathMap); @@ -234,6 +236,8 @@ public void setConfiguration(String configuration, boolean decrypt) throws IOExc setRemote(json.getBoolean("remote")); if (json.has("docker")) setDocker(json.getBoolean("docker")); + if (json.has("remoteRunner")) + setRemoteRunner(json.getBoolean("remoteRunner")); if (json.has("pandocEnabled")) setPandocEnabled(json.getBoolean("pandocEnabled")); if (json.has("fileExchange")) @@ -507,6 +511,18 @@ public void setDocker(boolean docker) _docker = docker; } + @Override + public boolean isRemoteRunner() + { + return _remoteRunner; + } + + @Override + public void setRemoteRunner(boolean remoteRunner) + { + _remoteRunner = remoteRunner; + } + @Override public boolean isPandocEnabled() { diff --git a/core/src/org/labkey/core/reports/ScriptEngineManagerImpl.java b/core/src/org/labkey/core/reports/ScriptEngineManagerImpl.java index aace2da21d4..7496bbb96b2 100644 --- a/core/src/org/labkey/core/reports/ScriptEngineManagerImpl.java +++ b/core/src/org/labkey/core/reports/ScriptEngineManagerImpl.java @@ -48,7 +48,9 @@ import org.labkey.api.reports.ExternalScriptEngineFactory; import org.labkey.api.reports.LabKeyScriptEngineManager; import org.labkey.api.reports.report.python.PythonScriptEngine; +import org.labkey.api.remoterunner.RemoteRunnerService; import org.labkey.api.reports.report.r.RDockerScriptEngineFactory; +import org.labkey.api.reports.report.r.RRemoteScriptEngineFactory; import org.labkey.api.reports.report.r.RScriptEngineFactory; import org.labkey.api.reports.report.r.RemoteRNotEnabledException; import org.labkey.api.reports.report.r.RserveScriptEngineFactory; @@ -330,6 +332,20 @@ public boolean isSandboxed() { if (def.isDocker()) return new RDockerScriptEngineFactory(def).getScriptEngine(); + else if (def.isRemoteRunner()) + { + RemoteRunnerService runner = RemoteRunnerService.get(); + if (null == runner || !runner.isEnabled()) + { + // Returning null here would silently fall back to running on the appserver, which is the thing + // this engine exists to avoid. + IllegalStateException ex = new IllegalStateException(String.format( + "R engine [%1$s] targets the remote script runner, but no runner is configured on this server.", def.getName())); + LOG.error(ex.getMessage()); + throw ex; + } + return new RRemoteScriptEngineFactory(def).getScriptEngine(); + } else if (def.isRemote()) { if (PremiumService.get().isRemoteREnabled()) diff --git a/core/src/org/labkey/core/view/configReportsAndScripts.jsp b/core/src/org/labkey/core/view/configReportsAndScripts.jsp index 73dc792ff51..1f773b900ac 100644 --- a/core/src/org/labkey/core/view/configReportsAndScripts.jsp +++ b/core/src/org/labkey/core/view/configReportsAndScripts.jsp @@ -19,6 +19,7 @@ <%@ page import="org.labkey.api.docker.DockerService"%> <%@ page import="org.labkey.api.files.FileContentService"%> <%@ page import="org.labkey.api.premium.PremiumService" %> +<%@ page import="org.labkey.api.remoterunner.RemoteRunnerService" %> <%@ page import="org.labkey.api.reports.ExternalScriptEngine" %> <%@ page import="org.labkey.api.reports.ExternalScriptEngineDefinition" %> <%@ page import="org.labkey.api.reports.report.ExternalScriptEngineReport" %> @@ -51,6 +52,10 @@ } boolean hasAdminOpsPerms = getContainer().hasPermission(getUser(), AdminOperationsPermission.class); boolean baseServerUrlSet = !AppProps.getInstance().getBaseServerUrl().contains("localhost"); + // Offered whenever the module supplying the runner is deployed; the form reports whether it is actually configured. + RemoteRunnerService remoteRunnerService = RemoteRunnerService.get(); + boolean isRemoteRunnerAvailable = null != remoteRunnerService; + boolean isRemoteRunnerConfigured = null != remoteRunnerService && remoteRunnerService.isEnabled(); %>