diff --git a/JShellAPI/README.MD b/JShellAPI/README.MD index 2dacaa8..72938e2 100644 --- a/JShellAPI/README.MD +++ b/JShellAPI/README.MD @@ -101,4 +101,4 @@ The maximum ram allocated per container, in megabytes. ### jshellapi.dockerCPUsUsage The cpu configuration of each container, see [--cpus option of docker](https://docs.docker.com/config/containers/resource_constraints/#cpu). ### jshellapi.schedulerSessionKillScanRate -The rate at which the session killer will check and delete session, in seconds, see [Session timeout](#Session-timeout). \ No newline at end of file +The rate at which the session killer will check and delete session, in seconds, see [Session timeout](#Session-timeout). diff --git a/JShellAPI/build.gradle b/JShellAPI/build.gradle index 7a2380b..1aedc46 100644 --- a/JShellAPI/build.gradle +++ b/JShellAPI/build.gradle @@ -13,8 +13,16 @@ dependencies { implementation 'com.github.docker-java:docker-java-transport-httpclient5:3.3.6' implementation 'com.github.docker-java:docker-java-core:3.3.6' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + // `logback-classic` has been excluded because of an issue encountered when running tests. + // It's about a conflict between some dependencies. + // The solution has been brought based on a good answer on Stackoverflow: https://stackoverflow.com/a/42641450/10000150 + exclude group: 'ch.qos.logback', module: 'logback-classic' + } + testImplementation 'org.springframework.boot:spring-boot-starter-webflux' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + } jib { @@ -36,4 +44,32 @@ shadowJar { archiveBaseName.set('JShellPlaygroundBackend') archiveClassifier.set('') archiveVersion.set('') -} \ No newline at end of file +} + +// -- Gradle testing configuration + +def jshellWrapperImageName = rootProject.ext.jShellWrapperImageName; + +processResources { + filesMatching('application.yaml') { + expand("JSHELL_WRAPPER_IMAGE_NAME": jshellWrapperImageName) + } +} + + +def taskBuildDockerImage = tasks.register('buildDockerImage') { + group = 'docker' + description = 'builds jshellwrapper as docker image' + dependsOn project(':JShellWrapper').tasks.named('jibDockerBuild') +} + +def taskRemoveDockerImage = tasks.register('removeDockerImage', Exec) { + group = 'docker' + description = 'removes jshellwrapper image' + commandLine 'docker', 'rmi', '-f', jshellWrapperImageName +} + +test { + dependsOn taskBuildDockerImage + finalizedBy taskRemoveDockerImage +} diff --git a/JShellAPI/src/main/java/org/togetherjava/jshellapi/Config.java b/JShellAPI/src/main/java/org/togetherjava/jshellapi/Config.java index 4c337e9..5935fb5 100644 --- a/JShellAPI/src/main/java/org/togetherjava/jshellapi/Config.java +++ b/JShellAPI/src/main/java/org/togetherjava/jshellapi/Config.java @@ -2,13 +2,28 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; @ConfigurationProperties("jshellapi") public record Config(long regularSessionTimeoutSeconds, long oneTimeSessionTimeoutSeconds, long evalTimeoutSeconds, long evalTimeoutValidationLeeway, int sysOutCharLimit, long maxAliveSessions, int dockerMaxRamMegaBytes, double dockerCPUsUsage, @Nullable String dockerCPUSetCPUs, long schedulerSessionKillScanRateSeconds, - long dockerResponseTimeout, long dockerConnectionTimeout) { + long dockerResponseTimeout, long dockerConnectionTimeout, String jshellWrapperImageName) { + + public static final String JSHELL_WRAPPER_IMAGE_NAME_TAG = ":master"; + + private static boolean checkJShellWrapperImageName(String imageName) { + if (!StringUtils.hasText(imageName) + || !imageName.endsWith(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)) { + return false; + } + + final String imageNameFirstPart = imageName.split(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)[0]; + + return StringUtils.hasText(imageNameFirstPart); + } + public Config { if (regularSessionTimeoutSeconds <= 0) throw new IllegalArgumentException("Invalid value " + regularSessionTimeoutSeconds); @@ -35,5 +50,9 @@ public record Config(long regularSessionTimeoutSeconds, long oneTimeSessionTimeo throw new IllegalArgumentException("Invalid value " + dockerResponseTimeout); if (dockerConnectionTimeout <= 0) throw new IllegalArgumentException("Invalid value " + dockerConnectionTimeout); + + if (!checkJShellWrapperImageName(jshellWrapperImageName)) { + throw new IllegalArgumentException("Invalid value " + jshellWrapperImageName); + } } } diff --git a/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/ApiEndpoints.java b/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/ApiEndpoints.java new file mode 100644 index 0000000..fa068d6 --- /dev/null +++ b/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/ApiEndpoints.java @@ -0,0 +1,14 @@ +package org.togetherjava.jshellapi.rest; + +/** + * Holds endpoints mentioned in controllers. + */ +public final class ApiEndpoints { + private ApiEndpoints() {} + + public static final String BASE = "/jshell"; + public static final String EVALUATE = "/eval"; + public static final String SINGLE_EVALUATE = "/single-eval"; + public static final String SNIPPETS = "/snippets"; + public static final String STARTING_SCRIPT = "/startup_script"; +} diff --git a/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/JShellController.java b/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/JShellController.java index 2c60570..d605bc2 100644 --- a/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/JShellController.java +++ b/JShellAPI/src/main/java/org/togetherjava/jshellapi/rest/JShellController.java @@ -15,13 +15,13 @@ import java.util.List; -@RequestMapping("jshell") +@RequestMapping(ApiEndpoints.BASE) @RestController public class JShellController { private JShellSessionService service; private StartupScriptsService startupScriptsService; - @PostMapping("/eval/{id}") + @PostMapping(ApiEndpoints.EVALUATE + "/{id}") public JShellResult eval(@PathVariable String id, @RequestParam(required = false) StartupScriptId startupScriptId, @RequestBody String code) throws DockerException { @@ -32,7 +32,7 @@ public JShellResult eval(@PathVariable String id, "An operation is already running")); } - @PostMapping("/eval") + @PostMapping(ApiEndpoints.EVALUATE) public JShellResultWithId eval(@RequestParam(required = false) StartupScriptId startupScriptId, @RequestBody String code) throws DockerException { JShellService jShellService = service.session(startupScriptId); @@ -42,7 +42,7 @@ public JShellResultWithId eval(@RequestParam(required = false) StartupScriptId s "An operation is already running"))); } - @PostMapping("/single-eval") + @PostMapping(ApiEndpoints.SINGLE_EVALUATE) public JShellResult singleEval(@RequestParam(required = false) StartupScriptId startupScriptId, @RequestBody String code) throws DockerException { JShellService jShellService = service.oneTimeSession(startupScriptId); @@ -51,7 +51,7 @@ public JShellResult singleEval(@RequestParam(required = false) StartupScriptId s "An operation is already running")); } - @GetMapping("/snippets/{id}") + @GetMapping(ApiEndpoints.SNIPPETS + "/{id}") public List snippets(@PathVariable String id, @RequestParam(required = false) boolean includeStartupScript) throws DockerException { validateId(id); @@ -71,7 +71,7 @@ public void delete(@PathVariable String id) throws DockerException { service.deleteSession(id); } - @GetMapping("/startup_script/{id}") + @GetMapping(ApiEndpoints.STARTING_SCRIPT + "/{id}") public String startupScript(@PathVariable StartupScriptId id) { return startupScriptsService.get(id); } diff --git a/JShellAPI/src/main/java/org/togetherjava/jshellapi/service/DockerService.java b/JShellAPI/src/main/java/org/togetherjava/jshellapi/service/DockerService.java index a2ffb69..4d98f22 100644 --- a/JShellAPI/src/main/java/org/togetherjava/jshellapi/service/DockerService.java +++ b/JShellAPI/src/main/java/org/togetherjava/jshellapi/service/DockerService.java @@ -29,6 +29,8 @@ public class DockerService implements DisposableBean { private final DockerClient client; + private final String jshellWrapperBaseImageName; + public DockerService(Config config) { DefaultDockerClientConfig clientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); @@ -40,6 +42,9 @@ public DockerService(Config config) { .build(); this.client = DockerClientImpl.getInstance(clientConfig, httpClient); + this.jshellWrapperBaseImageName = + config.jshellWrapperImageName().split(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)[0]; + cleanupLeftovers(WORKER_UNIQUE_ID); } @@ -59,22 +64,23 @@ private void cleanupLeftovers(UUID currentId) { public String spawnContainer(long maxMemoryMegs, long cpus, @Nullable String cpuSetCpus, String name, Duration evalTimeout, long sysoutLimit) throws InterruptedException { - String imageName = "togetherjava.org:5001/togetherjava/jshellwrapper"; + boolean presentLocally = client.listImagesCmd() - .withFilter("reference", List.of(imageName)) + .withFilter("reference", List.of(jshellWrapperBaseImageName)) .exec() .stream() .flatMap(it -> Arrays.stream(it.getRepoTags())) - .anyMatch(it -> it.endsWith(":master")); + .anyMatch(it -> it.endsWith(Config.JSHELL_WRAPPER_IMAGE_NAME_TAG)); if (!presentLocally) { - client.pullImageCmd(imageName) + client.pullImageCmd(jshellWrapperBaseImageName) .withTag("master") .exec(new PullImageResultCallback()) .awaitCompletion(5, TimeUnit.MINUTES); } - return client.createContainerCmd(imageName + ":master") + return client + .createContainerCmd(jshellWrapperBaseImageName + Config.JSHELL_WRAPPER_IMAGE_NAME_TAG) .withHostConfig(HostConfig.newHostConfig() .withAutoRemove(true) .withInit(true) diff --git a/JShellAPI/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/JShellAPI/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..4699496 --- /dev/null +++ b/JShellAPI/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,8 @@ +{ + "properties": [ + { + "name": "jshellapi.jshellwrapper-imageName", + "type": "java.lang.String", + "description": "JShellWrapper image name injected from the top-level gradle build file." + } +] } diff --git a/JShellAPI/src/main/resources/application.yaml b/JShellAPI/src/main/resources/application.yaml index 831580c..5a31b22 100644 --- a/JShellAPI/src/main/resources/application.yaml +++ b/JShellAPI/src/main/resources/application.yaml @@ -20,6 +20,9 @@ jshellapi: dockerResponseTimeout: 60 dockerConnectionTimeout: 60 + # JShellWrapper related + jshellWrapperImageName: ${JSHELL_WRAPPER_IMAGE_NAME} + server: error: include-message: always diff --git a/JShellAPI/src/test/java/org/togetherjava/jshellapi/JShellApiTests.java b/JShellAPI/src/test/java/org/togetherjava/jshellapi/JShellApiTests.java new file mode 100644 index 0000000..9b20eea --- /dev/null +++ b/JShellAPI/src/test/java/org/togetherjava/jshellapi/JShellApiTests.java @@ -0,0 +1,76 @@ +package org.togetherjava.jshellapi; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; + +import org.togetherjava.jshellapi.dto.JShellResult; +import org.togetherjava.jshellapi.dto.JShellSnippetResult; +import org.togetherjava.jshellapi.dto.SnippetStatus; +import org.togetherjava.jshellapi.dto.SnippetType; +import org.togetherjava.jshellapi.rest.ApiEndpoints; + +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integrates tests for JShellAPI. + */ +@ActiveProfiles("testing") +@ContextConfiguration(classes = Main.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class JShellApiTests { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private Config testsConfig; + + @Test + @DisplayName("When posting code snippet, evaluate it then return successfully result") + public void evaluateCodeSnippetTest() { + + final String testEvalId = "test"; + + // -- first code snippet eval + executeCodeEvalTest(testEvalId, "int a = 2+2;", 1, "4"); + + // -- second code snippet eval + executeCodeEvalTest(testEvalId, "a * 2", 2, "8"); + } + + private void executeCodeEvalTest(String evalId, String codeSnippet, int expectedId, + String expectedResult) { + final JShellSnippetResult jshellCodeSnippet = new JShellSnippetResult(SnippetStatus.VALID, + SnippetType.ADDITION, expectedId, codeSnippet, expectedResult); + + assertThat(testEval(evalId, codeSnippet)) + .isEqualTo(new JShellResult(List.of(jshellCodeSnippet), null, false, "")); + } + + private JShellResult testEval(String testEvalId, String codeInput) { + final String endpoint = + String.join("/", ApiEndpoints.BASE, ApiEndpoints.EVALUATE, testEvalId); + + return this.webTestClient.mutate() + .responseTimeout(Duration.ofSeconds(testsConfig.evalTimeoutSeconds())) + .build() + .post() + .uri(endpoint) + .bodyValue(codeInput) + .exchange() + .expectStatus() + .isOk() + .expectBody(JShellResult.class) + .value((JShellResult evalResult) -> assertThat(evalResult).isNotNull()) + .returnResult() + .getResponseBody(); + } +} diff --git a/JShellAPI/src/test/resources/application-testing.yaml b/JShellAPI/src/test/resources/application-testing.yaml new file mode 100644 index 0000000..f1eb225 --- /dev/null +++ b/JShellAPI/src/test/resources/application-testing.yaml @@ -0,0 +1,11 @@ +jshellapi: + + # Public API Config + regularSessionTimeoutSeconds: 10 + + # Internal config + schedulerSessionKillScanRateSeconds: 6 + + # Docker service config + dockerResponseTimeout: 6 + dockerConnectionTimeout: 6 diff --git a/JShellWrapper/build.gradle b/JShellWrapper/build.gradle index 279e8c0..ddacab9 100644 --- a/JShellWrapper/build.gradle +++ b/JShellWrapper/build.gradle @@ -24,7 +24,7 @@ test { jib { from.image = 'eclipse-temurin:22-alpine' to { - image = 'togetherjava.org:5001/togetherjava/jshellwrapper:master' ?: 'latest' + image = rootProject.ext.jShellWrapperImageName auth { username = System.getenv('ORG_REGISTRY_USER') ?: '' password = System.getenv('ORG_REGISTRY_PASSWORD') ?: '' @@ -41,4 +41,4 @@ shadowJar { archiveBaseName.set('JShellWrapper') archiveClassifier.set('') archiveVersion.set('') -} \ No newline at end of file +} diff --git a/JShellWrapper/src/test/java/JShellWrapperTest.java b/JShellWrapper/src/test/java/JShellWrapperTest.java index 962313f..7863d19 100644 --- a/JShellWrapper/src/test/java/JShellWrapperTest.java +++ b/JShellWrapper/src/test/java/JShellWrapperTest.java @@ -49,12 +49,10 @@ void testHelloWorld() { @Test void testExpressionResult() { - evalTest( - """ - eval - 1 - "Hello world!\"""", - """ + evalTest(""" + eval + 1 + "Hello world!\"""", """ OK 0 OK @@ -67,12 +65,10 @@ void testExpressionResult() { false """); - evalTest( - """ - eval - 1 - 2+2""", - """ + evalTest(""" + eval + 1 + 2+2""", """ OK 0 OK diff --git a/build.gradle b/build.gradle index 81ebaa4..6359690 100644 --- a/build.gradle +++ b/build.gradle @@ -68,3 +68,7 @@ subprojects { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' } } + +ext { + jShellWrapperImageName = 'togetherjava.org:5001/togetherjava/jshellwrapper:master' ?: 'latest' +}