diff --git a/build.gradle b/build.gradle
index 469bb4a..a1cd562 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,8 +5,13 @@ allprojects {
}
ext {
- floggerVersion = '0.6'
- junitPlatformVersion = '5.7.2'
- protobufJavaVersion = '3.17.3'
- micrometerVersion = 'latest.integration'
+ curatorVersion = '5.2.0'
+ grpcVersion = '1.41.0'
+ floggerVersion = '0.7.1'
+ junitPlatformVersion = '5.8.1'
+ protobufJavaVersion = '3.18.1'
+ micrometerVersion = '1.7.4'
+ consuleClientVersion = '1.4.5'
+ javaxAnnotationVersion = '1.3.2'
+ recordBuilderVersion = '28'
}
\ No newline at end of file
diff --git a/deploy/kubernetes/namespace.yml b/deploy/kubernetes/namespace.yml
new file mode 100644
index 0000000..222d8b7
--- /dev/null
+++ b/deploy/kubernetes/namespace.yml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: jsheets
\ No newline at end of file
diff --git a/deploy/kubernetes/server.yml b/deploy/kubernetes/server.yml
new file mode 100644
index 0000000..ba9f25e
--- /dev/null
+++ b/deploy/kubernetes/server.yml
@@ -0,0 +1,56 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: server
+ namespace: jsheets
+ labels:
+ app: server
+spec:
+ selector:
+ matchLabels:
+ app: server
+ replicas: 3
+ template:
+ metadata:
+ name: server
+ labels:
+ app: server
+ spec:
+ containers:
+ - name: server
+ image: ehenoma/jsheets:latest
+ ports:
+ - containerPort: 8080
+ hostPort: 8080
+ env:
+ - name: JSHEETS_SERVER_PORT
+ value: "8080"
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: server-service
+ namespace: jsheets
+spec:
+ selector:
+ app: server
+ clusterIP: None
+ ports:
+ - name: http
+ port: 8080
+ targetPort: 8080
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: server-ingress
+ namespace: jsheets
+spec:
+ rules:
+ - http:
+ paths:
+ - path: "/"
+ pathType: Prefix
+ backend:
+ service:
+ name: server-service
diff --git a/deploy/minimal/README.md b/deploy/minimal/README.md
new file mode 100644
index 0000000..3350775
--- /dev/null
+++ b/deploy/minimal/README.md
@@ -0,0 +1,5 @@
+# Minimal Deployment
+
+Contains the server itself and a document store.
+The server is responsible for evaluating snippets, which is not
+ideal for public/highly frequented websites.
\ No newline at end of file
diff --git a/deploy/minimal/docker-compose.yml b/deploy/minimal/docker-compose.yml
new file mode 100644
index 0000000..abd972a
--- /dev/null
+++ b/deploy/minimal/docker-compose.yml
@@ -0,0 +1,66 @@
+version: '3.7'
+services:
+ server:
+ image: ehenoma/jsheets:latest
+ build:
+ context: ../../
+ dockerfile: ./server/deploy/Dockerfile
+ restart: always
+ environment:
+ JSHEETS_SERVER_PORT: 8080
+ JSHEETS_MONGODB_URI: mongodb://root:root@document-store/jsheets
+ JSHEETS_ZOOKEEPER_CONNECTION_STRING: zookeeper:2181
+ ports:
+ - "8080:8080"
+ networks:
+ - document-store
+ - zookeeper
+ - runtime
+ document-store:
+ image: mongo:latest
+ container_name: document-store
+ restart: always
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: root
+ MONGO_INITDB_ROOT_PASSWORD: root
+ networks:
+ - document-store
+ zookeeper:
+ image: zookeeper
+ restart: always
+ hostname: zookeeper
+ ports:
+ - "2181:2181"
+ environment:
+ ZOO_MY_ID: 1
+ ZOO_PORT: 2181
+ networks:
+ - zookeeper
+ zookeeper-web:
+ image: elkozmon/zoonavigator:latest
+ restart: unless-stopped
+ environment:
+ HTTP_PORT: 9000
+ ports:
+ - "9000:9000"
+ networks:
+ - zookeeper
+ runtime:
+ image: ehenoma/jsheets-runtime:latest
+ build:
+ context: ../../
+ dockerfile: ./runtime/deploy/Dockerfile
+ restart: always
+ container_name: runtime
+ hostname: runtime
+ environment:
+ JSHEETS_RUNTIME_SERVER_PORT: 8080
+ JSHEETS_RUNTIME_ZOOKEEPER_CONNECTION_STRING: zookeeper:2181
+ JSHEETS_RUNTIME_SERVICE_ADVERTISED_HOST: runtime:8080
+ networks:
+ - zookeeper
+ - runtime
+networks:
+ document-store:
+ zookeeper:
+ runtime:
\ No newline at end of file
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
new file mode 100644
index 0000000..765a511
--- /dev/null
+++ b/docker-compose.build.yml
@@ -0,0 +1,13 @@
+# Make sure to run 'gradle build copyDependencies' before building this
+version: '3.7'
+services:
+ server:
+ image: ehenoma/jsheets:latest
+ build:
+ dockerfile: server/deploy/Dockerfile
+ context: .
+ runtime:
+ image: ehenoma/jsheets-latest:latest
+ build:
+ dockerfile: runtime/deploy/Dockerfile
+ context: .
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
deleted file mode 100644
index c72c7e3..0000000
--- a/docker-compose.dev.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-version: '3.7'
-services:
- server:
- image: ehenoma/jsheets:dev
- build:
- # Make sure to run 'gradle copyDependencies' before building
- dockerfile: server/deploy/Dockerfile
- context: .
- container_name: jsheets-server
- environment:
- JSHEETS_SERVER_PORT: 8080
- JSHEETS_MONGODB_URI: mongodb://root:root@document-store/jsheets
- ports:
- - "8080:8080"
- networks:
- - database
- document-store:
- container_name: document-store
- image: mongo:latest
- environment:
- MONGO_INITDB_ROOT_USERNAME: root
- MONGO_INITDB_ROOT_PASSWORD: root
- networks:
- - database
-
-networks:
- database:
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 647a44a..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-version: '3.7'
-services:
- server:
- image: ehenoma/jsheets:latest
- environment:
- JSHEETS_SERVER_PORT: 8080
- JSHEETS_MONGODB_URI: mongodb://root:root@document-store/jsheets
- ports:
- - "8080:8080"
- networks:
- - database
- document-store:
- container_name: document-store
- image: mongo:latest
- environment:
- MONGO_INITDB_ROOT_USERNAME: root
- MONGO_INITDB_ROOT_PASSWORD: root
- networks:
- - database
-
-networks:
- database:
\ No newline at end of file
diff --git a/evaluation/build.gradle b/evaluation/build.gradle
new file mode 100644
index 0000000..c17c1f7
--- /dev/null
+++ b/evaluation/build.gradle
@@ -0,0 +1,34 @@
+plugins {
+ id 'java'
+}
+
+group 'dev.jsheets'
+version '0.1.0'
+
+sourceCompatibility = 16
+targetCompatibility = 16
+
+repositories {
+ mavenCentral()
+}
+
+ext {
+ asmVersion = '9.2'
+}
+
+dependencies {
+ implementation project(':protocol')
+ implementation "org.ow2.asm:asm:$asmVersion"
+ implementation "com.google.protobuf:protobuf-java-util:$protobufJavaVersion"
+ implementation "com.google.flogger:flogger:$floggerVersion"
+ testImplementation "org.junit.jupiter:junit-jupiter-api:$junitPlatformVersion"
+ testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitPlatformVersion"
+ testRuntimeOnly "com.google.flogger:flogger-slf4j-backend:$floggerVersion"
+ compileOnly "io.soabase.record-builder:record-builder-core:$recordBuilderVersion"
+ annotationProcessor "io.soabase.record-builder:record-builder-processor:$recordBuilderVersion"
+}
+
+test {
+ useJUnitPlatform()
+ jvmArgs += ['--add-opens', 'jdk.jshell/jdk.jshell=ALL-UNNAMED']
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/CamelCase.java b/evaluation/src/main/java/jsheets/config/CamelCase.java
new file mode 100644
index 0000000..123913f
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/CamelCase.java
@@ -0,0 +1,55 @@
+package jsheets.config;
+
+public final class CamelCase {
+ private CamelCase() {}
+
+ @FunctionalInterface
+ public interface Case {
+ static Case upper() {
+ return Character::toUpperCase;
+ }
+
+ static Case lower() {
+ return Character::toLowerCase;
+ }
+
+ static Case same() {
+ return character -> character;
+ }
+
+ char apply(char character);
+ }
+
+ public static String convert(
+ String input,
+ String separator,
+ Case characterCase
+ ) {
+ return switch (input.length()) {
+ case 0 -> input;
+ case 1 -> String.valueOf(characterCase.apply(input.charAt(0)));
+ default -> convertWithSufficientLength(input, separator, characterCase);
+ };
+ }
+
+ private static String convertWithSufficientLength(
+ String input,
+ String separator,
+ Case characterCase
+ ) {
+ var output = new StringBuilder(input.length());
+ char firstCharacter = input.charAt(0);
+ boolean lastCapitalized = Character.isUpperCase(firstCharacter);
+ output.append(characterCase.apply(firstCharacter));
+ for (int index = 1; index < input.length(); index++) {
+ char character = input.charAt(index);
+ boolean capitalized = Character.isUpperCase(character);
+ if (capitalized && !lastCapitalized) {
+ output.append(separator);
+ }
+ lastCapitalized = capitalized;
+ output.append(characterCase.apply(character));
+ }
+ return output.toString();
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/CombinedConfig.java b/evaluation/src/main/java/jsheets/config/CombinedConfig.java
new file mode 100644
index 0000000..df49e4c
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/CombinedConfig.java
@@ -0,0 +1,43 @@
+package jsheets.config;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+public final class CombinedConfig implements Config {
+ /**
+ * Combines the {@code configs}, prioritized based on descending order.
+ *
+ * If config {@code Ci} contains a value for any given key, then it is
+ * returned, otherwise config {@code Ci+1} is queried until no configs remain
+ * and a missing field is returned.
+ *
+ * @param configs Ordered set of sources.
+ * @return Config that merges the values of all {@code configs}.
+ */
+ public static Config of(Config... configs) {
+ Objects.requireNonNull(configs);
+ return new CombinedConfig(configs.clone());
+ }
+
+ private final Config[] configs;
+
+ private CombinedConfig(Config[] configs) {
+ this.configs = configs;
+ }
+
+ @Override
+ public Field lookup(Key key) {
+ for (var config : configs) {
+ var field = config.lookup(key);
+ if (field.exists()) {
+ return field;
+ }
+ }
+ return new MissingField<>(key);
+ }
+
+ @Override
+ public String toString() {
+ return "CombinedSource(%s)".formatted(Arrays.toString(configs));
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/Config.java b/evaluation/src/main/java/jsheets/config/Config.java
new file mode 100644
index 0000000..d64c2df
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/Config.java
@@ -0,0 +1,93 @@
+package jsheets.config;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * Storage of {@link Config.Field configuration fields}, that can be accessed
+ * in a typesafe way (using the {@link Config.Key keys}.
+ */
+public interface Config {
+ Field lookup(Key key);
+
+ interface Source {
+ Config load();
+ }
+
+ interface Field {
+ T require();
+
+ Optional orNone();
+
+ default T or(T value) {
+ return orNone().orElse(value);
+ }
+
+ boolean exists();
+ }
+
+ final class Key {
+ public static Key ofFlag(String name) {
+ Objects.requireNonNull(name, "name");
+ return new Key<>(name, Boolean::parseBoolean);
+ }
+
+ public static Key ofString(String name) {
+ Objects.requireNonNull(name, "name");
+ return new Key<>(name, String::valueOf);
+ }
+
+ public static Key ofInt(String name) {
+ Objects.requireNonNull(name, "name");
+ return new Key<>(name, Integer::parseInt);
+ }
+
+ public static Key ofDouble(String name) {
+ Objects.requireNonNull(name, "name");
+ return new Key<>(name, Double::parseDouble);
+ }
+
+ public static Key of(String name, Function parse) {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(parse, "parse");
+ return new Key<>(name, parse);
+ }
+
+ private final String name;
+ private final Function parse;
+
+ private Key(String name, Function parse) {
+ this.name = name;
+ this.parse = parse;
+ }
+
+ public Field in(Config config) {
+ return config.lookup(this);
+ }
+
+ public T parse(String rawInput) {
+ return parse.apply(rawInput);
+ }
+
+ @Override
+ public boolean equals(Object target) {
+ if (target == this) {
+ return true;
+ }
+ return target instanceof Key key && (
+ key.name.equals(name)
+ );
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+}
diff --git a/evaluation/src/main/java/jsheets/config/EnvironmentConfig.java b/evaluation/src/main/java/jsheets/config/EnvironmentConfig.java
new file mode 100644
index 0000000..c68e707
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/EnvironmentConfig.java
@@ -0,0 +1,44 @@
+package jsheets.config;
+
+import java.util.Map;
+import java.util.Objects;
+
+import com.google.common.annotations.VisibleForTesting;
+
+public final class EnvironmentConfig implements Config {
+ public static Config.Source prefixed(String prefix) {
+ Objects.requireNonNull(prefix, "prefix");
+ return () -> new EnvironmentConfig(prefix + "_", System.getenv());
+ }
+
+ private final String prefix;
+ private final Map environment;
+
+ @VisibleForTesting
+ EnvironmentConfig(String prefix, Map environment) {
+ this.prefix = prefix;
+ this.environment = environment;
+ }
+
+ @Override
+ public Field lookup(Key key) {
+ var value = environment.get(formatKey(key));
+ if (value == null) {
+ return new MissingField<>(key);
+ }
+ return new ResolvedField<>(key.parse(value));
+ }
+
+ private String formatKey(Key> key) {
+ return prefix + translateKey(key.toString());
+ }
+
+ @VisibleForTesting
+ static String translateKey(String key) {
+ return toEnvironmentCase(key).replace(".", "_");
+ }
+
+ private static String toEnvironmentCase(String input) {
+ return CamelCase.convert(input, "_", Character::toUpperCase);
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/MissingField.java b/evaluation/src/main/java/jsheets/config/MissingField.java
new file mode 100644
index 0000000..f9039fa
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/MissingField.java
@@ -0,0 +1,26 @@
+package jsheets.config;
+
+import java.util.NoSuchElementException;
+import java.util.Optional;
+
+record MissingField(Config.Key key) implements Config.Field {
+ @Override
+ public T require() {
+ throw new NoSuchElementException(key + " is missing");
+ }
+
+ @Override
+ public T or(T value) {
+ return value;
+ }
+
+ @Override
+ public Optional orNone() {
+ return Optional.empty();
+ }
+
+ @Override
+ public boolean exists() {
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/RawConfig.java b/evaluation/src/main/java/jsheets/config/RawConfig.java
new file mode 100644
index 0000000..f307ec5
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/RawConfig.java
@@ -0,0 +1,63 @@
+package jsheets.config;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+public final class RawConfig implements Config {
+ public static RawConfig of(Map entries) {
+ var fields = new HashMap(entries.size());
+ for (var entry : entries.entrySet()) {
+ Objects.requireNonNull(entry.getKey(), "keys may not be null");
+ Objects.requireNonNull(entry.getValue(), "values may not be null");
+ fields.put(entry.getKey(), entry.getValue());
+ }
+ return new RawConfig(fields);
+ }
+
+ private final Map values;
+
+ private RawConfig(Map values) {
+ this.values = values;
+ }
+
+ @Override
+ public Field lookup(Key key) {
+ Objects.requireNonNull(key, "key may not be null");
+ var value = values.get(key.toString());
+ if (value == null) {
+ return new MissingField<>(key);
+ }
+ return new ResolvedField<>(key.parse(value));
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private final Map values = new HashMap<>();
+
+ @CanIgnoreReturnValue
+ public Builder with(Key key, T value) {
+ Objects.requireNonNull(key, "keys may not be null");
+ Objects.requireNonNull(value, "values may not be null");
+ values.put(key.toString(), value.toString());
+ return this;
+ }
+
+ @CanIgnoreReturnValue
+ public Builder withRaw(String key, String value) {
+ Objects.requireNonNull(key, "keys may not be null");
+ Objects.requireNonNull(value, "values may not be null");
+ values.put(key, value);
+ return this;
+ }
+
+ public RawConfig create() {
+ return new RawConfig(Map.copyOf(values));
+ }
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/config/ResolvedField.java b/evaluation/src/main/java/jsheets/config/ResolvedField.java
new file mode 100644
index 0000000..ab7fd89
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/config/ResolvedField.java
@@ -0,0 +1,25 @@
+package jsheets.config;
+
+import java.util.Optional;
+
+record ResolvedField(T value) implements Config.Field {
+ @Override
+ public T require() {
+ return value;
+ }
+
+ @Override
+ public T or(T fallback) {
+ return value;
+ }
+
+ @Override
+ public Optional orNone() {
+ return Optional.ofNullable(value);
+ }
+
+ @Override
+ public boolean exists() {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/Evaluation.java b/evaluation/src/main/java/jsheets/evaluation/Evaluation.java
similarity index 84%
rename from runtime/src/main/java/jsheets/runtime/evaluation/Evaluation.java
rename to evaluation/src/main/java/jsheets/evaluation/Evaluation.java
index b69bed2..f040e3c 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/Evaluation.java
+++ b/evaluation/src/main/java/jsheets/evaluation/Evaluation.java
@@ -1,4 +1,4 @@
-package jsheets.runtime.evaluation;
+package jsheets.evaluation;
import jsheets.EvaluateResponse;
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationEngine.java b/evaluation/src/main/java/jsheets/evaluation/EvaluationEngine.java
similarity index 81%
rename from runtime/src/main/java/jsheets/runtime/evaluation/EvaluationEngine.java
rename to evaluation/src/main/java/jsheets/evaluation/EvaluationEngine.java
index 082178d..410f172 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationEngine.java
+++ b/evaluation/src/main/java/jsheets/evaluation/EvaluationEngine.java
@@ -1,4 +1,4 @@
-package jsheets.runtime.evaluation;
+package jsheets.evaluation;
import jsheets.StartEvaluationRequest;
diff --git a/evaluation/src/main/java/jsheets/evaluation/failure/FailedEvaluation.java b/evaluation/src/main/java/jsheets/evaluation/failure/FailedEvaluation.java
new file mode 100644
index 0000000..b9fe030
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/failure/FailedEvaluation.java
@@ -0,0 +1,21 @@
+package jsheets.evaluation.failure;
+
+import jsheets.EvaluationError;
+
+import java.util.Locale;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public interface FailedEvaluation {
+ Stream describe(Locale locale);
+
+ static Optional capture(Throwable failure) {
+ while (failure != null) {
+ if (failure instanceof FailedEvaluation) {
+ return Optional.of((FailedEvaluation) failure);
+ }
+ failure = failure.getCause();
+ }
+ return Optional.empty();
+ }
+}
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/SandboxBytecodeCheck.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/SandboxBytecodeCheck.java
new file mode 100644
index 0000000..8c1b67a
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/SandboxBytecodeCheck.java
@@ -0,0 +1,118 @@
+package jsheets.evaluation.sandbox;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+import jsheets.evaluation.sandbox.validation.Analysis;
+import jsheets.evaluation.sandbox.validation.Rule;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+public final class SandboxBytecodeCheck {
+ public static SandboxBytecodeCheck withRules(Rule... rules) {
+ Objects.requireNonNull(rules, "rules");
+ return withRules(List.of(rules));
+ }
+
+ public static SandboxBytecodeCheck withRules(Collection rules) {
+ Objects.requireNonNull(rules, "rules");
+ return new SandboxBytecodeCheck(rules);
+ }
+
+ private final Collection rules;
+
+ private SandboxBytecodeCheck(Collection rules) {
+ this.rules = rules;
+ }
+
+ public void run(Analysis analysis, byte[] classCode) {
+ var reader = new ClassReader(classCode);
+ reader.accept(new ClassCheck(reader.getClassName(), rules, analysis), 0);
+ }
+
+ static final class ClassCheck extends ClassVisitor {
+ private final String className;
+ private final Collection rules;
+ private final Analysis analysis;
+
+ private ClassCheck(
+ String className,
+ Collection rules,
+ Analysis analysis
+ ) {
+ super(Opcodes.ASM9);
+ this.rules = rules;
+ this.className = className;
+ this.analysis = analysis;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(
+ int access,
+ String name,
+ String descriptor,
+ String signature,
+ String[] exceptions
+ ) {
+ return new MethodCheck(name, className, rules, analysis);
+ }
+ }
+
+ static final class MethodCheck extends MethodVisitor {
+ private final String name;
+ private final String className;
+ private final Collection rules;
+ private final Analysis analysis;
+
+ private MethodCheck(
+ String name,
+ String className,
+ Collection rules,
+ Analysis analysis
+ ) {
+ super(Opcodes.ASM9);
+ this.name = name;
+ this.rules = rules;
+ this.className = className;
+ this.analysis = analysis;
+ }
+
+ @Override
+ public void visitMethodInsn(
+ int opcode,
+ String owner,
+ String name,
+ String descriptor,
+ boolean isInterface
+ ) {
+ var type = Type.getMethodType(descriptor);
+ var ownerClass = Type.getObjectType(owner).getClassName();
+ var call = new Rule.MethodCall(createAccessPoint(), ownerClass, name, type);
+ for (var rule : rules) {
+ rule.visitCall(analysis, call);
+ }
+ }
+
+ @Override
+ public void visitFieldInsn(
+ int opcode,
+ String owner,
+ String field,
+ String descriptor
+ ) {
+ var ownerClass = Type.getObjectType(owner).getClassName();
+ var access = new Rule.FieldAccess(createAccessPoint(), ownerClass, field);
+ for (var rule : rules) {
+ rule.visitFieldAccess(analysis, access);
+ }
+ }
+
+ private Rule.AccessPoint createAccessPoint() {
+ return new Rule.AccessPoint(className, name);
+ }
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/Access.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/Access.java
new file mode 100644
index 0000000..61f7990
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/Access.java
@@ -0,0 +1,7 @@
+package jsheets.evaluation.sandbox.access;
+
+public enum Access {
+ NotSet,
+ Permitted,
+ Denied
+}
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraph.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraph.java
new file mode 100644
index 0000000..59301a3
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraph.java
@@ -0,0 +1,157 @@
+package jsheets.evaluation.sandbox.access;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+public final class AccessGraph {
+ private static final AccessGraph empty = new AccessGraph(
+ AccessGraphNode.Path.create("root", Access.Denied, List.of()));
+
+ public static AccessGraph empty() {
+ return empty;
+ }
+
+ public static AccessGraph of(String... permissions) {
+ var builder = newBuilder();
+ for (var permission : permissions) {
+ if (permission.startsWith("!")) {
+ var remaining = permission.substring(1).trim();
+ builder.deny(AccessKey.infer(remaining));
+ } else {
+ builder.permit(AccessKey.infer(permission.trim()));
+ }
+ }
+ return builder.create();
+ }
+
+ public static AccessGraphBuilder newBuilder() {
+ return new AccessGraphBuilder();
+ }
+
+ private final AccessGraphNode root;
+
+ AccessGraph(AccessGraphNode root) {
+ this.root = root;
+ }
+
+ private Collection findClosestMatch(AccessKey key) {
+ return findClosestMatch(root, key.split(), 0);
+ }
+
+ private static Collection findClosestMatch(
+ AccessGraphNode node,
+ String[] key,
+ int depth
+ ) {
+ if (depth >= key.length) {
+ return List.of(node);
+ }
+ if (depth == key.length - 1) {
+ return listClosestChildrenOrParent(node, key[key.length - 1]);
+ }
+ return findClosestChild(node, key, depth);
+ }
+
+ private static Collection findClosestChild(
+ AccessGraphNode node,
+ String[] key,
+ int depth
+ ) {
+ for (var child : node) {
+ if (child.matchesKey(key[depth])) {
+ return findClosestMatch(child, key, depth + 1);
+ }
+ }
+ return List.of(node);
+ }
+
+ private static Collection listClosestChildrenOrParent(
+ AccessGraphNode node,
+ String lastKey
+ ) {
+ var children = node.children()
+ .filter(child -> child.matchesKey(lastKey))
+ .toList();
+ return children.isEmpty() ? List.of(node) : children;
+ }
+
+ /**
+ * Checks if the use of the class or method under the given key is permitted.
+ *
+ * When checking access to methods prefer
+ * {@link AccessGraph#isMethodPermitted(MethodSignature)}.
+ *
+ * @param key Key of the checked package, class or method.
+ * @return True if the caller has permission to use classes or methods
+ * under this key.
+ */
+ public boolean isPermitted(AccessKey key) {
+ var matches = findClosestMatch(key);
+ if (matches.isEmpty()) {
+ return false;
+ }
+ var first = matches.iterator().next();
+ return first.access().equals(Access.Permitted);
+ }
+
+ /**
+ * Checks if a call to the method is permitted.
+ *
+ * This call is better for checking access to methods because it supports
+ * overloaded methods. The {@link AccessGraph#isPermitted} method does
+ * not handle ambiguous calls well.
+ *
+ * Note, that this method does not support covariant return types.
+ *
+ * @param signature Signature of the method that is checked.
+ * @return True if the caller has permissions to call this method.
+ */
+ public boolean isMethodPermitted(MethodSignature signature) {
+ var key = AccessKey.infer(signature.formatWithoutTypes());
+ var matches = findClosestMatch(key);
+ var access = switch (matches.size()) {
+ case 0 -> Access.Denied;
+ case 1 -> {
+ var result = matches.iterator().next();
+ boolean isMatch = !result.isClassMember() || result.matchesMethod(signature);
+ yield isMatch ? result.access() : Access.NotSet;
+ }
+ default -> findBestMethodMatch(signature, matches).map(AccessGraphNode::access);
+ };
+ return access.equals(Access.Permitted);
+ }
+
+ private Optional findBestMethodMatch(
+ MethodSignature signature,
+ Collection nodes
+ ) {
+ for (var node : nodes) {
+ if (node.matchesMethod(signature)) {
+ return Optional.of(node);
+ }
+ }
+ return Optional.empty();
+ }
+
+ @Override
+ public String toString() {
+ return "AccessGraph(%s)".formatted(root);
+ }
+
+ @Override
+ public boolean equals(Object target) {
+ if (this == target) {
+ return true;
+ }
+ return target instanceof AccessGraph graph && (
+ Objects.equals(graph.root, root)
+ );
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(root);
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphBuilder.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphBuilder.java
new file mode 100644
index 0000000..959fb7c
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphBuilder.java
@@ -0,0 +1,84 @@
+package jsheets.evaluation.sandbox.access;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+
+public final class AccessGraphBuilder {
+ private final AccessGraphNode root = AccessGraphNode.Path.create(
+ "root",
+ Access.NotSet,
+ new ArrayList<>()
+ );
+
+ AccessGraphBuilder() {}
+
+ @CanIgnoreReturnValue
+ public AccessGraphBuilder permit(AccessKey key) {
+ insert(key, Access.Permitted);
+ return this;
+ }
+
+ @CanIgnoreReturnValue
+ public AccessGraphBuilder deny(AccessKey key) {
+ insert(key, Access.Denied);
+ return this;
+ }
+
+ private void insert(AccessKey key, Access access) {
+ var parent = resolveParent(key);
+ parent.findChildByKey(key.lastPart())
+ .ifPresentOrElse(
+ existing -> existing.changeAccess(access),
+ () -> parent.insertChild(createNode(key, access))
+ );
+ }
+
+ private AccessGraphNode resolveParent(AccessKey key) {
+ var path = key.split();
+ if (path.length == 1) {
+ return root;
+ }
+ var parentPath = Arrays.copyOf(path, path.length - 1);
+ return resolveParent(root, parentPath, 0);
+ }
+
+ private AccessGraphNode resolveParent(AccessGraphNode target, String[] key, int depth) {
+ if (depth >= key.length) {
+ return target;
+ }
+ for (var child : target) {
+ if (child.matchesKey(key[depth])) {
+ return resolveParent(child, key, depth + 1);
+ }
+ }
+ return insertRemainingPath(target, key, depth);
+ }
+
+ private AccessGraphNode insertRemainingPath(AccessGraphNode target, String[] key, int depth) {
+ for (int index = depth; index < key.length; index++) {
+ var intermediate = AccessGraphNode.Path.create(key[index], Access.NotSet, new ArrayList<>());
+ target.insertChild(intermediate);
+ target = intermediate;
+ }
+ return target;
+ }
+
+ private AccessGraphNode createNode(AccessKey key, Access access) {
+ if (isMethodSignature(key.value())) {
+ var signature = MethodSignature.parse(key.value());
+ return AccessGraphNode.Method.create(signature, access, new ArrayList<>());
+ }
+ return AccessGraphNode.Path.create(key.lastPart(), access, new ArrayList<>());
+ }
+
+ private static boolean isMethodSignature(String path) {
+ return path.indexOf(MethodSignature.methodSeparator()) >= 0;
+ }
+
+ // TODO: Create defensive copies after building
+ public AccessGraph create() {
+ return new AccessGraph(root);
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphNode.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphNode.java
new file mode 100644
index 0000000..f4eaf54
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessGraphNode.java
@@ -0,0 +1,158 @@
+package jsheets.evaluation.sandbox.access;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public abstract class AccessGraphNode implements Iterable {
+ private Access access;
+ private final Collection children;
+
+ protected AccessGraphNode(Access access, Collection nodes) {
+ this.access = access;
+ this.children = nodes;
+ }
+
+ public abstract String key();
+
+ public abstract boolean isClassMember();
+ public abstract boolean matchesKey(String key);
+ public abstract boolean matchesMethod(MethodSignature signature);
+
+ public Optional findChildByKey(String key) {
+ for (var child : this) {
+ if (child.matchesKey(key)) {
+ return Optional.of(child);
+ }
+ }
+ return Optional.empty();
+ }
+
+ public void changeAccess(Access access) {
+ Objects.requireNonNull(access, "access");
+ this.access = access;
+ }
+
+ public Access access() {
+ return access;
+ }
+
+ public void insertChild(AccessGraphNode node) {
+ Objects.requireNonNull(node, "node");
+ this.children.add(node);
+ }
+
+ public Stream children() {
+ return children.stream();
+ }
+
+ @Override
+ public Iterator iterator() {
+ return children.iterator();
+ }
+
+ @Override
+ public String toString() {
+ return isLeaf() ? formatLeaf() : formatBranch();
+ }
+
+ private boolean isLeaf() {
+ return children.isEmpty();
+ }
+
+ private String formatBranch() {
+ return "AccessGraphNode(key=%s, access=%s, nodes=%s)"
+ .formatted(key(), access, children);
+ }
+
+ private String formatLeaf() {
+ return "AccessGraphNode(key=%s, access=%s)"
+ .formatted(key(), access);
+ }
+
+ public static final class Path extends AccessGraphNode {
+ public static AccessGraphNode create(
+ String key,
+ Access access,
+ Collection nodes
+ ) {
+ Objects.requireNonNull(key, "key");
+ Objects.requireNonNull(access, "access");
+ Objects.requireNonNull(nodes, "nodes");
+ return new Path(key, access, nodes);
+ }
+
+ private final String key;
+
+ private Path(String key, Access access, Collection nodes) {
+ super(access, nodes);
+ this.key = key;
+ }
+
+ @Override
+ public String key() {
+ return key;
+ }
+
+ @Override
+ public boolean matchesKey(String key) {
+ return this.key.equals(key);
+ }
+
+ @Override
+ public boolean matchesMethod(MethodSignature signature) {
+ return signature.methodName().equals(key);
+ }
+
+ @Override
+ public boolean isClassMember() {
+ return false;
+ }
+ }
+
+ public static final class Method extends AccessGraphNode {
+ public static AccessGraphNode create(
+ MethodSignature method,
+ Access access,
+ Collection nodes
+ ) {
+ Objects.requireNonNull(method, "method");
+ Objects.requireNonNull(access, "access");
+ Objects.requireNonNull(nodes, "nodes");
+ return new Method(method, access, nodes);
+ }
+
+ private final MethodSignature method;
+
+ private Method(
+ MethodSignature method,
+ Access access,
+ Collection nodes
+ ) {
+ super(access, nodes);
+ this.method = method;
+ }
+
+ @Override
+ public String key() {
+ return method.formatNameAndParameters();
+ }
+
+ @Override
+ public boolean matchesKey(String key) {
+ return method.methodName().matches(key);
+ }
+
+ @Override
+ public boolean matchesMethod(MethodSignature signature) {
+ return method.matches(signature);
+ }
+
+ @Override
+ public boolean isClassMember() {
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessKey.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessKey.java
new file mode 100644
index 0000000..673c31f
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/AccessKey.java
@@ -0,0 +1,38 @@
+package jsheets.evaluation.sandbox.access;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+public record AccessKey(Pattern separator, String value) {
+ public static AccessKey infer(String value) {
+ return value.contains("/") ? slashSeparated(value) : dotSeparated(value);
+ }
+
+ private static final Pattern dotOrMethodSeparator =
+ Pattern.compile("[.#]");
+
+ public static AccessKey dotSeparated(String value) {
+ Objects.requireNonNull(value, "value");
+ return new AccessKey(dotOrMethodSeparator, value);
+ }
+
+ private static final Pattern slashOrMethodSeparator =
+ Pattern.compile("[/#]");
+
+ public static AccessKey slashSeparated(String value) {
+ Objects.requireNonNull(value, "value");
+ return new AccessKey(slashOrMethodSeparator, value);
+ }
+
+ public String[] split() {
+ return separator.split(value);
+ }
+
+ public String lastPart() {
+ var parts = split();
+ if (parts.length == 0) {
+ return value;
+ }
+ return parts[parts.length - 1];
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/access/MethodSignature.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/MethodSignature.java
new file mode 100644
index 0000000..bc07041
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/access/MethodSignature.java
@@ -0,0 +1,113 @@
+package jsheets.evaluation.sandbox.access;
+
+import io.soabase.recordbuilder.core.RecordBuilder;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+@RecordBuilder
+public record MethodSignature(
+ String className,
+ String methodName,
+ String returnType,
+ Collection parameterTypes
+) {
+ private static final char methodSeparator = '#';
+
+ public static char methodSeparator() {
+ return methodSeparator;
+ }
+
+ public static MethodSignature parse(String input) {
+ var trimmed = input.trim().replace("/", ".");
+ var methodNameBegin = trimmed.indexOf(methodSeparator);
+ if (methodNameBegin < 0) {
+ throw new IllegalArgumentException(
+ "input does not contain method name (separated by #)"
+ );
+ }
+ return parsePreprocessed(trimmed, methodNameBegin);
+ }
+
+ private static final String wildcardParameter = "*";
+
+ private static final Collection wildcardParameters =
+ List.of(wildcardParameter);
+
+ private static MethodSignature parsePreprocessed(String input, int methodNameBegin) {
+ var className = input.substring(0, methodNameBegin);
+ var returnType = parseReturnType(input);
+ var parameterListBegin = input.indexOf('(');
+ if (parameterListBegin < 0) {
+ var methodName = input.substring(methodNameBegin + 1);
+ return new MethodSignature(className, methodName, returnType, wildcardParameters);
+ }
+ var parameterListEnd = input.lastIndexOf(')');
+ var methodName = input.substring(methodNameBegin + 1, parameterListBegin);
+ var parameterPart = input.substring(parameterListBegin + 1, parameterListEnd);
+ var parameterTypes = parseParameterTypes(parameterPart);
+ return new MethodSignature(className, methodName, returnType, parameterTypes);
+ }
+
+ private static final String wildcardReturnType = "*";
+
+ private static String parseReturnType(String input) {
+ int returnTypeBegin = input.indexOf(':');
+ return returnTypeBegin < 0
+ ? wildcardReturnType
+ : input.substring(returnTypeBegin + 1);
+ }
+
+ private static final Pattern parameterTypeSeparator = Pattern.compile(",\\s*");
+
+ private static Collection parseParameterTypes(String input) {
+ return List.of(parameterTypeSeparator.split(input));
+ }
+
+ public MethodSignature {
+ Objects.requireNonNull(className, "className");
+ Objects.requireNonNull(methodName, "methodName");
+ Objects.requireNonNull(returnType, "returnType");
+ Objects.requireNonNull(parameterTypes, "parameterTypes");
+ }
+
+ public boolean matches(MethodSignature signature) {
+ return signature.className.equals(className)
+ && signature.methodName.equals(methodName)
+ && (hasWildcardParameters() || signature.parameterTypes.equals(parameterTypes))
+ && (hasWildcardReturnType() || signature.returnType.equals(returnType));
+ }
+
+ private boolean hasWildcardReturnType() {
+ return wildcardReturnType.equals(returnType);
+ }
+
+ public boolean hasWildcardParameters() {
+ return parameterTypes.size() == 1
+ && parameterTypes.iterator().next().equals(wildcardParameter);
+ }
+
+ public String format() {
+ return "%s#%s:%s".formatted(
+ className,
+ formatNameAndParameters(),
+ returnType
+ );
+ }
+
+ private static final String constructorName = "";
+
+ public boolean isConstructor() {
+ return methodName.equals(constructorName);
+ }
+
+ public String formatNameAndParameters() {
+ return "%s(%s)".formatted(methodName, String.join(", ", parameterTypes));
+ }
+
+ public String formatWithoutTypes() {
+ return "%s#%s".formatted(className, methodName);
+ }
+}
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Analysis.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Analysis.java
new file mode 100644
index 0000000..c8e596e
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Analysis.java
@@ -0,0 +1,67 @@
+package jsheets.evaluation.sandbox.validation;
+
+import jsheets.EvaluationError;
+import jsheets.evaluation.failure.FailedEvaluation;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class Analysis {
+ public static Analysis create() {
+ return new Analysis();
+ }
+
+ public interface Violation extends FailedEvaluation {}
+
+ private final Collection violations =
+ new ConcurrentLinkedQueue<>();
+
+ private Analysis() {}
+
+ public void report(Violation violation) {
+ violations.add(violation);
+ }
+
+ public void reportViolations() {
+ if (!violations.isEmpty()) {
+ throw new FailedAnalysis(Set.copyOf(violations));
+ }
+ }
+
+ static final class FailedAnalysis extends RuntimeException implements FailedEvaluation {
+ private final Collection violations;
+
+ private FailedAnalysis(Collection violations) {
+ super(
+ violations.stream()
+ .map(Violation::toString)
+ .collect(Collectors.joining(", "))
+ );
+ this.violations = violations;
+ }
+
+ public Stream violations() {
+ return violations.stream();
+ }
+
+ @Override
+ public Stream describe(Locale locale) {
+ return violations.stream()
+ .flatMap(violation -> violation.describe(locale));
+ }
+ }
+
+ public static Stream captureViolations(Throwable failure) {
+ while (failure != null) {
+ if (failure instanceof FailedAnalysis) {
+ return ((FailedAnalysis) failure).violations();
+ }
+ failure = failure.getCause();
+ }
+ return Stream.empty();
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/ForbiddenMemberFilter.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/ForbiddenMemberFilter.java
new file mode 100644
index 0000000..975696b
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/ForbiddenMemberFilter.java
@@ -0,0 +1,102 @@
+package jsheets.evaluation.sandbox.validation;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import jsheets.EvaluationError;
+import jsheets.evaluation.sandbox.access.AccessGraph;
+import jsheets.evaluation.sandbox.access.AccessKey;
+import jsheets.evaluation.sandbox.access.MethodSignature;
+import jsheets.evaluation.sandbox.access.MethodSignatureBuilder;
+import org.objectweb.asm.Type;
+
+public final class ForbiddenMemberFilter implements Rule {
+ public static ForbiddenMemberFilter create(AccessGraph accessGraph) {
+ Objects.requireNonNull(accessGraph, "accessGraph");
+ return new ForbiddenMemberFilter(accessGraph);
+ }
+
+ private final AccessGraph accessGraph;
+
+ private ForbiddenMemberFilter(AccessGraph accessGraph) {
+ this.accessGraph = accessGraph;
+ }
+
+ public record ForbiddenMethod(MethodSignature method) implements Analysis.Violation {
+ @Override
+ public Stream describe(Locale locale) {
+ var message = method.isConstructor()
+ ? formatForConstructor(locale)
+ : formatForMethod(locale);
+ return Stream.of(
+ EvaluationError.newBuilder()
+ .setKind("sandbox")
+ .setMessage(message)
+ .build()
+ );
+ }
+
+ private String formatForConstructor(Locale locale) {
+ return "The class %s is not allowed".formatted(method.className());
+ }
+
+ private String formatForMethod(Locale locale) {
+ return "The method %s in %s is not allowed"
+ .formatted(method.methodName(), method.className());
+ }
+ }
+
+ @Override
+ public void visitCall(Analysis analysis, MethodCall call) {
+ if (isClassExcluded(call.owner())) {
+ return;
+ }
+ var signature = createSignatureOfCall(call);
+ if (!accessGraph.isMethodPermitted(signature)) {
+ analysis.report(new ForbiddenMethod(signature));
+ }
+ }
+
+ private MethodSignature createSignatureOfCall(MethodCall call) {
+ return MethodSignatureBuilder.builder()
+ .className(call.owner())
+ .methodName(call.method())
+ .returnType(call.type().getReturnType().getClassName())
+ .parameterTypes(
+ Arrays.stream(call.type().getArgumentTypes())
+ .map(Type::getClassName)
+ .toList()
+ ).build();
+ }
+
+ public record ForbiddenField(String owner, String field) implements Analysis.Violation {
+ @Override
+ public Stream describe(Locale locale) {
+ return Stream.of(
+ EvaluationError.newBuilder()
+ .setKind("sandbox")
+ .setMessage("The field %s in %s is not allowed".formatted(field, owner))
+ .build()
+ );
+ }
+ }
+
+ @Override
+ public void visitFieldAccess(Analysis analysis, FieldAccess access) {
+ if (isClassExcluded(access.owner())) {
+ return;
+ }
+ var key = "%s.%s".formatted(access.owner(), access.field());
+ if (!accessGraph.isPermitted(AccessKey.dotSeparated(key))) {
+ analysis.report(new ForbiddenField(access.owner(), access.field()));
+ }
+ }
+
+ private static final String generatedSnippetClassPrefix = "REPL.$JShell$";
+
+ private boolean isClassExcluded(String className) {
+ return className.startsWith(generatedSnippetClassPrefix);
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Rule.java b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Rule.java
new file mode 100644
index 0000000..f44f797
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/sandbox/validation/Rule.java
@@ -0,0 +1,23 @@
+package jsheets.evaluation.sandbox.validation;
+
+import org.objectweb.asm.Type;
+
+public interface Rule {
+ record AccessPoint(String className, String methodName) { }
+
+ record MethodCall(
+ AccessPoint accessPoint,
+ String owner,
+ String method,
+ Type type
+ ) { }
+
+ record FieldAccess(
+ AccessPoint accessPoint,
+ String owner,
+ String field
+ ) { }
+
+ default void visitCall(Analysis analysis, MethodCall call) {}
+ default void visitFieldAccess(Analysis analysis, FieldAccess access) {}
+}
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageLog.java b/evaluation/src/main/java/jsheets/evaluation/shell/MessageLog.java
similarity index 94%
rename from runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageLog.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/MessageLog.java
index f8d3f0f..1e091e5 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageLog.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/MessageLog.java
@@ -1,9 +1,9 @@
-package jsheets.runtime.evaluation.shell;
+package jsheets.evaluation.shell;
import jsheets.EvaluateResponse;
import jsheets.EvaluationResult;
import jsheets.EvaluationResult.Kind;
-import jsheets.runtime.evaluation.Evaluation;
+import jsheets.evaluation.Evaluation;
public final class MessageLog {
private final Evaluation.Listener output;
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageOutput.java b/evaluation/src/main/java/jsheets/evaluation/shell/MessageOutput.java
similarity index 95%
rename from runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageOutput.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/MessageOutput.java
index 708dfec..96dddb9 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/shell/MessageOutput.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/MessageOutput.java
@@ -1,4 +1,4 @@
-package jsheets.runtime.evaluation.shell;
+package jsheets.evaluation.shell;
import java.io.PrintStream;
import java.time.Duration;
@@ -9,8 +9,8 @@
import com.google.common.flogger.FluentLogger;
+import jsheets.evaluation.Evaluation;
import jsheets.output.CapturingOutput;
-import jsheets.runtime.evaluation.Evaluation;
final class MessageOutput implements AutoCloseable {
private static final FluentLogger log = FluentLogger.forEnclosingClass();
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluation.java b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java
similarity index 73%
rename from runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluation.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java
index e8ad25c..c27436d 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluation.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluation.java
@@ -1,5 +1,4 @@
-package jsheets.runtime.evaluation.shell;
-
+package jsheets.evaluation.shell;
import java.time.Clock;
import java.util.Comparator;
@@ -21,10 +20,10 @@
import jsheets.EvaluationResult;
import jsheets.SnippetSources;
import jsheets.StartEvaluationRequest;
-import jsheets.runtime.evaluation.Evaluation;
-import jsheets.shell.environment.ExecutionEnvironment;
-import jsheets.shell.execution.ExecutionMethod;
-import jsheets.source.SharedSources;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.failure.FailedEvaluation;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
+import jsheets.evaluation.shell.execution.ExecutionMethod;
final class ShellEvaluation implements Evaluation {
private enum Stage { Initial, Starting, Evaluating, Terminated }
@@ -34,14 +33,12 @@ private enum Stage { Initial, Starting, Evaluating, Terminated }
private volatile JShell shell;
private volatile ExecutionMethod executionMethod;
private final Evaluation.Listener listener;
- private final SharedSources sources;
private final ExecutionEnvironment environment;
private final ExecutionMethod.Factory executionMethodFactory;
private final MessageOutput messageOutput;
private final AtomicReference stage = new AtomicReference<>(Stage.Initial);
ShellEvaluation(
- Clock clock,
ExecutionEnvironment environment,
ExecutionMethod.Factory executionMethodFactory,
Evaluation.Listener listener,
@@ -51,7 +48,6 @@ private enum Stage { Initial, Starting, Evaluating, Terminated }
this.messageOutput = messageOutput;
this.executionMethodFactory = executionMethodFactory;
this.environment = environment;
- sources = SharedSources.createEmpty(clock);
}
public void start(StartEvaluationRequest request) {
@@ -83,19 +79,49 @@ private void evaluateComponent(
reportEvent(component.getId(), snippet, response);
}
messageOutput.flush();
- } catch (Exception failedEvaluation) {
- log.atWarning()
- .atMostEvery(5, TimeUnit.SECONDS)
- .withCause(failedEvaluation).log("shell evaluation failed");
- response.addError(
- EvaluationError.newBuilder()
- .setComponentId(component.getId())
- .setKind("internal")
- .build()
- );
+ } catch (Throwable failedEvaluation) {
+ reportError(component, response, failedEvaluation);
}
}
+ private void reportError(
+ SnippetSources.CodeComponent component,
+ EvaluateResponse.Builder response,
+ Throwable failure
+ ) {
+ FailedEvaluation.capture(failure).ifPresentOrElse(
+ value -> reportFailedEvaluation(component, response, value),
+ () -> reportInternalFailure(component, response, failure)
+ );
+ }
+
+ private void reportFailedEvaluation(
+ SnippetSources.CodeComponent component,
+ EvaluateResponse.Builder response,
+ FailedEvaluation failedEvaluation
+ ) {
+ failedEvaluation.describe(Locale.ENGLISH)
+ .distinct()
+ .map(error -> error.toBuilder().setComponentId(component.getId()))
+ .forEach(response::addError);
+ }
+
+ private void reportInternalFailure(
+ SnippetSources.CodeComponent component,
+ EvaluateResponse.Builder response,
+ Throwable failure
+ ) {
+ log.atWarning()
+ .atMostEvery(5, TimeUnit.SECONDS)
+ .withCause(failure).log("shell evaluation failed");
+ response.addError(
+ EvaluationError.newBuilder()
+ .setComponentId(component.getId())
+ .setKind("internal")
+ .build()
+ );
+ }
+
private void reportEvent(
String componentId,
SnippetEvent event,
@@ -109,6 +135,7 @@ private void reportEvent(
response.addResult(
EvaluationResult.newBuilder()
.setComponentId(componentId)
+ .setKind(EvaluationResult.Kind.INFO)
.setOutput(event.value())
.build()
);
@@ -160,7 +187,6 @@ private void cleanUp() {
public String toString() {
return MoreObjects.toStringHelper(this)
.add("shell", shell)
- .add("sources", sources)
.add("environment", environment)
.add("stage", stage)
.toString();
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngine.java b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java
similarity index 86%
rename from runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngine.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java
index e1e96bc..77da92c 100644
--- a/runtime/src/main/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngine.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/ShellEvaluationEngine.java
@@ -1,8 +1,7 @@
-package jsheets.runtime.evaluation.shell;
+package jsheets.evaluation.shell;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.Executor;
@@ -10,15 +9,14 @@
import java.util.concurrent.ScheduledExecutorService;
import jsheets.StartEvaluationRequest;
-import jsheets.runtime.evaluation.Evaluation;
-import jsheets.runtime.evaluation.EvaluationEngine;
-import jsheets.shell.environment.ExecutionEnvironment;
-import jsheets.shell.environment.StandardEnvironment;
-import jsheets.shell.execution.ExecutionMethod;
-import jsheets.shell.execution.SystemBasedExecutionMethodFactory;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.EvaluationEngine;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
+import jsheets.evaluation.shell.environment.StandardEnvironment;
+import jsheets.evaluation.shell.execution.ExecutionMethod;
+import jsheets.evaluation.shell.execution.SystemBasedExecutionMethodFactory;
public final class ShellEvaluationEngine implements EvaluationEngine {
- private final Clock clock;
private final Executor workerPool;
private final ScheduledExecutorService scheduler;
private final ExecutionEnvironment executionEnvironment;
@@ -26,14 +24,12 @@ public final class ShellEvaluationEngine implements EvaluationEngine {
private final Duration messageFlushInterval;
private ShellEvaluationEngine(
- Clock clock,
Executor workerPool,
ScheduledExecutorService scheduler,
ExecutionEnvironment executionEnvironment,
ExecutionMethod.Factory executionMethodFactory,
Duration messageFlushInterval
) {
- this.clock = clock;
this.workerPool = workerPool;
this.scheduler = scheduler;
this.executionEnvironment = executionEnvironment;
@@ -53,7 +49,6 @@ public Evaluation start(
private ShellEvaluation createEvaluation(Evaluation.Listener listener) {
return new ShellEvaluation(
- clock,
executionEnvironment,
executionMethodFactory,
listener,
@@ -70,19 +65,12 @@ public static Builder newBuilder() {
}
public static final class Builder {
- private Clock clock = Clock.systemUTC();
private Executor workerPool;
private ExecutionEnvironment environment;
private Duration messageFlushInterval;
private ScheduledExecutorService scheduler;
private ExecutionMethod.Factory executionMethodFactory;
- public Builder useClock(Clock clock) {
- Objects.requireNonNull(clock, "clock");
- this.clock = clock;
- return this;
- }
-
public Builder useWorkerPool(Executor pool) {
Objects.requireNonNull(pool, "workerPool");
workerPool = pool;
@@ -115,7 +103,6 @@ public Builder withMessageFlushInterval(Duration messageFlushInterval) {
public EvaluationEngine create() {
return new ShellEvaluationEngine(
- clock,
selectWorkerPool(),
selectScheduler(),
selectEnvironment(),
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStore.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStore.java
new file mode 100644
index 0000000..b87c5f9
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStore.java
@@ -0,0 +1,8 @@
+package jsheets.evaluation.shell.environment;
+
+import jdk.jshell.spi.ExecutionControl;
+
+public interface ClassFileStore {
+ void redefine(ExecutionControl.ClassBytecodes[] bytecodes);
+ void load(ExecutionControl.ClassBytecodes[] bytecodes);
+}
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStoreLoader.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStoreLoader.java
new file mode 100644
index 0000000..6fb9211
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ClassFileStoreLoader.java
@@ -0,0 +1,272 @@
+package jsheets.evaluation.shell.environment;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+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.URLClassLoader;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.security.CodeSource;
+import java.time.ZoneId;
+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 java.util.Objects;
+
+import jdk.jshell.execution.LoaderDelegate;
+import jdk.jshell.spi.ExecutionControl;
+
+public final class ClassFileStoreLoader implements LoaderDelegate {
+ // This class is based on Java's loader for DirectExecution.
+ public static ClassFileStoreLoader of(ClassFileStore store) {
+ Objects.requireNonNull(store, "store");
+ return new ClassFileStoreLoader(store);
+ }
+
+ private final Map> types = new HashMap<>();
+ private final ClassFileStore store;
+ private final RemoteClassLoader remote = new RemoteClassLoader();
+
+ private ClassFileStoreLoader(ClassFileStore store) {
+ this.store = store;
+ }
+
+ public Runnable install() {
+ var thread = Thread.currentThread();
+ var previousLoader = thread.getContextClassLoader();
+ thread.setContextClassLoader(remote);
+ return () -> thread.setContextClassLoader(previousLoader);
+ }
+
+ @Override
+ public void load(ExecutionControl.ClassBytecodes[] binaries)
+ throws ExecutionControl.ClassInstallException
+ {
+ store.load(binaries);
+ for (var binary : binaries) {
+ remote.declare(binary.name(), binary.bytecodes());
+ }
+ preload(binaries);
+ }
+
+ private void preload(ExecutionControl.ClassBytecodes[] binaries)
+ throws ExecutionControl.ClassInstallException
+ {
+ boolean[] loaded = new boolean[binaries.length];
+ try {
+ for ( int index = 0; index < binaries.length; ++index ) {
+ var code = binaries[index];
+ var type = remote.loadClass(code.name());
+ types.put(code.name(), type);
+ loaded[index] = true;
+ preload(type);
+ }
+ } catch (Throwable failure) {
+ throw new ExecutionControl.ClassInstallException("load: " + failure.getMessage(),
+ loaded
+ );
+ }
+ }
+
+ private void preload(Class> type) {
+ type.getDeclaredMethods();
+ }
+
+ @Override
+ public void classesRedefined(ExecutionControl.ClassBytecodes[] binaries) {
+ store.redefine(binaries);
+ for (var binary : binaries) {
+ remote.declare(binary.name(), binary.bytecodes());
+ }
+ }
+
+ @Override
+ public void addToClasspath(String classPath) throws ExecutionControl.InternalException {
+ try {
+ for (var path : classPath.split(File.pathSeparator)) {
+ remote.addURL(new File(path).toURI().toURL());
+ }
+ } catch (Exception failure) {
+ throw new ExecutionControl.InternalException(failure.toString());
+ }
+ }
+
+ @Override
+ public Class> findClass(String name) throws ClassNotFoundException {
+ var type = types.get(name);
+ if (type == null) {
+ throw new ClassNotFoundException(name + " not found");
+ }
+ return type;
+ }
+
+ private record FileRecord(byte[] content, long timestamp) {}
+
+ private static class RemoteClassLoader extends URLClassLoader {
+ private final Map files = new HashMap<>();
+
+ RemoteClassLoader() {
+ super(new URL[0]);
+ }
+
+ void declare(String name, byte[] bytes) {
+ files.put(
+ createResourceKeyForClassName(name),
+ new FileRecord(bytes, System.currentTimeMillis())
+ );
+ }
+
+ private String createResourceKeyForClassName(String name) {
+ return name.replace('.', '/') + ".class";
+ }
+
+ @Override
+ protected Class> findClass(String name) throws ClassNotFoundException {
+ var file = files.get(createResourceKeyForClassName(name));
+ if (file == null) {
+ return super.findClass(name);
+ }
+ return super.defineClass(
+ name,
+ file.content,
+ 0,
+ file.content.length,
+ (CodeSource) null
+ );
+ }
+
+ @Override
+ public URL findResource(String name) {
+ var resource = lookupResource(name);
+ return resource != null ? resource : super.findResource(name);
+ }
+
+ private URL lookupResource(String name) {
+ if (!files.containsKey(name)) {
+ return null;
+ }
+ try {
+ return new URL(
+ /* context */ null,
+ new URI("jshell", null, "/" + name, null).toString(),
+ new RemoteClassLoader.ResourceUrlStreamHandler(name)
+ );
+ } catch (MalformedURLException | URISyntaxException failure) {
+ throw new InternalError(failure);
+ }
+ }
+
+ @Override
+ public Enumeration findResources(String name) throws IOException {
+ var resource = lookupResource(name);
+ var parentResources = super.findResources(name);
+ return resource == null
+ ? parentResources
+ : plus(parentResources, resource);
+ }
+
+ private static Enumeration plus(Enumeration enumeration, T element) {
+ var result = new ArrayList();
+ while (enumeration.hasMoreElements()) {
+ result.add(enumeration.nextElement());
+ }
+ result.add(element);
+ return Collections.enumeration(result);
+ }
+
+ @Override
+ public void addURL(URL url) {
+ super.addURL(url);
+ }
+
+ private class ResourceUrlStreamHandler extends URLStreamHandler {
+ private final String name;
+
+ ResourceUrlStreamHandler(String name) {
+ this.name = name;
+ }
+
+ @Override
+ protected URLConnection openConnection(URL resource) {
+ return new URLConnection(resource) {
+ private InputStream input;
+ private Map> fields;
+ private List fieldNames;
+
+ @Override
+ public InputStream getInputStream() {
+ connect();
+ return input;
+ }
+
+ @Override
+ public void connect() {
+ if (connected) {
+ return;
+ }
+ connected = true;
+ var file = files.get(name);
+ input = new ByteArrayInputStream(file.content);
+ fields = new LinkedHashMap<>();
+ initializeHeaders(fields, file);
+ fieldNames = new ArrayList<>(fields.keySet());
+ }
+
+ private void initializeHeaders(
+ Map> headers,
+ FileRecord file
+ ) {
+ var length = Integer.toString(file.content.length);
+ headers.put("content-length", List.of(length));
+ var timeStamp = formatTime(file.timestamp);
+ headers.put("date", List.of(timeStamp));
+ headers.put("last-modified", List.of(timeStamp));
+ }
+
+ private String formatTime(long timeStamp) {
+ var instant = new Date(timeStamp).toInstant();
+ var time = instant.atZone(ZoneId.of("GMT"));
+ return DateTimeFormatter.RFC_1123_DATE_TIME.format(time);
+ }
+
+ @Override
+ public Map> getHeaderFields() {
+ connect();
+ return fields;
+ }
+
+ @Override
+ public String getHeaderField(int index) {
+ var name = getHeaderFieldKey(index);
+ return name != null ? getHeaderField(name) : null;
+ }
+
+ @Override
+ public String getHeaderFieldKey(int index) {
+ return index < fieldNames.size() ? fieldNames.get(index) : null;
+ }
+
+ @Override
+ public String getHeaderField(String name) {
+ connect();
+ return fields.getOrDefault(name, List.of())
+ .stream()
+ .findFirst()
+ .orElse(null);
+ }
+ };
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/EmptyClassFileStore.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/EmptyClassFileStore.java
new file mode 100644
index 0000000..c794a7d
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/EmptyClassFileStore.java
@@ -0,0 +1,17 @@
+package jsheets.evaluation.shell.environment;
+
+import jdk.jshell.spi.ExecutionControl;
+
+public final class EmptyClassFileStore implements ClassFileStore {
+ public static EmptyClassFileStore create() {
+ return new EmptyClassFileStore();
+ }
+
+ private EmptyClassFileStore() {}
+
+ @Override
+ public void load(ExecutionControl.ClassBytecodes[] bytecodes) {}
+
+ @Override
+ public void redefine(ExecutionControl.ClassBytecodes[] bytecodes) {}
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/shell/environment/ExecutionEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ExecutionEnvironment.java
similarity index 83%
rename from runtime/src/main/java/jsheets/shell/environment/ExecutionEnvironment.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/ExecutionEnvironment.java
index 3db47f5..9619a7a 100644
--- a/runtime/src/main/java/jsheets/shell/environment/ExecutionEnvironment.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/ExecutionEnvironment.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment;
+package jsheets.evaluation.shell.environment;
import jdk.jshell.spi.ExecutionControlProvider;
diff --git a/runtime/src/main/java/jsheets/shell/environment/StandardEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/StandardEnvironment.java
similarity index 96%
rename from runtime/src/main/java/jsheets/shell/environment/StandardEnvironment.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/StandardEnvironment.java
index b2d1e7e..e08661e 100644
--- a/runtime/src/main/java/jsheets/shell/environment/StandardEnvironment.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/StandardEnvironment.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment;
+package jsheets.evaluation.shell.environment;
import java.net.InetAddress;
import java.util.Map;
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java
new file mode 100644
index 0000000..aab6520
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionControl.java
@@ -0,0 +1,129 @@
+package jsheets.evaluation.shell.environment.fork;
+
+import java.io.ObjectInput;
+import java.io.ObjectOutput;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import com.google.common.flogger.FluentLogger;
+
+import com.sun.jdi.VMDisconnectedException;
+import com.sun.jdi.VirtualMachine;
+import jdk.jshell.execution.JdiExecutionControl;
+import jsheets.evaluation.shell.environment.ClassFileStore;
+
+/*
+ * This class is based on the JdiDefaultExecutionControl and is highly
+ * coupled to the remote agent implementation.
+ */
+public final class ForkedExecutionControl extends JdiExecutionControl {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ private VirtualMachine machine;
+ private Process process;
+
+ private final Lock stopLock = new ReentrantLock();
+ private boolean userCodeRunning = false;
+
+ private final String remoteAgentClass;
+ private final ClassFileStore classFileStore;
+
+ ForkedExecutionControl(
+ ObjectOutput output,
+ ObjectInput input,
+ VirtualMachine machine,
+ Process process,
+ String remoteAgentClass,
+ ClassFileStore classFileStore
+ ) {
+ super(output, input);
+ this.machine = machine;
+ this.process = process;
+ this.remoteAgentClass = remoteAgentClass;
+ this.classFileStore = classFileStore;
+ }
+
+ @Override
+ protected synchronized VirtualMachine vm() throws EngineTerminationException {
+ if (machine == null) {
+ throw new EngineTerminationException("virtual machine is closed");
+ }
+ return machine;
+ }
+
+ @Override
+ public void load(ClassBytecodes[] bytecodes)
+ throws ClassInstallException, NotImplementedException, EngineTerminationException
+ {
+ classFileStore.load(bytecodes);
+ super.load(bytecodes);
+ }
+
+ @Override
+ public void redefine(ClassBytecodes[] bytecodes)
+ throws ClassInstallException, EngineTerminationException
+ {
+ classFileStore.redefine(bytecodes);
+ super.redefine(bytecodes);
+ }
+
+ @Override
+ public String invoke(String classname, String methodName)
+ throws RunException, EngineTerminationException, InternalException
+ {
+ String result;
+ updateUserCodeRunning(true);
+ try {
+ result = super.invoke(classname, methodName);
+ } finally {
+ updateUserCodeRunning(false);
+ }
+ return result;
+ }
+
+ private void updateUserCodeRunning(boolean target) {
+ stopLock.lock();
+ try {
+ userCodeRunning = target;
+ } finally {
+ stopLock.unlock();
+ }
+ }
+
+ @Override
+ public void stop() throws EngineTerminationException, InternalException {
+ stopLock.lock();
+ try {
+ if (userCodeRunning) {
+ new RemoteInterrupt(vm(), remoteAgentClass).runInSuspendedMode();
+ }
+ } finally {
+ stopLock.unlock();
+ }
+ }
+
+
+ @Override
+ public void close() {
+ super.close();
+ disposeMachine();
+ }
+
+ synchronized void disposeMachine() {
+ try {
+ if (machine != null) {
+ machine.dispose();
+ machine = null;
+ }
+ } catch (VMDisconnectedException alreadyClosed) {
+ log.atFiner().withCause(alreadyClosed).log("remote is already closed");
+ } catch (Throwable failure) {
+ log.atFine().withCause(failure).log("failed to dispose remote");
+ } finally {
+ if (process != null) {
+ process.destroy();
+ process = null;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java
new file mode 100644
index 0000000..4511e5a
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkedExecutionEnvironment.java
@@ -0,0 +1,40 @@
+package jsheets.evaluation.shell.environment.fork;
+
+import java.util.Collection;
+import java.util.Objects;
+
+import jdk.jshell.spi.ExecutionControlProvider;
+import jsheets.evaluation.shell.environment.ClassFileStore;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
+
+public final class ForkedExecutionEnvironment implements ExecutionEnvironment {
+ public static ForkedExecutionEnvironment create(
+ ClassFileStore store,
+ Collection virtualMachineOptions
+ ) {
+ Objects.requireNonNull(store, "store");
+ Objects.requireNonNull(virtualMachineOptions, "virtualMachineOptions");
+ return new ForkedExecutionEnvironment(store, virtualMachineOptions);
+ }
+
+ private final ClassFileStore store;
+ private final Collection virtualMachineOptions;
+
+ private ForkedExecutionEnvironment(
+ ClassFileStore store,
+ Collection virtualMachineOptions
+ ) {
+ this.store = store;
+ this.virtualMachineOptions = virtualMachineOptions;
+ }
+
+ @Override
+ public ExecutionControlProvider control(String name) {
+ return ForkingExecutionControlProvider.create(virtualMachineOptions, store);
+ }
+
+ @Override
+ public Installation install() {
+ return () -> {};
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java
new file mode 100644
index 0000000..dd4adeb
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/ForkingExecutionControlProvider.java
@@ -0,0 +1,181 @@
+package jsheets.evaluation.shell.environment.fork;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInput;
+import java.io.ObjectOutput;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+
+import com.sun.jdi.VirtualMachine;
+import jdk.jshell.execution.JdiInitiator;
+import jdk.jshell.execution.RemoteExecutionControl;
+import jdk.jshell.execution.Util;
+import jdk.jshell.spi.ExecutionControl;
+import jdk.jshell.spi.ExecutionControlProvider;
+import jdk.jshell.spi.ExecutionEnv;
+import jsheets.evaluation.shell.environment.ClassFileStore;
+import jsheets.evaluation.shell.environment.EmptyClassFileStore;
+
+import static jdk.jshell.execution.Util.remoteInputOutput;
+
+public final class ForkingExecutionControlProvider
+ implements ExecutionControlProvider {
+
+ private static final Duration defaultTimeout = Duration.ofMillis(3000);
+
+ public static ForkingExecutionControlProvider create() {
+ return create(List.of(), EmptyClassFileStore.create());
+ }
+
+ public static ForkingExecutionControlProvider create(
+ Collection rawVirtualMachineOptions,
+ ClassFileStore classFileStore
+ ) {
+ Objects.requireNonNull(classFileStore, "classFileStore");
+ Objects.requireNonNull(rawVirtualMachineOptions, "rawVirtualMachineOptions");
+ var host = InetAddress.getLoopbackAddress().getHostName();
+ return new ForkingExecutionControlProvider(
+ host,
+ defaultTimeout,
+ List.copyOf(rawVirtualMachineOptions),
+ classFileStore
+ );
+ }
+
+ private final String host;
+ private final Duration timeout;
+ private final List rawVirtualMachineOptions;
+ private final ClassFileStore classFileStore;
+
+ private ForkingExecutionControlProvider(
+ String host,
+ Duration timeout,
+ List rawVirtualMachineOptions,
+ ClassFileStore classFileStore
+ ) {
+ this.host = host;
+ this.timeout = timeout;
+ this.rawVirtualMachineOptions = rawVirtualMachineOptions;
+ this.classFileStore = classFileStore;
+ }
+
+ @Override
+ public String name() {
+ return "forking-execution-control-provider";
+ }
+
+ @Override
+ public Map defaultParameters() {
+ return Map.of();
+ }
+
+ @Override
+ public ExecutionControl generate(
+ ExecutionEnv environment,
+ Map specialParameters
+ ) throws IOException {
+ return create(environment);
+ }
+
+ record Box(VirtualMachine machine, Process process) {}
+
+ private static final String remoteAgentClassName =
+ RemoteExecutionControl.class.getName();
+
+ private Box initiate(int port) {
+ var initiator = new JdiInitiator(
+ /* port */ port,
+ /* options */ rawVirtualMachineOptions,
+ /* remoteAgentClassName */ remoteAgentClassName,
+ /* controlledLaunch */ false,
+ /* host */ "",
+ /* timeout */ (int) timeout.toMillis(),
+ /* connectorOptions*/ Collections.emptyMap()
+ );
+ return new Box(
+ initiator.vm(),
+ initiator.process()
+ );
+ }
+
+ private static final int backlog = 1;
+
+ ExecutionControl create(ExecutionEnv environment) throws IOException {
+ var address = InetAddress.getLoopbackAddress();
+ try (var listener = new ServerSocket(0, backlog, address)) {
+ listener.setSoTimeout((int) timeout.toMillis());
+ var box = initiate(listener.getLocalPort());
+ return accept(listener, environment, box);
+ }
+ }
+
+ private ExecutionControl accept(
+ ServerSocket listener,
+ ExecutionEnv environment,
+ Box box
+ ) throws IOException {
+ var hooks = registerCloseHooks(box.machine());
+ var socket = listener.accept();
+ var output = socket.getOutputStream();
+ return remoteInputOutput(
+ socket.getInputStream(),
+ output,
+ createOutputs(environment),
+ createInputs(environment),
+ createControl(box, environment, hooks)
+ );
+ }
+
+ private Collection> registerCloseHooks(VirtualMachine machine) {
+ var hooks = new CopyOnWriteArrayList>();
+ Util.detectJdiExitEvent(machine, event -> {
+ for (var hook : hooks) {
+ hook.accept(event);
+ }
+ });
+ return hooks;
+ }
+
+ private BiFunction createControl(
+ Box box,
+ ExecutionEnv environment,
+ Collection> hooks
+ ) {
+ return (input, output) -> {
+ var control = new ForkedExecutionControl(
+ output,
+ input,
+ box.machine(),
+ box.process(),
+ remoteAgentClassName,
+ classFileStore
+ );
+ hooks.add(event -> environment.closeDown());
+ hooks.add(event -> control.disposeMachine());
+ return control;
+ };
+ }
+
+ private Map createOutputs(ExecutionEnv environment) {
+ return Map.of(
+ "out", environment.userOut(),
+ "err", environment.userErr()
+ );
+ }
+
+ private Map createInputs(ExecutionEnv environment) {
+ return Map.of("in", environment.userIn());
+ }
+}
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/RemoteInterrupt.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/RemoteInterrupt.java
new file mode 100644
index 0000000..4d05c7d
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/fork/RemoteInterrupt.java
@@ -0,0 +1,94 @@
+package jsheets.evaluation.shell.environment.fork;
+
+import java.util.Objects;
+import java.util.Set;
+
+import com.google.common.flogger.FluentLogger;
+
+import com.sun.jdi.BooleanValue;
+import com.sun.jdi.ObjectReference;
+import com.sun.jdi.StackFrame;
+import com.sun.jdi.ThreadReference;
+import com.sun.jdi.VirtualMachine;
+import jdk.jshell.spi.ExecutionControl;
+
+final class RemoteInterrupt {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ private final VirtualMachine virtualMachine;
+ private final String remoteAgentClass;
+
+ RemoteInterrupt(VirtualMachine virtualMachine, String remoteAgentClass) {
+ this.virtualMachine = virtualMachine;
+ this.remoteAgentClass = remoteAgentClass;
+ }
+
+ public void runInSuspendedMode() throws ExecutionControl.InternalException {
+ virtualMachine.suspend();
+ try {
+ run();
+ } catch (Exception failure) {
+ throw new ExecutionControl.InternalException(
+ "failed to stop remote execution: %s".formatted(failure)
+ );
+ } finally {
+ virtualMachine.resume();
+ }
+ }
+
+ public void run() throws Exception {
+ for (var thread : virtualMachine.allThreads()) {
+ if (shouldIgnoreThread(thread)) {
+ continue;
+ }
+ for (var frame : thread.frames()) {
+ if (isRemoteAgentFrame(frame)) {
+ var instance = frame.thisObject();
+ Objects.requireNonNull(instance, "frame instance is null");
+ closeRemoteAgentThread(thread, instance);
+ }
+ }
+ }
+ }
+
+ private static final Set agentMethods = Set.of("invoke", "varValue");
+
+ private boolean isRemoteAgentFrame(StackFrame frame) {
+ var location = frame.location();
+ if (!remoteAgentClass.equals(location.declaringType().name())) {
+ return false;
+ }
+ var methodName = location.method().name();
+ return agentMethods.contains(methodName);
+ }
+
+ /* Threads created by user code can and should be ignored */
+ private boolean shouldIgnoreThread(ThreadReference thread) {
+ log.atInfo().log("not ignoring thread " + thread);
+ return false;
+ }
+
+ /*
+ * Closes the remote agent thread by manipulating the fields that are
+ * controlling its execution state and throwing an asynchronous exception.
+ * This code is highly coupled with the agent implementation running on the
+ * remote end.
+ */
+ private void closeRemoteAgentThread(
+ ThreadReference thread,
+ ObjectReference frame
+ ) throws Exception {
+ var inClientCodeField = frame.referenceType().fieldByName("inClientCode");
+ var expectingStopField = frame.referenceType().fieldByName("expectingStop");
+ var stopExceptionField = frame.referenceType().fieldByName("stopException");
+ var inClientCode = (BooleanValue) frame.getValue(inClientCodeField);
+ if (inClientCode.value()) {
+ frame.setValue(expectingStopField, virtualMachine.mirrorOf(true));
+ var stopInstance = (ObjectReference) frame.getValue(stopExceptionField);
+ virtualMachine.resume();
+ log.atFine().log("attempting to stop the client code execution");
+ thread.stop(stopInstance);
+ frame.setValue(expectingStopField, virtualMachine.mirrorOf(false));
+ }
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/shell/environment/inprocess/EmbeddedEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/EmbeddedEnvironment.java
similarity index 93%
rename from runtime/src/main/java/jsheets/shell/environment/inprocess/EmbeddedEnvironment.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/EmbeddedEnvironment.java
index fb126f2..2b4bb67 100644
--- a/runtime/src/main/java/jsheets/shell/environment/inprocess/EmbeddedEnvironment.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/EmbeddedEnvironment.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment.inprocess;
+package jsheets.evaluation.shell.environment.inprocess;
import java.util.Map;
@@ -6,7 +6,7 @@
import jdk.jshell.spi.ExecutionControlProvider;
import jdk.jshell.spi.ExecutionEnv;
import jsheets.output.TenantBasedOutput;
-import jsheets.shell.environment.ExecutionEnvironment;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
/**
* Executing multiple shells in a shared process can have many negative
diff --git a/runtime/src/main/java/jsheets/shell/environment/inprocess/InProcessExecutionControl.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/InProcessExecutionControl.java
similarity index 98%
rename from runtime/src/main/java/jsheets/shell/environment/inprocess/InProcessExecutionControl.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/InProcessExecutionControl.java
index 17074cb..439c537 100644
--- a/runtime/src/main/java/jsheets/shell/environment/inprocess/InProcessExecutionControl.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/InProcessExecutionControl.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment.inprocess;
+package jsheets.evaluation.shell.environment.inprocess;
import java.lang.reflect.Method;
import java.util.Objects;
diff --git a/runtime/src/main/java/jsheets/shell/environment/inprocess/MultiTenancy.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/MultiTenancy.java
similarity index 96%
rename from runtime/src/main/java/jsheets/shell/environment/inprocess/MultiTenancy.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/MultiTenancy.java
index 50efa54..46dc26f 100644
--- a/runtime/src/main/java/jsheets/shell/environment/inprocess/MultiTenancy.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/MultiTenancy.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment.inprocess;
+package jsheets.evaluation.shell.environment.inprocess;
import java.io.PrintStream;
import java.util.function.Consumer;
diff --git a/runtime/src/main/java/jsheets/shell/environment/inprocess/Preemption.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Preemption.java
similarity index 75%
rename from runtime/src/main/java/jsheets/shell/environment/inprocess/Preemption.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Preemption.java
index 81d5a48..1c2e7d9 100644
--- a/runtime/src/main/java/jsheets/shell/environment/inprocess/Preemption.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Preemption.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment.inprocess;
+package jsheets.evaluation.shell.environment.inprocess;
/**
* Thrown when the execution is preempted using {@link InProcessExecutionControl#stop()}.
diff --git a/runtime/src/main/java/jsheets/shell/environment/inprocess/Tenancy.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Tenancy.java
similarity index 70%
rename from runtime/src/main/java/jsheets/shell/environment/inprocess/Tenancy.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Tenancy.java
index bf6f910..2f3cbc6 100644
--- a/runtime/src/main/java/jsheets/shell/environment/inprocess/Tenancy.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/inprocess/Tenancy.java
@@ -1,4 +1,4 @@
-package jsheets.shell.environment.inprocess;
+package jsheets.evaluation.shell.environment.inprocess;
import jdk.jshell.spi.ExecutionEnv;
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxClassFileCheck.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxClassFileCheck.java
new file mode 100644
index 0000000..b2c0785
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxClassFileCheck.java
@@ -0,0 +1,38 @@
+package jsheets.evaluation.shell.environment.sandbox;
+
+import java.util.Collection;
+import java.util.Objects;
+
+import jdk.jshell.spi.ExecutionControl;
+import jsheets.evaluation.sandbox.SandboxBytecodeCheck;
+import jsheets.evaluation.sandbox.validation.Analysis;
+import jsheets.evaluation.sandbox.validation.Rule;
+import jsheets.evaluation.shell.environment.ClassFileStore;
+
+public final class SandboxClassFileCheck implements ClassFileStore {
+ public static SandboxClassFileCheck of(Collection rules) {
+ Objects.requireNonNull(rules, "rules");
+ return new SandboxClassFileCheck(rules);
+ }
+
+ private final Collection rules;
+
+ private SandboxClassFileCheck(Collection rules) {
+ this.rules = rules;
+ }
+
+ @Override
+ public void redefine(ExecutionControl.ClassBytecodes[] bytecodes) {
+ var analysis = Analysis.create();
+ var check = SandboxBytecodeCheck.withRules(rules);
+ for (var binary : bytecodes) {
+ check.run(analysis, binary.bytecodes());
+ }
+ analysis.reportViolations();
+ }
+
+ @Override
+ public void load(ExecutionControl.ClassBytecodes[] bytecodes) {
+
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxedEnvironment.java b/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxedEnvironment.java
new file mode 100644
index 0000000..82f6408
--- /dev/null
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/environment/sandbox/SandboxedEnvironment.java
@@ -0,0 +1,53 @@
+package jsheets.evaluation.shell.environment.sandbox;
+
+import jdk.jshell.execution.DirectExecutionControl;
+import jdk.jshell.spi.ExecutionControl;
+import jdk.jshell.spi.ExecutionControlProvider;
+import jdk.jshell.spi.ExecutionEnv;
+import jsheets.evaluation.shell.environment.ClassFileStore;
+import jsheets.evaluation.shell.environment.ClassFileStoreLoader;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
+import jsheets.evaluation.sandbox.validation.Rule;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+public final class SandboxedEnvironment
+ implements ExecutionEnvironment, ExecutionControlProvider {
+
+ public static SandboxedEnvironment create(Collection rules) {
+ Objects.requireNonNull(rules);
+ return new SandboxedEnvironment(() -> SandboxClassFileCheck.of(rules));
+ }
+
+ private final Supplier loader;
+
+ private SandboxedEnvironment(Supplier loader) {
+ this.loader = loader;
+ }
+
+ @Override
+ public Installation install() {
+ return () -> {};
+ }
+
+ @Override
+ public ExecutionControlProvider control(String name) {
+ return this;
+ }
+
+ @Override
+ public String name() {
+ return "sandbox";
+ }
+
+ @Override
+ public ExecutionControl generate(
+ ExecutionEnv environment,
+ Map parameters
+ ) {
+ return new DirectExecutionControl(ClassFileStoreLoader.of(loader.get()));
+ }
+}
diff --git a/runtime/src/main/java/jsheets/shell/execution/DirectExecution.java b/evaluation/src/main/java/jsheets/evaluation/shell/execution/DirectExecution.java
similarity index 93%
rename from runtime/src/main/java/jsheets/shell/execution/DirectExecution.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/execution/DirectExecution.java
index 9186b86..15d2b67 100644
--- a/runtime/src/main/java/jsheets/shell/execution/DirectExecution.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/execution/DirectExecution.java
@@ -1,4 +1,4 @@
-package jsheets.shell.execution;
+package jsheets.evaluation.shell.execution;
import java.util.Collection;
import java.util.Objects;
diff --git a/runtime/src/main/java/jsheets/shell/execution/ExecutionMethod.java b/evaluation/src/main/java/jsheets/evaluation/shell/execution/ExecutionMethod.java
similarity index 84%
rename from runtime/src/main/java/jsheets/shell/execution/ExecutionMethod.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/execution/ExecutionMethod.java
index f229f45..d1bc198 100644
--- a/runtime/src/main/java/jsheets/shell/execution/ExecutionMethod.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/execution/ExecutionMethod.java
@@ -1,4 +1,4 @@
-package jsheets.shell.execution;
+package jsheets.evaluation.shell.execution;
import java.util.Collection;
diff --git a/runtime/src/main/java/jsheets/shell/execution/ExhaustiveExecution.java b/evaluation/src/main/java/jsheets/evaluation/shell/execution/ExhaustiveExecution.java
similarity index 96%
rename from runtime/src/main/java/jsheets/shell/execution/ExhaustiveExecution.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/execution/ExhaustiveExecution.java
index f85d40f..5ae62aa 100644
--- a/runtime/src/main/java/jsheets/shell/execution/ExhaustiveExecution.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/execution/ExhaustiveExecution.java
@@ -1,4 +1,4 @@
-package jsheets.shell.execution;
+package jsheets.evaluation.shell.execution;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
@@ -267,7 +267,12 @@ static T run(ResolveTask task) {
gutsAccessor = resolveGutsAccessor();
} catch (Throwable failure) {
resolveError = failure.getMessage();
- log.atWarning().withCause(failure).log("failed to initialize");
+ if (failure.getCause() instanceof IllegalAccessException) {
+ log.atWarning()
+ .log("ExhaustiveExecution is not possible: jdk.jshell is not open");
+ } else {
+ log.atWarning().withCause(failure).log("failed to initialize");
+ }
}
}
}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/shell/execution/SystemBasedExecutionMethodFactory.java b/evaluation/src/main/java/jsheets/evaluation/shell/execution/SystemBasedExecutionMethodFactory.java
similarity index 92%
rename from runtime/src/main/java/jsheets/shell/execution/SystemBasedExecutionMethodFactory.java
rename to evaluation/src/main/java/jsheets/evaluation/shell/execution/SystemBasedExecutionMethodFactory.java
index 6fd9c3c..afe4210 100644
--- a/runtime/src/main/java/jsheets/shell/execution/SystemBasedExecutionMethodFactory.java
+++ b/evaluation/src/main/java/jsheets/evaluation/shell/execution/SystemBasedExecutionMethodFactory.java
@@ -1,4 +1,4 @@
-package jsheets.shell.execution;
+package jsheets.evaluation.shell.execution;
import jdk.jshell.JShell;
diff --git a/runtime/src/main/java/jsheets/output/CapturingOutput.java b/evaluation/src/main/java/jsheets/output/CapturingOutput.java
similarity index 100%
rename from runtime/src/main/java/jsheets/output/CapturingOutput.java
rename to evaluation/src/main/java/jsheets/output/CapturingOutput.java
diff --git a/runtime/src/main/java/jsheets/output/ListeningPrintStream.java b/evaluation/src/main/java/jsheets/output/ListeningPrintStream.java
similarity index 100%
rename from runtime/src/main/java/jsheets/output/ListeningPrintStream.java
rename to evaluation/src/main/java/jsheets/output/ListeningPrintStream.java
diff --git a/runtime/src/main/java/jsheets/output/TenantBasedOutput.java b/evaluation/src/main/java/jsheets/output/TenantBasedOutput.java
similarity index 100%
rename from runtime/src/main/java/jsheets/output/TenantBasedOutput.java
rename to evaluation/src/main/java/jsheets/output/TenantBasedOutput.java
diff --git a/runtime/src/main/java/jsheets/source/SharedSources.java b/evaluation/src/main/java/jsheets/source/SharedSources.java
similarity index 100%
rename from runtime/src/main/java/jsheets/source/SharedSources.java
rename to evaluation/src/main/java/jsheets/source/SharedSources.java
diff --git a/evaluation/src/test/java/jsheets/config/CamelCaseTest.java b/evaluation/src/test/java/jsheets/config/CamelCaseTest.java
new file mode 100644
index 0000000..6f7db17
--- /dev/null
+++ b/evaluation/src/test/java/jsheets/config/CamelCaseTest.java
@@ -0,0 +1,53 @@
+package jsheets.config;
+
+import java.util.function.Function;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import jsheets.config.CamelCase.Case;
+
+public class CamelCaseTest {
+ @Test
+ public void emptyReturnsSameInstance() {
+ var input = new String(new char[0]); // no interning
+ Assertions.assertSame(input, CamelCase.convert(input, "_", Case.upper()));
+ }
+
+ @Test
+ public void singleCharacterIsConvertedInCase() {
+ Assertions.assertEquals("a", CamelCase.convert("A", "_", Case.lower()));
+ Assertions.assertEquals("a", CamelCase.convert("a", "_", Case.lower()));
+ }
+
+ @Test
+ public void testToSnakeCase() {
+ Function toSnakeCase = input ->
+ CamelCase.convert(input, "_", Case.lower());
+
+ Assertions.assertEquals("a_bc", toSnakeCase.apply("aBC"));
+ Assertions.assertEquals("g_rpc", toSnakeCase.apply("gRpc"));
+ Assertions.assertEquals("g_rpc", toSnakeCase.apply("gRPC"));
+ Assertions.assertEquals("my_sql", toSnakeCase.apply("MySql"));
+ Assertions.assertEquals("queen", toSnakeCase.apply("QUEEN"));
+ Assertions.assertEquals(
+ "the_sun_is_burning",
+ toSnakeCase.apply("TheSunIsBurning")
+ );
+ Assertions.assertEquals(
+ "simon_and_garfunkel",
+ toSnakeCase.apply("simonAndGarfunkel")
+ );
+ }
+
+ @Test
+ public void testToScreamingCase() {
+ Function toScreamingCase = input ->
+ CamelCase.convert(input, "_", Case.upper());
+
+ Assertions.assertEquals("LUKE_KELLY", toScreamingCase.apply("LukeKelly"));
+ Assertions.assertEquals("LUKE_KELLY", toScreamingCase.apply("lukeKelly"));
+ Assertions.assertEquals("LUKE_KELLY", toScreamingCase.apply("luke_kelly"));
+ Assertions.assertEquals("ABC", toScreamingCase.apply("abc"));
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/test/java/jsheets/config/EnvironmentConfigTest.java b/evaluation/src/test/java/jsheets/config/EnvironmentConfigTest.java
new file mode 100644
index 0000000..a8c8bb7
--- /dev/null
+++ b/evaluation/src/test/java/jsheets/config/EnvironmentConfigTest.java
@@ -0,0 +1,62 @@
+package jsheets.config;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class EnvironmentConfigTest {
+ @Test
+ public void testLookup() {
+ var config = new EnvironmentConfig(
+ "MIRIAM_",
+ Map.of(
+ "MIRIAM_TASTE_FAVOURITE_SONG", "A Poem on the Underground Wall",
+ "MIRIAM_TASTE_FAVOURITE_COLOR", "Teal",
+ "MIRIAM_TASTE_WORST_MOVIE", "Titanic",
+ "MIRIAM_PHONE_MAKE", "jMobile"
+ )
+ );
+ Assertions.assertEquals(
+ "A Poem on the Underground Wall",
+ config.lookup(Config.Key.ofString("taste.favouriteSong")).require()
+ );
+ Assertions.assertEquals(
+ "jMobile",
+ config.lookup(Config.Key.ofString("phone.make")).require()
+ );
+ Assertions.assertFalse(
+ config.lookup(Config.Key.ofString("phone.color")).exists()
+ );
+ Assertions.assertTrue(
+ config.lookup(Config.Key.ofString("taste.worstMovie")).exists()
+ );
+ Assertions.assertFalse(
+ config.lookup(Config.Key.ofString("miriam.taste.worstMovie")).exists()
+ );
+ }
+
+ @Test
+ public void testTranslateKey() {
+ Assertions.assertEquals(
+ "OPINION_THE_BEST_SONG",
+ EnvironmentConfig.translateKey("opinion.theBestSong")
+ );
+ Assertions.assertEquals(
+ "ARTIST_NINA_SIMONE_BLUES",
+ EnvironmentConfig.translateKey("artist.nina_simone.blues")
+ );
+ Assertions.assertEquals(
+ "PLAYBACK_SONG_NAME",
+ EnvironmentConfig.translateKey("playback.song.name")
+ );
+ Assertions.assertEquals(
+ "PLAYBACK_SONG_NAME",
+ EnvironmentConfig.translateKey("playback.songName")
+ );
+ Assertions.assertEquals(
+ "PLAYBACK_SONG_NAME",
+ EnvironmentConfig.translateKey("playbackSongName")
+ );
+ }
+}
\ No newline at end of file
diff --git a/evaluation/src/test/java/jsheets/evaluation/sandbox/SandboxExecutionTest.java b/evaluation/src/test/java/jsheets/evaluation/sandbox/SandboxExecutionTest.java
new file mode 100644
index 0000000..7d648cb
--- /dev/null
+++ b/evaluation/src/test/java/jsheets/evaluation/sandbox/SandboxExecutionTest.java
@@ -0,0 +1,51 @@
+package jsheets.evaluation.sandbox;
+
+import jdk.jshell.JShell;
+import jsheets.evaluation.sandbox.access.AccessGraph;
+import jsheets.evaluation.sandbox.validation.Analysis;
+import jsheets.evaluation.sandbox.validation.ForbiddenMemberFilter;
+import jsheets.evaluation.shell.environment.sandbox.SandboxedEnvironment;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+public final class SandboxExecutionTest {
+ @Test
+ public void testPermitted() {
+ var shell = createSandboxedShell();
+ shell.eval("System.out.println(\"Hello, World!\")");
+ }
+
+ @Test
+ public void testDenied() {
+ var shell = createSandboxedShell();
+ try {
+ shell.eval("System.err.println(\"Hello, World!\")");
+ } catch (Exception failure) {
+ var violations = Analysis.captureViolations(failure).toList();
+ Assertions.assertEquals(
+ List.of(
+ new ForbiddenMemberFilter.ForbiddenField("java.lang.System", "err")
+ ),
+ violations
+ );
+ }
+ }
+
+ private JShell createSandboxedShell() {
+ var accessGraph = AccessGraph.of(
+ "java.lang.Object",
+ "java.lang.System.out",
+ "java.io.PrintStream#println"
+ );
+ return JShell.builder()
+ .out(System.out)
+ .err(System.err)
+ .executionEngine(
+ SandboxedEnvironment.create(List.of(ForbiddenMemberFilter.create(accessGraph))),
+ Map.of()
+ ).build();
+ }
+}
diff --git a/evaluation/src/test/java/jsheets/evaluation/sandbox/access/AccessGraphTest.java b/evaluation/src/test/java/jsheets/evaluation/sandbox/access/AccessGraphTest.java
new file mode 100644
index 0000000..56b733b
--- /dev/null
+++ b/evaluation/src/test/java/jsheets/evaluation/sandbox/access/AccessGraphTest.java
@@ -0,0 +1,87 @@
+package jsheets.evaluation.sandbox.access;
+
+import com.google.common.base.Charsets;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.List;
+
+import static jsheets.evaluation.sandbox.access.AccessKey.*;
+
+public class AccessGraphTest {
+ @Test
+ public void testReadingFromFile() {
+ var specification = readFileContent("accessGraph.txt");
+ var graph = AccessGraph.of(specification.split("\n"));
+ Assertions.assertFalse(graph.isPermitted(dotSeparated("a.b.c")));
+ Assertions.assertTrue(graph.isPermitted(dotSeparated("a.b.c.Foo")));
+ Assertions.assertTrue(graph.isPermitted(dotSeparated("a.b.c.Foo#run")));
+ Assertions.assertFalse(graph.isPermitted(dotSeparated("a.b.c.Unknown")));
+ Assertions.assertFalse(graph.isPermitted(dotSeparated("a.b.c.Foo#exit")));
+ Assertions.assertFalse(graph.isPermitted(dotSeparated("a.b.c.Bar")));
+ Assertions.assertFalse(graph.isPermitted(dotSeparated("a.b.c.Bar#run")));
+ }
+
+ @Test
+ public void testOverloadedMethod() {
+ var graph = AccessGraph.of(
+ "a.b.c.Foo#run(String[], int):void"
+ );
+ Assertions.assertTrue(graph.isMethodPermitted(MethodSignature.parse("a.b.c.Foo#run(String[], int):void")));
+ Assertions.assertFalse(graph.isMethodPermitted(MethodSignature.parse("a.b.c.Foo#run():void")));
+ }
+
+ @Test
+ public void testKeySplit() {
+ Assertions.assertEquals(
+ List.of("a", "b", "c"),
+ List.of(dotSeparated("a.b.c").split())
+ );
+ Assertions.assertEquals(
+ List.of("a", "b", "c"),
+ List.of(slashSeparated("a/b/c").split())
+ );
+ Assertions.assertEquals(
+ List.of("a", "b", "c", "Foo"),
+ List.of(slashSeparated("a/b/c#Foo").split())
+ );
+ }
+
+ private String readFileContent(String path) {
+ var resource = Thread.currentThread()
+ .getContextClassLoader()
+ .getResourceAsStream(path);
+ if (resource == null) {
+ throw new RuntimeException(new FileNotFoundException());
+ }
+ try (resource) {
+ return new String(resource.readAllBytes(), Charsets.UTF_8);
+ } catch (IOException failure) {
+ throw new RuntimeException(failure);
+ }
+ }
+
+ @Test
+ public void testOf() {
+ var graph = AccessGraph.of(
+ "java.lang",
+ "java.util.List",
+ "java.lang.Thread#sleep",
+ "java.lang.System",
+ "!java.lang.System#exit"
+ );
+ Assertions.assertFalse(graph.isPermitted(slashSeparated("java/io")));
+ Assertions.assertTrue(graph.isPermitted(slashSeparated("java/lang")));
+ Assertions.assertTrue(graph.isPermitted(slashSeparated("java/util/List")));
+ Assertions.assertTrue(graph.isPermitted(slashSeparated("java/lang/System/out")));
+ Assertions.assertFalse(
+ graph.isMethodPermitted(MethodSignature.parse("java/lang/System#exit"))
+ );
+ Assertions.assertTrue(
+ graph.isMethodPermitted(MethodSignature.parse("java/lang/Thread#sleep()"))
+ );
+ }
+}
diff --git a/evaluation/src/test/java/jsheets/evaluation/sandbox/access/MethodSignatureTest.java b/evaluation/src/test/java/jsheets/evaluation/sandbox/access/MethodSignatureTest.java
new file mode 100644
index 0000000..906b006
--- /dev/null
+++ b/evaluation/src/test/java/jsheets/evaluation/sandbox/access/MethodSignatureTest.java
@@ -0,0 +1,30 @@
+package jsheets.evaluation.sandbox.access;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+public class MethodSignatureTest {
+ @Test
+ public void testParsing() {
+ Assertions.assertEquals(
+ MethodSignatureBuilder.builder()
+ .className("java.lang.System")
+ .methodName("exit")
+ .returnType("void")
+ .parameterTypes(List.of("int"))
+ .build(),
+ MethodSignature.parse("java/lang/System#exit(int):void")
+ );
+ Assertions.assertEquals(
+ MethodSignatureBuilder.builder()
+ .className("java.lang.String")
+ .methodName("join")
+ .returnType("*")
+ .parameterTypes(List.of("java.lang.CharSequence", "java.lang.Iterable"))
+ .build(),
+ MethodSignature.parse("java/lang/String#join(java/lang/CharSequence, java/lang/Iterable)")
+ );
+ }
+}
diff --git a/runtime/src/test/java/jsheets/shell/ExhaustiveExecutionTest.java b/evaluation/src/test/java/jsheets/evaluation/shell/ExhaustiveExecutionTest.java
similarity index 95%
rename from runtime/src/test/java/jsheets/shell/ExhaustiveExecutionTest.java
rename to evaluation/src/test/java/jsheets/evaluation/shell/ExhaustiveExecutionTest.java
index 3c932f7..f5ce0f5 100644
--- a/runtime/src/test/java/jsheets/shell/ExhaustiveExecutionTest.java
+++ b/evaluation/src/test/java/jsheets/evaluation/shell/ExhaustiveExecutionTest.java
@@ -1,9 +1,9 @@
-package jsheets.shell;
+package jsheets.evaluation.shell;
import jdk.jshell.JShell;
import jdk.jshell.Snippet;
import jsheets.output.CapturingOutput;
-import jsheets.shell.execution.ExhaustiveExecution;
+import jsheets.evaluation.shell.execution.ExhaustiveExecution;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -69,7 +69,6 @@ public void testEmpty() {
public void testInvalid() {
var events = ExhaustiveExecution.create(createSilentShell())
.execute("callWithoutClosedParen(someArgument, 1, 2, 3");
- System.out.println(events);
Assertions.assertEquals(1, events.size());
}
}
\ No newline at end of file
diff --git a/runtime/src/test/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngineTest.java b/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java
similarity index 91%
rename from runtime/src/test/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngineTest.java
rename to evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java
index 85632a4..c26d359 100644
--- a/runtime/src/test/java/jsheets/runtime/evaluation/shell/ShellEvaluationEngineTest.java
+++ b/evaluation/src/test/java/jsheets/evaluation/shell/ShellEvaluationEngineTest.java
@@ -1,6 +1,5 @@
-package jsheets.runtime.evaluation.shell;
+package jsheets.evaluation.shell;
-import java.time.Clock;
import java.util.UUID;
import jsheets.EvaluateResponse;
@@ -8,8 +7,8 @@
import jsheets.Snippet;
import jsheets.SnippetSources;
import jsheets.StartEvaluationRequest;
-import jsheets.runtime.evaluation.Evaluation;
-import jsheets.shell.environment.StandardEnvironment;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.shell.environment.StandardEnvironment;
import org.junit.jupiter.api.Test;
/* This is not a Unit Test */
@@ -27,7 +26,6 @@ public void testExecution() {
var environment = StandardEnvironment.create();
var installation = environment.install();
var engine = ShellEvaluationEngine.newBuilder()
- .useClock(Clock.systemUTC())
.useEnvironment(environment)
.useWorkerPool(Runnable::run)
.create();
diff --git a/runtime/src/test/java/jsheets/output/TenantBasedOutputTest.java b/evaluation/src/test/java/jsheets/output/TenantBasedOutputTest.java
similarity index 100%
rename from runtime/src/test/java/jsheets/output/TenantBasedOutputTest.java
rename to evaluation/src/test/java/jsheets/output/TenantBasedOutputTest.java
diff --git a/evaluation/src/test/resources/accessGraph.txt b/evaluation/src/test/resources/accessGraph.txt
new file mode 100644
index 0000000..71243fa
--- /dev/null
+++ b/evaluation/src/test/resources/accessGraph.txt
@@ -0,0 +1,3 @@
+a.b.c.Foo
+!a.b.c.Foo#exit
+!a.b.c.Bar
\ No newline at end of file
diff --git a/protocol/build.gradle b/protocol/build.gradle
index a3d5377..53600e7 100644
--- a/protocol/build.gradle
+++ b/protocol/build.gradle
@@ -12,6 +12,8 @@ repositories {
}
dependencies {
+ compileOnly "io.grpc:grpc-all:$grpcVersion"
+ compileOnly "javax.annotation:javax.annotation-api:$javaxAnnotationVersion"
implementation "com.google.protobuf:protobuf-java:$protobufJavaVersion"
}
@@ -58,6 +60,7 @@ protobuf {
outputSubDir = 'js'
}
}
+ "grpc" {}
}
all()*.builtins {
"js" {
diff --git a/runtime/README.md b/runtime/README.md
new file mode 100644
index 0000000..f6f4faa
--- /dev/null
+++ b/runtime/README.md
@@ -0,0 +1,181 @@
+# Runtime
+The runtime component is responsible for evaluating Snippets,
+it exposes the
+[SnippetRuntime](../protocol/src/main/proto/jsheets/api/snippet_runtime.proto)
+as a [gRpc Service](https://grpc.io).
+Snippets are currently evaluated by an instrumented version of
+[JShell](https://docs.oracle.com/javase/9/jshell/introduction-jshell.htm) that
+is modified for secure execution of script-like code.
+Apart from the active *JShell* instances, the *Runtime* is **Stateless**,
+allowing it to be scaled
+horizontally. This is beneficial, as user code evaluation poses a chance
+for *JVM crashes*.
+
+### Running
+This component should be run using the docker image, since it requires
+special configuration for all features to work and does now bundle its
+dependencies in the build *jar archive*.
+#### Docker
+The image tag is `ehenoma/jsheets-runtime:latest` and is located in the
+[deploy](./deploy) folder.
+
+Following [Docker Compose](https://docs.docker.com/compose/) file is sufficient
+to run a configured instance of the runtime.
+
+```yml
+version: "3.7"
+services:
+ runtime:
+ image: ehenoma/jsheets-runtime:latest
+ container_name: jsheets-runtime
+ hostname: jsheets-runtime
+ environment:
+ JSHEETS_RUNTIME_SERVER_FEATURES_ENABLE_GRPC_REFLECTION: "true"
+ JSHEETS_RUNTIME_SERVER_SERVICE_ID: "my-only-service"
+ ports:
+ - "8080:8080"
+```
+
+#### Manual
+If you wish to run it manually, ensure that all required libraries are provided
+(in the runtime classpath) and open the `jdk.jshell` module to all unnamed modules:
+`--add-opens jdk.jshell/jdk.jshell=ALL-UNNAMED`, the latter is required to use
+the *exhaustive execution* feature.
+
+For more information inspect the image's [run script](./deploy/entrypoint.sh).
+
+### Configuration
+
+**NOTE** That every environment variable is prefixed with `JSHEETS_RUNTIME_`,
+thus `SERVER_PORT` has to be specified as `JSHEETS_RUNTIME_SERVER_PORT`.
+
+| Key | Environment Suffix | Default | Description |
+|-----|-------------|---------|-------------|
+| server.port | `SERVER_PORT` | `8080` | gRpc Server Port |
+| server.features.enableHealthService | `FEATURES_ENABLE_HEALTH_CHECK` | `true` | Toggles the Health Service |
+| server.features.enableGrpcReflection | `FEATURES_ENABLE_GRPC_REFLECTION` | `false` | Toggles the Health Service |
+|
+| service.id | `SERVICE_ID` | *generated* | Id that this service is advertised with |
+| service.advertisedHost | `SERVICE_ADVERTISED_HOST` | none | The endpoint that is advertised in the service discovery |
+| evaluation.sandbox.disable | `EVALUATION_SANDBOX_DISABLE` | `false` | Disables the sandbox for code execution **dangerous** |
+| zookeeper.connectionString | `ZOOKEEPER_CONNECTION_STRING` | none | Connection string to zookeeper |
+| zookeeper.connectBackoff | `ZOOKEEPER_CONNECT_BACKOFF` | `1000` | Initial backoff after failed zookeeper connection |
+
+### Sandboxing
+The JVM itself is a sufficient sandbox, if we restrict the methods
+that can be called to that of classes without side effects to the system
+and prevent `java.lang.reflect` and `java.lang.invoke`, code can
+barely do any direct harm (other than using too many resources).
+
+The [evaluation](../evaluation) module provides the
+`jsheets.evaluation.sandbox.access` library, that is used to restrict
+access to a given list of methods, fields and classes. It is configured
+using a text file that looks similar to a `.gitignore`:
+
+```
+java.util.List
+java.util.Collection
+java.lang.Thread#currentThread
+!java.lang.Object#wait
+```
+
+#### Format
+Method signatures can be written as follows:
+
+- `java.lang.Object#equals(java.lang.Object):boolean` is a full signature with
+parameter list and return type. It only matches methods that have the exact
+same class name, name, parameter and return types.
+
+- `java.lang.Object#equals(*):boolean` or `java.lang.Object#equals:boolean`
+has a wildcard parameter list. It matches any method that has the same class
+name, name and return type. This is especially useful for methods with many
+overloads.
+
+- `java.lang.Object#equals(java.lang.Object):*` or
+`java.lang.Object#equals(java.lang.Object)` has a wildcard return type. It
+matches any method that has the same class name, name and parameter types.
+
+- `java.lang.Object#equals:*` or `java.lang.Object#equals` has a wildcard
+parameter list and return type. It matches any method that has the same class
+name and name.
+
+#### Example
+Given the following class `Library` in package `evilcorp.coolib`
+```java
+package evilcorp.coolib;
+
+class Library {
+ int count(int[] integers) { /*...*/ }
+
+ int count(double[] doubles) { /*...*/ }
+
+ int count(float[] floats) { /*...*/ }
+
+ long count(int[][] twoDimensionIntegers) { /*...*/ }
+
+ void quit() {
+ System.exit(-1);
+ }
+
+ void quit(String message) {
+ System.err.println(message);
+ System.exit(-1);
+ }
+}
+```
+
+we can write the following access graph configs:
+
+```
+evilcorp.coolib.Library#count(*)*
+```
+Here we just enable the methods that we wish to call, but this would be
+cumbersome, if we had to do it for every method in a big library.
+Instead, we want to exclude the methods that are not allowed:
+```
+evilcorp.coolib
+!evilcorp.coolib.Library#quit
+```
+Now every class within `evilcorp.coolib` and every of their methods are
+allowed, with exception to any method in `evilcorp.coolib.Library` that
+is named `quit`.
+
+If this sounds too out of context to you, picture the `java.lang.System` class,
+which is a very central and useful class in java's standard library, it contains
+fields and methods that are essential to some programs that do not pose any
+security risk (like `System#identityHashCode(Object)`), but also methods like
+`System#exit(int)`, we would thus be very careful with granting access to
+this class.
+
+### Scaling
+Since the *runtime* does not save any data and its state only consists of
+the active evaluations, it can be scaled horizontally to **thousands** of
+instances.
+
+It is important to keep the evaluations per instance fairly low to
+reduce the amount of evaluations that are affected by crashes and
+lower usage of system resources (such as processors and memory).
+
+
+### Handling Crashes
+If the *runtime* crashes, it is taken out of the service discovery and will not
+receive any further requests from the *backend*. Current evaluations will time
+out and the backend can choose to retry them or report an error to the client.
+Given the deployment strategy, the instance may be recreated immediately
+afterwards and put back into the service discovery.
+
+### Future Planning
+
+The current evaluation model is limited:
+- Code is restricted (in what libraries and methods it uses)
+to prevent security issues and simulate a sandbox.
+- Crashes of individual evaluations result in a crash of the entire instance,
+thus preempting all other evaluations.
+
+But it provides a somewhat solid and definitely scalable solution for the
+first version of *JShell*.
+
+Future implementations may spawn [Containers](https://linuxcontainers.org/)
+for every *Sheet* or *Snippet* or *User*. Some features could also be
+limited to those who have a *user account*. This container could load some kind
+of state, provide access to a small (possibly persistent) file system and more.
\ No newline at end of file
diff --git a/runtime/build.gradle b/runtime/build.gradle
index 2eda29f..b9b1603 100644
--- a/runtime/build.gradle
+++ b/runtime/build.gradle
@@ -1,27 +1,95 @@
plugins {
id 'java'
+ id 'application'
}
group 'dev.jsheets'
version '0.1.0'
+repositories {
+ mavenCentral()
+}
+
sourceCompatibility = 16
targetCompatibility = 16
-repositories {
- mavenCentral()
+ext {
+ mongoDbDriverVersion = '4.3.2'
+ cfg4jVersion = '4.4.1'
+ slf4jVersion = '1.7.32'
+ javalinVersion = '4.1.1'
+ argsParseVersion = '0.9.0'
+ guiceVersion = '5.0.1'
}
dependencies {
implementation project(':protocol')
+ implementation project(':evaluation')
+ implementation "org.apache.curator:curator-x-discovery:$curatorVersion"
+ implementation "io.micrometer:micrometer-core:$micrometerVersion"
+ implementation "org.mongodb:mongodb-driver-sync:$mongoDbDriverVersion"
+ implementation "org.slf4j:slf4j-simple:$slf4jVersion"
+ implementation "io.grpc:grpc-all:$grpcVersion"
implementation "com.google.protobuf:protobuf-java-util:$protobufJavaVersion"
+ implementation "net.sourceforge.argparse4j:argparse4j:$argsParseVersion"
+ implementation "javax.annotation:javax.annotation-api:$javaxAnnotationVersion"
implementation "com.google.flogger:flogger:$floggerVersion"
+ implementation "com.google.flogger:flogger-slf4j-backend:$floggerVersion"
+ implementation "com.google.inject:guice:$guiceVersion"
+ compileOnly "io.soabase.record-builder:record-builder-core:$recordBuilderVersion"
+ annotationProcessor "io.soabase.record-builder:record-builder-processor:$recordBuilderVersion"
testImplementation "org.junit.jupiter:junit-jupiter-api:$junitPlatformVersion"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitPlatformVersion"
- testRuntimeOnly "com.google.flogger:flogger-slf4j-backend:$floggerVersion"
}
test {
useJUnitPlatform()
- jvmArgs += ['--add-opens', 'jdk.jshell/jdk.jshell=ALL-UNNAMED']
-}
\ No newline at end of file
+}
+
+mainClassName = 'jsheets.runtime.App'
+
+task copyDependencies(type: Copy) {
+ from configurations.runtimeClasspath
+ into "$buildDir/libs"
+}
+
+private def listClassPath() {
+ return configurations.runtimeClasspath.collect { "libs/${it.name}" }.join(" ")
+}
+
+processResources {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+}
+
+jar {
+ archiveName 'app.jar'
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+ manifest {
+ attributes(
+ "Main-Class": "jsheets.runtime.App",
+ "Class-Path": listClassPath()
+ )
+ }
+}
+
+def dockerImageName = "ehenoma/jsheets-runtime"
+def dockerImageTag = System.getenv("TARGET_IMAGE_TAG") || "latest"
+def dockerImage = "$dockerImageName:$dockerImageTag"
+
+task buildDocker(type: Exec) {
+ dependsOn copyDependencies, build
+ workingDir "$projectDir"
+ commandLine "docker", "build", "--rm", ".", "-t", dockerImage, "-f", "./runtime/deploy/Dockerfile"
+}
+
+task justRunDocker(type: Exec) {
+ workingDir "$projectDir"
+ commandLine "docker", "run", dockerImage, "-p", "8080:8080"
+}
+
+task runDocker(type: Exec) {
+ dependsOn buildDocker
+ workingDir "$projectDir"
+ commandLine "docker", "run", dockerImage, "-p", "8080:8080"
+}
+
diff --git a/runtime/deploy/Dockerfile b/runtime/deploy/Dockerfile
new file mode 100644
index 0000000..7b51c07
--- /dev/null
+++ b/runtime/deploy/Dockerfile
@@ -0,0 +1,47 @@
+FROM openjdk:17-alpine AS link-java
+
+MAINTAINER "merlinosayimwen@gmail.com"
+
+ARG APP_HEAP_LIMIT=1G
+ENV APP_HEAP_LIMIT=$APP_HEAP_LIMIT
+
+ARG APP_HEAP_MINIMUM=512m
+ENV APP_HEAP_MINIMUM=$APP_HEAP_MINIMUM
+
+ARG APP_LOG_VERSION=false
+ENV APP_LOG_VERSION=$APP_LOG_VERSION
+
+WORKDIR /usr/src/build
+
+# Context is parent directory
+COPY ./runtime/build/libs libs
+
+RUN mv ./libs/app.jar app.jar
+
+RUN jdeps --ignore-missing-deps -q --multi-release 17 \
+ --print-module-deps \
+ --class-path libs/* \
+ app.jar > deps.info
+
+# link using zip compression
+RUN jlink --verbose \
+ --compress 2 \
+ --strip-java-debug-attributes \
+ --no-header-files \
+ --no-man-pages \
+ --output jre \
+ --add-modules $(cat deps.info)
+
+FROM alpine:latest
+WORKDIR /app
+
+COPY --from=link-java /usr/src/build/jre jre
+COPY --from=link-java /usr/src/build/libs/* libs/
+COPY --from=link-java /usr/src/build/app.jar app.jar
+ADD /runtime/deploy/entrypoint.sh entrypoint.sh
+ADD /website/build static/
+RUN chmod +x entrypoint.sh
+
+EXPOSE 8080
+
+ENTRYPOINT ./entrypoint.sh
\ No newline at end of file
diff --git a/runtime/deploy/entrypoint.sh b/runtime/deploy/entrypoint.sh
new file mode 100644
index 0000000..957d043
--- /dev/null
+++ b/runtime/deploy/entrypoint.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+
+if [ "$APP_LOG_VERSION" = "true" ];
+then
+ java -version
+fi
+
+# jdk.jshell is opened to support the exhaustive-execution
+
+jre/bin/java \
+ --add-opens jdk.jshell/jdk.jshell=ALL-UNNAMED \
+ -jar app.jar \
+ -XX:+UseZGC \
+ -XX:+UseZGC \
+ -Xmx"$APP_HEAP_LIMIT" \
+ -Xms"$APP_HEAP_MINIMUM" \
+ "$@"
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/App.java b/runtime/src/main/java/jsheets/runtime/App.java
new file mode 100644
index 0000000..04c3e5e
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/App.java
@@ -0,0 +1,53 @@
+package jsheets.runtime;
+
+import java.io.IOException;
+
+import com.google.common.flogger.FluentLogger;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+import jsheets.runtime.evaluation.EvaluationModule;
+
+public final class App {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ private App() {}
+
+ public static void main(String[] options) {
+ configureLogging();
+ var injector = configureInjector();
+ launch(injector);
+ }
+
+ private static void launch(Injector injector) {
+ var setup = injector.getInstance(ServerSetup.class);
+ try {
+ setup.start();
+ } catch (IOException failure) {
+ log.atSevere()
+ .withCause(failure)
+ .log("encountered error while serving");
+ } catch (InterruptedException interruption) {
+ log.atSevere()
+ .withCause(interruption)
+ .log("interrupted while serving");
+ }
+ }
+
+ private static Injector configureInjector() {
+ return Guice.createInjector(
+ ServerSetupModule.create(),
+ ConfigModule.create(),
+ ZookeeperModule.create(),
+ EvaluationModule.create()
+ );
+ }
+
+ private static void configureLogging() {
+ System.setProperty(
+ "flogger.backend_factory",
+ "com.google.common.flogger.backend.slf4j.Slf4jBackendFactory#getInstance"
+ );
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/ConfigModule.java b/runtime/src/main/java/jsheets/runtime/ConfigModule.java
new file mode 100644
index 0000000..031cd14
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/ConfigModule.java
@@ -0,0 +1,39 @@
+package jsheets.runtime;
+
+import java.util.ArrayList;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+import javax.inject.Named;
+import jsheets.config.CombinedConfig;
+import jsheets.config.Config;
+import jsheets.config.EnvironmentConfig;
+import jsheets.runtime.evaluation.EvaluationConfigSource;
+
+final class ConfigModule extends AbstractModule {
+ static ConfigModule create() {
+ return new ConfigModule();
+ }
+
+ private ConfigModule() {}
+
+ private static final String environmentPrefix = "JSHEETS_RUNTIME";
+
+ @Provides
+ @Singleton
+ Config createConfig(@Named("environment") Config environment) {
+ var configs = new ArrayList();
+ configs.add(environment);
+ configs.add(EvaluationConfigSource.fromClassPath().load());
+ return CombinedConfig.of(configs.toArray(Config[]::new));
+ }
+
+ @Provides
+ @Singleton
+ @Named("environment")
+ Config environmentConfig() {
+ return EnvironmentConfig.prefixed(environmentPrefix).load();
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/ServerSetup.java b/runtime/src/main/java/jsheets/runtime/ServerSetup.java
new file mode 100644
index 0000000..242929a
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/ServerSetup.java
@@ -0,0 +1,129 @@
+package jsheets.runtime;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.flogger.MetadataKey;
+
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import io.grpc.ServerServiceDefinition;
+import io.grpc.health.v1.HealthCheckResponse;
+import io.grpc.protobuf.services.HealthStatusManager;
+import io.grpc.protobuf.services.ProtoReflectionService;
+import io.soabase.recordbuilder.core.RecordBuilder;
+import javax.inject.Inject;
+import javax.inject.Named;
+
+public final class ServerSetup {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ public enum Feature {
+ Reflection,
+ Health
+ }
+
+ @RecordBuilder
+ public record Options(int port, Set features) {}
+
+ public interface Hook {
+ void start();
+ void stop();
+ }
+
+ private final Options options;
+ private final ServerServiceDefinition runtimeService;
+ private final HealthStatusManager health = new HealthStatusManager();
+ private final AtomicReference activeServer = new AtomicReference<>(null);
+ private final Collection hooks;
+
+ @Inject
+ ServerSetup(
+ Options options,
+ Collection hooks,
+ @Named("runtimeService") ServerServiceDefinition runtimeService
+ ) {
+ this.hooks = hooks;
+ this.options = options;
+ this.runtimeService = runtimeService;
+ }
+
+ public void start() throws IOException, InterruptedException {
+ log.atConfig().log("configuring server");
+ var server = createServer();
+ if (!activeServer.compareAndSet(null, server)) {
+ throw new IllegalStateException("already running");
+ }
+ boot(server);
+ try {
+ server.awaitTermination();
+ } finally {
+ callStopHooks();
+ }
+ }
+
+ private static final MetadataKey portKey =
+ MetadataKey.single("port", Integer.class);
+
+ private void boot(Server server) throws IOException {
+ server.start();
+ log.atInfo().with(portKey, options.port).log("listening for requests");
+ callStartHooks();
+ updateHealth(HealthCheckResponse.ServingStatus.SERVING);
+ log.atConfig().log("finished boot");
+ }
+
+ private void callStartHooks() {
+ for (var hook : hooks) {
+ try {
+ log.atConfig().log("calling start() in hook %s", hook);
+ hook.start();
+ } catch (Throwable failure) {
+ log.atWarning().withCause(failure).log(
+ "error while calling start() in hook %s",
+ hook
+ );
+ }
+ }
+ }
+
+ private void callStopHooks() {
+ for (var hook : hooks) {
+ try {
+ log.atConfig().log("calling stop() in hook %s", hook);
+ hook.stop();
+ } catch (Throwable failure) {
+ log.atWarning().withCause(failure).log(
+ "error while calling stop() in hook %s",
+ hook
+ );
+ }
+ }
+ }
+
+ private void updateHealth(HealthCheckResponse.ServingStatus status) {
+ health.setStatus(runtimeService.getServiceDescriptor().getName(), status);
+ }
+
+ private Server createServer() {
+ var server = ServerBuilder.forPort(options.port);
+ server.addService(runtimeService);
+ addOptionalServices(server);
+ return server.build();
+ }
+
+ private void addOptionalServices(ServerBuilder> server) {
+ if (options.features().contains(Feature.Reflection)) {
+ log.atConfig().log("the grpc reflection-service has been enabled");
+ server.addService(ProtoReflectionService.newInstance());
+ }
+ if (options.features().contains(Feature.Health)) {
+ log.atConfig().log("the grpc health-service has been enabled");
+ updateHealth(HealthCheckResponse.ServingStatus.NOT_SERVING);
+ server.addService(health.getHealthService());
+ }
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/ServerSetupModule.java b/runtime/src/main/java/jsheets/runtime/ServerSetupModule.java
new file mode 100644
index 0000000..c948cc4
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/ServerSetupModule.java
@@ -0,0 +1,94 @@
+package jsheets.runtime;
+
+import java.util.*;
+
+import com.google.common.net.HostAndPort;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+
+import io.grpc.ServerServiceDefinition;
+import javax.inject.Named;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import jsheets.config.Config;
+import jsheets.runtime.discovery.AdvertisementHook;
+import jsheets.runtime.discovery.ServiceAdvertisementChannel;
+
+/* Configures the ServerSetup. */
+final class ServerSetupModule extends AbstractModule {
+ static ServerSetupModule create() {
+ return new ServerSetupModule();
+ }
+
+ private ServerSetupModule() {}
+
+ @Provides
+ @Singleton
+ @Named("runtimeService")
+ ServerServiceDefinition snippetRuntimeService(SnippetRuntimeService service) {
+ return service.bindService();
+ }
+
+ private static final Config.Key servicePortKey =
+ Config.Key.ofInt("server.port");
+
+ private static final int defaultPort = 8080;
+
+ @Provides
+ @Singleton
+ ServerSetup.Options serverSetupOptions(Config config) {
+ return ServerSetupOptionsBuilder.builder()
+ .port(servicePortKey.in(config).orNone().orElse(defaultPort))
+ .features(listFeatures(config))
+ .build();
+ }
+
+ private static final Config.Key healthCheckKey =
+ Config.Key.ofFlag("server.features.enableHealthCheck");
+
+ private static final Config.Key grpcReflectionKey =
+ Config.Key.ofFlag("server.features.enableGrpcReflection");
+
+ private Set listFeatures(Config config) {
+ var features = EnumSet.noneOf(ServerSetup.Feature.class);
+ if (healthCheckKey.in(config).orNone().orElse(true)) {
+ features.add(ServerSetup.Feature.Health);
+ }
+ if (grpcReflectionKey.in(config).orNone().orElse(false)) {
+ features.add(ServerSetup.Feature.Reflection);
+ }
+ return features;
+ }
+
+ private static final Config.Key serviceIdKey =
+ Config.Key.ofString("service.id");
+
+ @Provides
+ @Singleton
+ @Named("serviceId")
+ String serviceId(Config config) {
+ return serviceIdKey.in(config).orNone()
+ .orElse(UUID.randomUUID().toString());
+ }
+
+ private static final Config.Key advertisedHostKey =
+ Config.Key.of("service.advertisedHost", HostAndPort::fromString);
+
+ @Provides
+ @Singleton
+ Collection setupHooks(
+ Config config,
+ @Named("serviceId") String serviceId,
+ Provider> advertisementChannelFactory
+ ) {
+ return advertisedHostKey.in(config).orNone().map(host -> {
+ var hook = AdvertisementHook.create(
+ serviceId,
+ host,
+ advertisementChannelFactory.get().orElseThrow()
+ );
+ return List.of(hook);
+ }).orElse(List.of());
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/SnippetRuntimeService.java b/runtime/src/main/java/jsheets/runtime/SnippetRuntimeService.java
new file mode 100644
index 0000000..a8aaf89
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/SnippetRuntimeService.java
@@ -0,0 +1,120 @@
+package jsheets.runtime;
+
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+import com.google.common.flogger.FluentLogger;
+
+import io.grpc.Status;
+import io.grpc.stub.StreamObserver;
+import javax.inject.Inject;
+import jsheets.EvaluateRequest;
+import jsheets.EvaluateResponse;
+import jsheets.SnippetRuntimeGrpc.SnippetRuntimeImplBase;
+import jsheets.StartEvaluationRequest;
+import jsheets.StopEvaluationRequest;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.EvaluationEngine;
+
+final class SnippetRuntimeService extends SnippetRuntimeImplBase {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ private final EvaluationEngine engine;
+
+ @Inject
+ SnippetRuntimeService(EvaluationEngine engine) {
+ this.engine = engine;
+ }
+
+ @Override
+ public StreamObserver evaluate(
+ StreamObserver responseStream
+ ) {
+ return new Call(responseStream);
+ }
+
+ final class Call
+ implements StreamObserver, Evaluation.Listener {
+
+ private final StreamObserver responseStream;
+ private final Lock lock = new ReentrantLock();
+ private Evaluation evaluation;
+
+ private Call(StreamObserver responseStream) {
+ this.responseStream = responseStream;
+ }
+
+ @Override
+ public void onNext(EvaluateRequest request) {
+ lock.lock();
+ try {
+ switch (request.getMessageCase()) {
+ case STOP -> processStop(request.getStop());
+ case START -> processStart(request.getStart());
+ default -> processUnknown(request);
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static final Status invalidState =
+ Status.FAILED_PRECONDITION.withDescription("state");
+
+ private void processStop(StopEvaluationRequest request) {
+ if (evaluation == null) {
+ log.atWarning().log("received stop request without active evaluation");
+ responseStream.onError(invalidState.asException());
+ return;
+ }
+ evaluation.stop();
+ evaluation = null;
+ log.atFine().log("closed evaluation");
+ }
+
+ private void processStart(StartEvaluationRequest request) {
+ if (evaluation != null) {
+ log.atWarning().log("received start request with active evaluation");
+ responseStream.onError(invalidState.asException());
+ return;
+ }
+ evaluation = engine.start(request, this);
+ log.atFine().log("started evaluation");
+ }
+
+ private void processUnknown(EvaluateRequest request) {
+ log.atWarning()
+ .atMostEvery(5, TimeUnit.SECONDS)
+ .log("received unknown request message: %s", request);
+ }
+
+ @Override
+ public void onError(Throwable failure) {}
+
+ @Override
+ public void onCompleted() {
+ lock.lock();
+ try {
+ if (evaluation != null) {
+ evaluation.stop();
+ evaluation = null;
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ // Listens to EvaluationEngine
+ @Override
+ public void send(EvaluateResponse response) {
+ responseStream.onNext(response);
+ }
+
+ // Listens to EvaluationEngine
+ @Override
+ public void close() {
+ responseStream.onCompleted();
+ }
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/ZookeeperModule.java b/runtime/src/main/java/jsheets/runtime/ZookeeperModule.java
new file mode 100644
index 0000000..c371791
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/ZookeeperModule.java
@@ -0,0 +1,56 @@
+package jsheets.runtime;
+
+import java.util.Optional;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+
+import com.google.inject.Singleton;
+import jsheets.config.Config;
+import org.apache.curator.framework.CuratorFramework;
+
+import jsheets.runtime.discovery.ServiceAdvertisementChannel;
+import jsheets.runtime.discovery.ZookeeperServiceAdvertisementChannel;
+import org.apache.curator.framework.CuratorFrameworkFactory;
+import org.apache.curator.retry.ExponentialBackoffRetry;
+
+public class ZookeeperModule extends AbstractModule {
+ public static ZookeeperModule create() {
+ return new ZookeeperModule();
+ }
+
+ private ZookeeperModule() {}
+
+ private static final Config.Key connectionStringKey
+ = Config.Key.ofString("zookeeper.connectionString");
+
+ private static final Config.Key connectionBackoffKey
+ = Config.Key.ofInt("zookeeper.connectBackoff");
+
+ private static final int defaultBackoff = 1000;
+ private static final int retryLimit = 3;
+
+ @Provides
+ @Singleton
+ Optional curatorFramework(Config config) {
+ return connectionStringKey.in(config).orNone()
+ .map(connectionString -> {
+ int backoff = connectionBackoffKey.in(config).or(defaultBackoff);
+ var client = CuratorFrameworkFactory.newClient(
+ connectionString,
+ new ExponentialBackoffRetry(backoff, retryLimit)
+ );
+ client.start();
+ Runtime.getRuntime().addShutdownHook(new Thread(client::close));
+ return client;
+ });
+ }
+
+ @Provides
+ @Singleton
+ Optional serviceAdvertisementChannel(
+ Optional curator
+ ) {
+ return curator.map(ZookeeperServiceAdvertisementChannel::create);
+ }
+}
diff --git a/runtime/src/main/java/jsheets/runtime/discovery/AdvertisementHook.java b/runtime/src/main/java/jsheets/runtime/discovery/AdvertisementHook.java
new file mode 100644
index 0000000..b148938
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/discovery/AdvertisementHook.java
@@ -0,0 +1,68 @@
+package jsheets.runtime.discovery;
+
+import java.util.Objects;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.net.HostAndPort;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+
+import jsheets.runtime.ServerSetup;
+
+public final class AdvertisementHook implements ServerSetup.Hook {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ public static AdvertisementHook create(
+ String serviceId,
+ HostAndPort advertisedHost,
+ ServiceAdvertisementChannel channel
+ ) {
+ Objects.requireNonNull(advertisedHost, "advertisedHost");
+ Objects.requireNonNull(channel, "channel");
+ return new AdvertisementHook(serviceId, advertisedHost, channel);
+ }
+
+ private final HostAndPort advertisedHost;
+ private final ServiceAdvertisementChannel advertisementChannel;
+ private final String serviceId;
+ private volatile ServiceAdvertisement advertisement;
+
+ @Inject
+ AdvertisementHook(
+ @Named("serviceId") String serviceId,
+ HostAndPort advertisedHost,
+ ServiceAdvertisementChannel advertisementChannel
+ ) {
+ this.serviceId = serviceId;
+ this.advertisedHost = advertisedHost;
+ this.advertisementChannel = advertisementChannel;
+ }
+
+ @Override
+ public void start() {
+ try {
+ advertisementChannel.open();
+ advertisement = advertisementChannel.advertise(serviceId, advertisedHost);
+ } catch (Exception failure) {
+ log.atSevere().withCause(failure)
+ .log("failed to advertise service");
+ }
+ }
+
+ @Override
+ public void stop() {
+ var currentAdvertisement = advertisement;
+ if (currentAdvertisement != null) {
+ currentAdvertisement.remove();
+ advertisement = null;
+ }
+ advertisementChannel.close();
+ }
+
+ @Override
+ public String toString() {
+ return "AdvertisementHook(serviceId=%s, host=%s, channel=%s)"
+ .formatted(serviceId, advertisedHost, advertisementChannel);
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisement.java b/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisement.java
new file mode 100644
index 0000000..56e19dd
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisement.java
@@ -0,0 +1,11 @@
+package jsheets.runtime.discovery;
+
+/**
+ * Represents an existing entry in a service discovery backend.
+ */
+public interface ServiceAdvertisement {
+ /**
+ * Deletes the entry from the discovery backend.
+ */
+ void remove();
+}
diff --git a/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisementChannel.java b/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisementChannel.java
new file mode 100644
index 0000000..847aa3e
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/discovery/ServiceAdvertisementChannel.java
@@ -0,0 +1,9 @@
+package jsheets.runtime.discovery;
+
+import com.google.common.net.HostAndPort;
+
+public interface ServiceAdvertisementChannel {
+ ServiceAdvertisement advertise(String serviceId, HostAndPort address);
+ void open();
+ void close();
+}
diff --git a/runtime/src/main/java/jsheets/runtime/discovery/ZookeeperServiceAdvertisementChannel.java b/runtime/src/main/java/jsheets/runtime/discovery/ZookeeperServiceAdvertisementChannel.java
new file mode 100644
index 0000000..41cd984
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/discovery/ZookeeperServiceAdvertisementChannel.java
@@ -0,0 +1,81 @@
+package jsheets.runtime.discovery;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.net.HostAndPort;
+import org.apache.curator.framework.CuratorFramework;
+import org.apache.curator.x.discovery.ServiceDiscovery;
+import org.apache.curator.x.discovery.ServiceDiscoveryBuilder;
+import org.apache.curator.x.discovery.ServiceInstance;
+import org.apache.curator.x.discovery.ServiceType;
+
+public final class ZookeeperServiceAdvertisementChannel
+ implements ServiceAdvertisementChannel {
+
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ public static ServiceAdvertisementChannel create(CuratorFramework curator) {
+ var discovery = ServiceDiscoveryBuilder.builder(Void.class)
+ .basePath("jsheets/services")
+ .client(curator)
+ .build();
+ return new ZookeeperServiceAdvertisementChannel(discovery);
+ }
+
+ private final ServiceDiscovery discovery;
+
+ private ZookeeperServiceAdvertisementChannel(ServiceDiscovery discovery) {
+ this.discovery = discovery;
+ }
+
+ @Override
+ public void open() {
+ try {
+ discovery.start();
+ } catch (Exception failure) {
+ throw new RuntimeException();
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ discovery.close();
+ } catch (Exception failure) {
+ log.atWarning().withCause(failure).log("failed to close discovery");
+ }
+ }
+
+ @Override
+ public ServiceAdvertisement advertise(String serviceId, HostAndPort address) {
+ var service = createServiceInstance(serviceId, address);
+ try {
+ discovery.registerService(service);
+ } catch (Exception failedRegistration) {
+ throw new RuntimeException(failedRegistration);
+ }
+ return () -> {
+ try {
+ discovery.unregisterService(service);
+ } catch (Exception failure) {
+ log.atWarning().withCause(failure).log("failed to unregister service");
+ }
+ };
+ }
+
+ private ServiceInstance createServiceInstance(
+ String serviceId,
+ HostAndPort address
+ ) {
+ return new ServiceInstance<>(
+ /* name */ "runtime",
+ /* id */ serviceId,
+ /* address */ address.getHost(),
+ /* port */ address.getPort(),
+ /* ssl port */ address.getPort(),
+ /* payload */ null,
+ /* registrationTimeUTC */ System.currentTimeMillis(),
+ /* serviceType */ ServiceType.DYNAMIC,
+ /* uri */ null
+ );
+ }
+}
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java
new file mode 100644
index 0000000..abf54d4
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationConfigSource.java
@@ -0,0 +1,98 @@
+package jsheets.runtime.evaluation;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+import com.google.common.flogger.FluentLogger;
+
+import com.google.inject.Provides;
+
+import javax.inject.Named;
+import jsheets.config.Config;
+import jsheets.config.RawConfig;
+import jsheets.runtime.ServerSetup;
+
+/**
+ * Reads the {@code AccessGraph} configuration from the classpath.
+ */
+public final class EvaluationConfigSource implements Config.Source {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ private EvaluationConfigSource() {}
+
+ private static final Config.Key disableSandboxKey =
+ Config.Key.ofFlag("evaluation.sandbox.disable");
+
+ /**
+ * Since the sandbox is an important security measure, it has to be
+ * disabled explicitly.
+ */
+ public static Config.Key disableSandboxKey() {
+ return disableSandboxKey;
+ }
+
+ private static final Config.Key accessGraphKey =
+ Config.Key.ofString("evaluation.sandbox.accessGraph");
+
+ private static final Config.Key defaultImportsKey =
+ Config.Key.ofString("evaluation.defaultImports");
+
+ public static Config.Key accessGraphKey() {
+ return accessGraphKey;
+ }
+
+ public static EvaluationConfigSource fromClassPath() {
+ return new EvaluationConfigSource();
+ }
+
+ private static final Config.Key virtualMachineOptionsKey =
+ Config.Key.ofString("evaluation.fork.virtualMachineOptions");
+
+ static Config.Key virtualMachineOptionsKey() {
+ return virtualMachineOptionsKey;
+ }
+
+ private static final String accessGraphFilePath =
+ "runtime/evaluation/sandbox/accessGraph.txt";
+
+ private static final String virtualMachineOptionsFilePath =
+ "runtime/evaluation/fork/virtualMachineOptions.txt";
+
+ private static final String defaultImportsFilePath =
+ "runtime/evaluation/defaultImportsKey.txt";
+
+ @Override
+ public Config load() {
+ var config = RawConfig.newBuilder();
+ readFullFile(accessGraphFilePath).ifPresent(value ->
+ config.with(accessGraphKey, value)
+ );
+ readFullFile(virtualMachineOptionsFilePath).ifPresent(value ->
+ config.with(virtualMachineOptionsKey, value)
+ );
+ readFullFile(defaultImportsFilePath).ifPresent(value ->
+ config.with(defaultImportsKey, value)
+ );
+ return config.create();
+ }
+
+ private static Optional readFullFile(String path) {
+ var resources = Thread.currentThread().getContextClassLoader();
+ var file = resources.getResourceAsStream(path);
+ if (file == null) {
+ log.atConfig().log("could not find %s in classpath", path);
+ return Optional.empty();
+ }
+ try (var input = new BufferedInputStream(file)) {
+ return Optional.of(new String(input.readAllBytes()));
+ } catch (IOException failedRead) {
+ log.atWarning()
+ .withCause(failedRead)
+ .log("failed to read %s", path);
+ }
+ return Optional.empty();
+ }
+}
\ No newline at end of file
diff --git a/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java
new file mode 100644
index 0000000..7dfdf5e
--- /dev/null
+++ b/runtime/src/main/java/jsheets/runtime/evaluation/EvaluationModule.java
@@ -0,0 +1,67 @@
+package jsheets.runtime.evaluation;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+import javax.inject.Named;
+import jsheets.evaluation.EvaluationEngine;
+import jsheets.evaluation.sandbox.access.AccessGraph;
+import jsheets.evaluation.sandbox.validation.ForbiddenMemberFilter;
+import jsheets.evaluation.shell.ShellEvaluationEngine;
+import jsheets.evaluation.shell.environment.ExecutionEnvironment;
+import jsheets.evaluation.shell.environment.fork.ForkedExecutionEnvironment;
+import jsheets.evaluation.shell.environment.sandbox.SandboxClassFileCheck;
+import jsheets.evaluation.shell.environment.sandbox.SandboxedEnvironment;
+import jsheets.evaluation.shell.environment.StandardEnvironment;
+import jsheets.config.Config;
+import jsheets.evaluation.shell.execution.SystemBasedExecutionMethodFactory;
+
+import java.util.Collection;
+import java.util.List;
+
+import static jsheets.runtime.evaluation.EvaluationConfigSource.accessGraphKey;
+import static jsheets.runtime.evaluation.EvaluationConfigSource.disableSandboxKey;
+import static jsheets.runtime.evaluation.EvaluationConfigSource.virtualMachineOptionsKey;
+
+public final class EvaluationModule extends AbstractModule {
+ public static EvaluationModule create() {
+ return new EvaluationModule();
+ }
+
+ private EvaluationModule() {}
+
+ @Provides
+ @Singleton
+ EvaluationEngine evaluationEngine(ExecutionEnvironment environment) {
+ return ShellEvaluationEngine.newBuilder()
+ .useEnvironment(environment)
+ .useExecutionMethodFactory(SystemBasedExecutionMethodFactory.create())
+ .create();
+ }
+
+ @Provides
+ @Singleton
+ ExecutionEnvironment executionEnvironment(Config config) {
+ boolean disableSandbox =
+ disableSandboxKey().in(config).orNone().orElse(false);
+ if (disableSandbox) {
+ return StandardEnvironment.create();
+ }
+ var accessGraphConfig = accessGraphKey().in(config).require();
+ var accessGraph = AccessGraph.of(accessGraphConfig.split("\n"));
+ return ForkedExecutionEnvironment.create(
+ SandboxClassFileCheck.of(
+ List.of(ForbiddenMemberFilter.create(accessGraph))
+ ),
+ listVirtualMachineOptions(config)
+ );
+ }
+
+ Collection listVirtualMachineOptions(Config config) {
+ return List.of(
+ virtualMachineOptionsKey().in(config).or("").trim().split("\n")
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/runtime/src/main/resources/runtime/evaluation/defaultImports.txt b/runtime/src/main/resources/runtime/evaluation/defaultImports.txt
new file mode 100644
index 0000000..75bfda6
--- /dev/null
+++ b/runtime/src/main/resources/runtime/evaluation/defaultImports.txt
@@ -0,0 +1,7 @@
+java.lang.*
+java.math.*
+java.time.*
+java.text.*
+java.util.*
+java.util.function.*
+java.util.stream.*
\ No newline at end of file
diff --git a/runtime/src/main/resources/runtime/evaluation/fork/virtualMachineOptions.txt b/runtime/src/main/resources/runtime/evaluation/fork/virtualMachineOptions.txt
new file mode 100644
index 0000000..d274d62
--- /dev/null
+++ b/runtime/src/main/resources/runtime/evaluation/fork/virtualMachineOptions.txt
@@ -0,0 +1,3 @@
+-Xmx8M
+-Xms8M
+-XX:+UseSerialGC
\ No newline at end of file
diff --git a/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt b/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt
new file mode 100644
index 0000000..6afde38
--- /dev/null
+++ b/runtime/src/main/resources/runtime/evaluation/sandbox/accessGraph.txt
@@ -0,0 +1,13 @@
+java.lang
+!java.lang.Thread
+!java.lang.reflect
+!java.lang.invoke
+java.lang.System
+!java.lang.System#exit
+!java.util.concurrent
+java.text
+java.time
+java.math
+java.io.Scanner
+java.io.PrintStream#print
+java.io.PrintStream#println
diff --git a/server/build.gradle b/server/build.gradle
index c969070..afdb6b6 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -14,10 +14,10 @@ sourceCompatibility = 16
targetCompatibility = 16
ext {
- mongoDbDriverVersion = '4.3.0'
+ mongoDbDriverVersion = '4.3.2'
cfg4jVersion = '4.4.1'
slf4jVersion = '1.7.32'
- javalinVersion = '4.0.0'
+ javalinVersion = '4.1.1'
argsParseVersion = '0.9.0'
javaxAnnotationVersion = '1.3.2'
guiceVersion = '5.0.1'
@@ -25,10 +25,11 @@ ext {
dependencies {
implementation project(':protocol')
- implementation project(':runtime')
+ implementation project(':evaluation')
+ implementation "org.apache.curator:curator-x-discovery:$curatorVersion"
implementation "io.micrometer:micrometer-core:$micrometerVersion"
implementation "org.mongodb:mongodb-driver-sync:$mongoDbDriverVersion"
- implementation "org.cfg4j:cfg4j-core:$cfg4jVersion"
+ implementation "com.ecwid.consul:consul-api:$consuleClientVersion"
implementation "io.javalin:javalin:$javalinVersion"
implementation "org.slf4j:slf4j-simple:$slf4jVersion"
implementation "com.google.protobuf:protobuf-java-util:$protobufJavaVersion"
@@ -37,6 +38,10 @@ dependencies {
implementation "com.google.flogger:flogger:$floggerVersion"
implementation "com.google.flogger:flogger-slf4j-backend:$floggerVersion"
implementation "com.google.inject:guice:$guiceVersion"
+ implementation "io.grpc:grpc-netty:$grpcVersion"
+ implementation "io.grpc:grpc-stub:$grpcVersion"
+ implementation "io.grpc:grpc-core:$grpcVersion"
+ implementation "io.grpc:grpc-protobuf:$grpcVersion"
testImplementation "org.junit.jupiter:junit-jupiter-api:$junitPlatformVersion"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitPlatformVersion"
}
diff --git a/server/src/main/java/jsheets/server/App.java b/server/src/main/java/jsheets/server/App.java
index 21f05c7..1c23538 100644
--- a/server/src/main/java/jsheets/server/App.java
+++ b/server/src/main/java/jsheets/server/App.java
@@ -14,7 +14,10 @@ public static void main(String[] arguments) {
}
private static Injector configureInjector() {
- return Guice.createInjector(ServerModule.create());
+ return Guice.createInjector(
+ ConfigModule.create(),
+ ServerModule.create()
+ );
}
private static void configureLogging() {
diff --git a/server/src/main/java/jsheets/server/ConfigModule.java b/server/src/main/java/jsheets/server/ConfigModule.java
new file mode 100644
index 0000000..cc50ec7
--- /dev/null
+++ b/server/src/main/java/jsheets/server/ConfigModule.java
@@ -0,0 +1,21 @@
+package jsheets.server;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import jsheets.config.Config;
+import jsheets.config.EnvironmentConfig;
+
+public final class ConfigModule extends AbstractModule {
+ public static ConfigModule create() {
+ return new ConfigModule();
+ }
+
+ private ConfigModule() {}
+
+ private static final String environmentPrefix = "JSHEETS";
+
+ @Provides
+ Config config() {
+ return EnvironmentConfig.prefixed(environmentPrefix).load();
+ }
+}
diff --git a/server/src/main/java/jsheets/server/Server.java b/server/src/main/java/jsheets/server/Server.java
index 0370162..0c94a59 100644
--- a/server/src/main/java/jsheets/server/Server.java
+++ b/server/src/main/java/jsheets/server/Server.java
@@ -19,17 +19,17 @@
public final class Server {
private static final FluentLogger log = FluentLogger.forEnclosingClass();
- public record Config(int port) { }
+ public record Options(int port) { }
private final Injector injector;
- private final Config config;
+ private final Options options;
private final AtomicReference runningServer =
new AtomicReference<>(null);
@Inject
- Server(Injector injector, Config config) {
+ Server(Injector injector, Options options) {
this.injector = injector;
- this.config = config;
+ this.options = options;
}
public void start() {
@@ -38,7 +38,7 @@ public void start() {
throw new IllegalStateException("already running");
}
log.atInfo().log("starting...");
- server.start(config.port());
+ server.start(options.port());
}
public void stop() {
diff --git a/server/src/main/java/jsheets/server/ServerModule.java b/server/src/main/java/jsheets/server/ServerModule.java
index 48cbd1e..7e09a8a 100644
--- a/server/src/main/java/jsheets/server/ServerModule.java
+++ b/server/src/main/java/jsheets/server/ServerModule.java
@@ -28,8 +28,8 @@ protected void configure() {
private static final int defaultPort = 8080;
@Provides
- Server.Config createConfig() {
- return new Server.Config(
+ Server.Options createConfig() {
+ return new Server.Options(
readIntFromEnvironment("JSHEETS_SERVER_PORT").orElse(defaultPort)
);
}
diff --git a/server/src/main/java/jsheets/server/evaluation/EvaluationConnection.java b/server/src/main/java/jsheets/server/evaluation/EvaluationConnection.java
index 7acd064..e35f1b6 100644
--- a/server/src/main/java/jsheets/server/evaluation/EvaluationConnection.java
+++ b/server/src/main/java/jsheets/server/evaluation/EvaluationConnection.java
@@ -6,8 +6,8 @@
import io.javalin.websocket.*;
import jsheets.*;
-import jsheets.runtime.evaluation.Evaluation;
-import jsheets.runtime.evaluation.EvaluationEngine;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.EvaluationEngine;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.websocket.api.CloseStatus;
import org.eclipse.jetty.websocket.api.Session;
@@ -138,20 +138,38 @@ private void receiveStart(WsContext context, StartEvaluationRequest request) {
establishConnection(context, request);
}
- private static final CloseStatus CANCELLED =
+ private static final CloseStatus cancelledStatus =
new CloseStatus(HttpStatus.BAD_REQUEST_400, "cancelled");
+ private static final CloseStatus runtimeUnavailableStatus =
+ new CloseStatus(HttpStatus.ENHANCE_YOUR_CALM_420, "runtime unavailable");
+
private void establishConnection(
WsContext context,
StartEvaluationRequest request
) {
var listener = new UpstreamListener(context);
- var upstream = engine.start(request, listener);
+ Evaluation upstream;
+ try {
+ upstream = engine.start(request, listener);
+ } catch (Throwable failure) {
+ reportFailedStart(context, failure);
+ return;
+ }
if (!completeConnecting(upstream)) {
- ensureSessionIsClosed(context.session, CANCELLED);
+ ensureSessionIsClosed(context.session, cancelledStatus);
}
}
+ private void reportFailedStart(WsContext context, Throwable failure) {
+ log.atWarning()
+ .with(sessionIdMetadata, context.getSessionId())
+ .withCause(failure)
+ .atMostEvery(5, TimeUnit.SECONDS)
+ .log("failed to start evaluation");
+ ensureSessionIsClosed(context.session, cancelledStatus);
+ }
+
private boolean completeConnecting(Evaluation upstream) {
if (!stage.compareAndSet(Stage.Connecting, Stage.Evaluating)) {
log.atWarning().log("the evaluation was terminated in the connecting stage");
@@ -209,6 +227,8 @@ public String toString() {
private void ensureSessionIsClosed(Session session, CloseStatus status) {
// close does not throw an exception if the session is already closed
+ stage.set(Stage.Terminated);
+ closeHook.run();
session.close(status);
}
diff --git a/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java b/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java
index 0fac756..9547997 100644
--- a/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java
+++ b/server/src/main/java/jsheets/server/evaluation/EvaluationModule.java
@@ -1,17 +1,27 @@
package jsheets.server.evaluation;
-import java.time.Clock;
-import java.util.concurrent.Executors;
-
+import com.google.common.flogger.FluentLogger;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
-import jsheets.runtime.evaluation.EvaluationEngine;
-import jsheets.runtime.evaluation.shell.ShellEvaluationEngine;
+import jsheets.config.Config;
+import jsheets.evaluation.EvaluationEngine;
+import jsheets.evaluation.shell.ShellEvaluationEngine;
+import jsheets.server.evaluation.client.PooledEvaluationEngine;
+import jsheets.server.evaluation.client.ZookeeperEngineDiscovery;
+import org.apache.curator.framework.CuratorFramework;
+import org.apache.curator.framework.CuratorFrameworkFactory;
+import org.apache.curator.retry.ExponentialBackoffRetry;
+import org.apache.curator.x.discovery.ServiceDiscoveryBuilder;
+import org.apache.curator.x.discovery.ServiceProvider;
+import java.util.Optional;
+import java.util.concurrent.Executors;
public final class EvaluationModule extends AbstractModule {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
public static EvaluationModule create() {
return new EvaluationModule();
}
@@ -20,10 +30,68 @@ private EvaluationModule() {}
@Provides
@Singleton
- EvaluationEngine evaluationEngine(Clock clock) {
+ EvaluationEngine evaluationEngine(
+ Optional curatorBinding
+ ) {
+ return curatorBinding.map(this::createRemoteEvaluationEngine)
+ .orElseGet(this::createEmbeddedEvaluationEngine);
+ }
+
+ private EvaluationEngine createRemoteEvaluationEngine(CuratorFramework client) {
+ var provider = createServiceProvider(client);
+ try {
+ provider.start();
+ } catch (Exception failure) {
+ log.atSevere().withCause(failure).log("failed to start service discovery");
+ }
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ provider.close();
+ } catch (Exception failure) {
+ log.atWarning().withCause(failure).log("failed to close service discovery");
+ }
+ }));
+ return PooledEvaluationEngine.of(ZookeeperEngineDiscovery.create(provider));
+ }
+
+ private EvaluationEngine createEmbeddedEvaluationEngine() {
return ShellEvaluationEngine.newBuilder()
.useWorkerPool(Executors.newCachedThreadPool())
- .useClock(clock)
.create();
}
+
+ private ServiceProvider createServiceProvider(CuratorFramework curator) {
+ return ServiceDiscoveryBuilder.builder(Void.class)
+ .client(curator)
+ .basePath("/jsheets/services")
+ .build()
+ .serviceProviderBuilder()
+ .serviceName("runtime")
+ .build();
+ }
+
+ private static final Config.Key connectionStringKey
+ = Config.Key.ofString("zookeeper.connectionString");
+
+ private static final Config.Key connectionBackoffKey
+ = Config.Key.ofInt("zookeeper.connectBackoff");
+
+ private static final int defaultBackoff = 1000;
+ private static final int retryLimit = 3;
+
+ @Provides
+ @Singleton
+ Optional curatorFramework(Config config) {
+ return connectionStringKey.in(config).orNone()
+ .map(connectionString -> {
+ int backoff = connectionBackoffKey.in(config).or(defaultBackoff);
+ var client = CuratorFrameworkFactory.newClient(
+ connectionString,
+ new ExponentialBackoffRetry(backoff, retryLimit)
+ );
+ client.start();
+ Runtime.getRuntime().addShutdownHook(new Thread(client::close));
+ return client;
+ });
+ }
}
\ No newline at end of file
diff --git a/server/src/main/java/jsheets/server/evaluation/client/EnginePool.java b/server/src/main/java/jsheets/server/evaluation/client/EnginePool.java
new file mode 100644
index 0000000..4cd7224
--- /dev/null
+++ b/server/src/main/java/jsheets/server/evaluation/client/EnginePool.java
@@ -0,0 +1,9 @@
+package jsheets.server.evaluation.client;
+
+import java.util.Optional;
+
+import jsheets.evaluation.EvaluationEngine;
+
+public interface EnginePool {
+ Optional select();
+}
diff --git a/server/src/main/java/jsheets/server/evaluation/client/PooledEvaluationEngine.java b/server/src/main/java/jsheets/server/evaluation/client/PooledEvaluationEngine.java
new file mode 100644
index 0000000..6f49d3b
--- /dev/null
+++ b/server/src/main/java/jsheets/server/evaluation/client/PooledEvaluationEngine.java
@@ -0,0 +1,34 @@
+package jsheets.server.evaluation.client;
+
+import jsheets.StartEvaluationRequest;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.EvaluationEngine;
+
+import javax.inject.Inject;
+import java.util.Objects;
+
+public final class PooledEvaluationEngine implements EvaluationEngine {
+ public static PooledEvaluationEngine of(EnginePool pool) {
+ Objects.requireNonNull(pool, "pool");
+ return new PooledEvaluationEngine(pool);
+ }
+
+ private final EnginePool pool;
+
+ @Inject
+ PooledEvaluationEngine(EnginePool pool) {
+ this.pool = pool;
+ }
+
+ @Override
+ public Evaluation start(StartEvaluationRequest request, Evaluation.Listener listener) {
+ return pool.select()
+ .map(engine -> engine.start(request, listener))
+ .orElseGet(() -> reportNoEngineFound(listener));
+ }
+
+ private Evaluation reportNoEngineFound(Evaluation.Listener listener) {
+ listener.close();
+ return () -> {};
+ }
+}
diff --git a/server/src/main/java/jsheets/server/evaluation/client/SnippetRuntimeEngine.java b/server/src/main/java/jsheets/server/evaluation/client/SnippetRuntimeEngine.java
new file mode 100644
index 0000000..97d6d9b
--- /dev/null
+++ b/server/src/main/java/jsheets/server/evaluation/client/SnippetRuntimeEngine.java
@@ -0,0 +1,100 @@
+package jsheets.server.evaluation.client;
+
+import java.util.Objects;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.flogger.MetadataKey;
+
+import io.grpc.Channel;
+import io.grpc.stub.StreamObserver;
+
+import jsheets.EvaluateRequest;
+import jsheets.EvaluateResponse;
+import jsheets.SnippetRuntimeGrpc;
+import jsheets.SnippetRuntimeGrpc.SnippetRuntimeStub;
+import jsheets.StartEvaluationRequest;
+import jsheets.StopEvaluationRequest;
+import jsheets.evaluation.Evaluation;
+import jsheets.evaluation.EvaluationEngine;
+
+/**
+ * Client side {@link EvaluationEngine} that connects to a
+ * {@code SnippetRuntime} to evaluate snippets.
+ */
+public final class SnippetRuntimeEngine implements EvaluationEngine {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ public static SnippetRuntimeEngine forChannel(Channel channel) {
+ Objects.requireNonNull(channel, "channel");
+ return new SnippetRuntimeEngine(SnippetRuntimeGrpc.newStub(channel));
+ }
+
+ private final SnippetRuntimeStub client;
+
+ private SnippetRuntimeEngine(SnippetRuntimeStub client) {
+ this.client = client;
+ }
+
+ @Override
+ public Evaluation start(
+ StartEvaluationRequest request,
+ Evaluation.Listener listener
+ ) {
+ var snippetId = request.getSnippet().getReference().getSnippetId();
+ var observer = new ListenerBoundObserver(snippetId, listener);
+ var call = client.evaluate(observer);
+ call.onNext(wrapStartRequest(request));
+ return () -> call.onNext(createStopRequest());
+ }
+
+ private EvaluateRequest wrapStartRequest(StartEvaluationRequest request) {
+ return EvaluateRequest.newBuilder()
+ .setStart(request)
+ .build();
+ }
+
+ private EvaluateRequest createStopRequest() {
+ return EvaluateRequest.newBuilder()
+ .setStop(StopEvaluationRequest.getDefaultInstance())
+ .build();
+ }
+
+ static final class ListenerBoundObserver implements StreamObserver {
+ private final String snippetId;
+ private final Evaluation.Listener listener;
+
+ private ListenerBoundObserver(
+ String snippetId,
+ Evaluation.Listener listener
+ ) {
+ this.snippetId = snippetId;
+ this.listener = listener;
+ }
+
+ @Override
+ public void onNext(EvaluateResponse response) {
+ listener.send(response);
+ }
+
+ @Override
+ public void onCompleted() {
+ listener.close();
+ }
+
+ private static final MetadataKey snippetIdKey =
+ MetadataKey.single("snippetId", String.class);
+
+ @Override
+ public void onError(Throwable failure) {
+ log.atWarning()
+ .withCause(failure)
+ .with(snippetIdKey, snippetId)
+ .log("received error response in evaluate call");
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "SnippetRuntimeEngine(client=%s)".formatted(client);
+ }
+}
\ No newline at end of file
diff --git a/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java b/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java
new file mode 100644
index 0000000..9c82833
--- /dev/null
+++ b/server/src/main/java/jsheets/server/evaluation/client/ZookeeperEngineDiscovery.java
@@ -0,0 +1,43 @@
+package jsheets.server.evaluation.client;
+
+import com.google.common.flogger.FluentLogger;
+import io.grpc.ManagedChannelBuilder;
+import jsheets.evaluation.EvaluationEngine;
+import org.apache.curator.x.discovery.ServiceProvider;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+// TODO: Pool Connections
+public final class ZookeeperEngineDiscovery implements EnginePool {
+ private static final FluentLogger log = FluentLogger.forEnclosingClass();
+
+ public static ZookeeperEngineDiscovery create(ServiceProvider services) {
+ Objects.requireNonNull(services, "services");
+ return new ZookeeperEngineDiscovery(services);
+ }
+
+ private final ServiceProvider services;
+
+ private ZookeeperEngineDiscovery(ServiceProvider services) {
+ this.services = services;
+ }
+
+ @Override
+ public Optional select() {
+ try {
+ var service = services.getInstance();
+ var channel = ManagedChannelBuilder.forAddress(service.getAddress(), service.getPort())
+ .usePlaintext()
+ .build();
+ return Optional.of(SnippetRuntimeEngine.forChannel(channel));
+ } catch (Exception failure) {
+ log.atWarning()
+ .withCause(failure)
+ .atMostEvery(5, TimeUnit.SECONDS)
+ .log("failed to find runtime instance");
+ return Optional.empty();
+ }
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index f4307c3..83749f2 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,7 +1,8 @@
rootProject.name = 'jsheets'
+include 'evaluation'
include 'runtime'
include 'protocol'
-include 'server'
include 'website'
+include 'server'
diff --git a/website/src/client/index.ts b/website/src/client/index.ts
index 2d630dc..239be66 100644
--- a/website/src/client/index.ts
+++ b/website/src/client/index.ts
@@ -57,6 +57,7 @@ export default class Client {
client.onerror = error => {
console.log({error})
+ listener.onEnd()
}
client.onopen = () => {