From 0419fbc2ac19665787db98f588c50ced53a86480 Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Mon, 3 Jul 2017 16:58:05 +0100 Subject: [PATCH] Refactor deployer app so that it starts empty Functions are namespaced under the "app name", e.g. /sample/uppercase is the "uppercase" function in the "sample" app. Also added a README to get started quickly. --- spring-cloud-function-deployer/README.md | 56 ++++++++++++++++ .../deployer/FunctionAdminController.java | 26 +------- .../FunctionExtractingFunctionCatalog.java | 66 +++++++++++++++---- ...actingFunctionCatalogIntegrationTests.java | 65 +++++++++++++++--- ...unctionExtractingFunctionCatalogTests.java | 59 +++++++++++------ 5 files changed, 207 insertions(+), 65 deletions(-) create mode 100644 spring-cloud-function-deployer/README.md diff --git a/spring-cloud-function-deployer/README.md b/spring-cloud-function-deployer/README.md new file mode 100644 index 000000000..069d4501d --- /dev/null +++ b/spring-cloud-function-deployer/README.md @@ -0,0 +1,56 @@ +Spring Cloud Function Deployer is an app that can deploy functions packaged as jars. Once the app is running it can deploy a basic Spring Cloud Function app from a jar with locally cached dependencies in about 500ms (compared to 1500ms for the same application launched from cold). It can be used in a pool as a "warm" JVM to deploy functions quicker than they could be started from scratch. + +The app has a single endpoint called "/admin" that you can use to manage the deployed functions. You GET from it to list the deployed apps, POST to `/{name}` to deploy a named app with a `path` parameter pointing to a jar resource, and then DELETE `/{name}` to remove it. Functions in the apps are exposed as `/{name}/{function}` with the usual conventions for Spring Cloud Function (i.e. the function name is the bean name by default). + +== Running the Deployer + +Run the main class `ApplicationRunner` in this project (from the command line or in the IDE). E.g. + +``` +$ ./mvnw install -DskipTests +$ cd spring-cloud-function-deployer +$ ../mvnw spring-boot:run +``` + +The app starts empty, so the admin resource shows no deployed apps: + +``` +$ curl localhost:8080/admin +{} +``` + +Deploy a sample like this: + +``` +$ curl localhost:8080/admin/pojos -d path=maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT +{"id":"81c568e36c7909ec1dd841aa7ee6d3e3"} +``` + +(takes about 500ms, once the local Maven cache is warm). Deploy another one: + +``` +$ curl localhost:8080/admin/sample -d path=maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT +{"id":"cb2fdb3130f6349f143f4686848ea90f"} +``` + +Undeploy the first one: + +``` +$ curl localhost:8080/admin/pojos -X DELETE +{"name":"81c568e36c7909ec1dd841aa7ee6d3e3","id":"pojos","path":"maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"} +``` + +List the deployed apps: + +``` +$ curl localhost:8080/admin +{"sample":{"name":"sample","id":"cb2fdb3130f6349f143f4686848ea90","path":"maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT"}} +``` + +Send an event to one of the functions: + +``` +$ curl -H "Content-Type: text/plain" localhost:8080/sample/uppercase -d foo +FOO +``` + diff --git a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionAdminController.java b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionAdminController.java index 859b569b5..4d7effa68 100644 --- a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionAdminController.java +++ b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionAdminController.java @@ -15,19 +15,10 @@ */ package org.springframework.cloud.function.deployer; -import java.util.Arrays; import java.util.Collections; import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.loader.thin.ArchiveUtils; -import org.springframework.cloud.deployer.spi.app.AppDeployer; -import org.springframework.cloud.deployer.spi.core.AppDefinition; -import org.springframework.cloud.deployer.spi.core.AppDeploymentRequest; -import org.springframework.context.support.LiveBeansView; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -42,7 +33,7 @@ import org.springframework.web.bind.annotation.RestController; */ @RestController @RequestMapping("/admin") -public class FunctionAdminController implements CommandLineRunner { +public class FunctionAdminController { private final FunctionExtractingFunctionCatalog deployer; @@ -68,21 +59,8 @@ public class FunctionAdminController implements CommandLineRunner { return deployer.deployed(); } - @Override - public void run(String... args) throws Exception { - deploy("sample", "maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"); - } - private String deploy(String name, String path, String... args) throws Exception { - Resource resource = new FileSystemResource( - ArchiveUtils.getArchiveRoot(ArchiveUtils.getArchive(path))); - AppDefinition definition = new AppDefinition(resource.getFilename(), - Collections.singletonMap(LiveBeansView.MBEAN_DOMAIN_PROPERTY_NAME, - "functions." + name)); - AppDeploymentRequest request = new AppDeploymentRequest(definition, resource, - Collections.singletonMap(AppDeployer.GROUP_PROPERTY_KEY, "functions"), - Arrays.asList(args)); - String deployed = deployer.deploy(name, request); + String deployed = deployer.deploy(name, path, args); return deployed; } } diff --git a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java index cd1320960..f4fe6cdc1 100644 --- a/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java +++ b/spring-cloud-function-deployer/src/main/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalog.java @@ -15,8 +15,11 @@ */ package org.springframework.cloud.function.deployer; -import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.function.Consumer; @@ -26,10 +29,16 @@ import java.util.function.Supplier; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.loader.thin.ArchiveUtils; +import org.springframework.cloud.deployer.spi.app.AppDeployer; +import org.springframework.cloud.deployer.spi.core.AppDefinition; import org.springframework.cloud.deployer.spi.core.AppDeploymentRequest; import org.springframework.cloud.deployer.thin.ThinJarAppDeployer; import org.springframework.cloud.function.context.FunctionInspector; import org.springframework.cloud.function.registry.FunctionCatalog; +import org.springframework.context.support.LiveBeansView; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.util.MethodInvoker; public class FunctionExtractingFunctionCatalog @@ -44,6 +53,8 @@ public class FunctionExtractingFunctionCatalog private Map names = new LinkedHashMap<>(); + private Map ids = new LinkedHashMap<>(); + public FunctionExtractingFunctionCatalog() { this("thin", "slim"); } @@ -123,15 +134,19 @@ public class FunctionExtractingFunctionCatalog return (String) inspect(function, "getName"); } - public String deploy(String name, AppDeploymentRequest request) { + public String deploy(String name, String path, String... args) { + Resource resource = new FileSystemResource( + ArchiveUtils.getArchiveRoot(ArchiveUtils.getArchive(path))); + AppDefinition definition = new AppDefinition(resource.getFilename(), + Collections.singletonMap(LiveBeansView.MBEAN_DOMAIN_PROPERTY_NAME, + "functions." + name)); + AppDeploymentRequest request = new AppDeploymentRequest(definition, resource, + Collections.singletonMap(AppDeployer.GROUP_PROPERTY_KEY, "functions"), + Arrays.asList(args)); String id = this.deployer.deploy(request); - try { - this.deployed.put(id, request.getResource().getURI().toString()); - } - catch (IOException e) { - throw new IllegalStateException("Cannot locate resource for " + name, e); - } + this.deployed.put(id, path); this.names.put(name, id); + this.ids.put(id, name); return id; } @@ -142,10 +157,10 @@ public class FunctionExtractingFunctionCatalog throw new IllegalStateException("No such app"); } this.deployer.undeploy(id); - this.deployed.remove(id); - this.names.remove(name); String path = this.deployed.remove(id); - return new DeployedArtifact(id, name, path); + this.names.remove(name); + this.ids.remove(id); + return new DeployedArtifact(name, id, path); } private Object inspect(Object arg, String method) { @@ -170,11 +185,25 @@ public class FunctionExtractingFunctionCatalog } private Object invoke(Class type, String method, Object... arg) { + Set results = new LinkedHashSet<>(); for (String id : this.deployed.keySet()) { Object catalog = this.deployer.getBean(id, type); if (catalog == null) { continue; } + String name = this.ids.get(id); + String prefix = name + "/"; + if (arg.length == 1) { + if (arg[0] instanceof String) { + String specific = arg[0].toString(); + if (specific.startsWith(prefix)) { + arg[0] = specific.substring(prefix.length()); + } + else { + continue; + } + } + } try { MethodInvoker invoker = new MethodInvoker(); invoker.setTargetObject(catalog); @@ -183,14 +212,25 @@ public class FunctionExtractingFunctionCatalog invoker.prepare(); Object result = invoker.invoke(); if (result != null) { - return result; + if (result instanceof Collection) { + for (Object value : (Collection) result) { + results.add(prefix + value); + } + } + else if (result instanceof String) { + return prefix + result; + } + + else { + return result; + } } } catch (Exception e) { throw new IllegalStateException("Cannot extract catalog", e); } } - return null; + return arg.length > 0 ? null : results; } public Map deployed() { diff --git a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java index 6566926ca..99775e8e7 100644 --- a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java +++ b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogIntegrationTests.java @@ -15,13 +15,17 @@ */ package org.springframework.cloud.function.deployer; +import java.net.URI; + import org.junit.AfterClass; import org.junit.BeforeClass; -import org.junit.Ignore; import org.junit.Test; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; import org.springframework.util.SocketUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -30,19 +34,33 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Dave Syer * */ -@Ignore -// TODO: Salvage some stuff from this project public class FunctionExtractingFunctionCatalogIntegrationTests { private static ConfigurableApplicationContext context; private static int port; @BeforeClass - public static void open() { + public static void open() throws Exception { port = SocketUtils.findAvailableTcpPort(); // System.setProperty("debug", "true"); context = new ApplicationRunner().start("--server.port=" + port, "--spring.cloud.stream.enabled=false"); + deploy("sample", "maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT"); + } + + private static void deploy(String name, String path) throws Exception { + ResponseEntity result = new TestRestTemplate().postForEntity( + "http://localhost:" + port + "/admin/" + name + "?path=" + path, "", + String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private static String undeploy(String name) throws Exception { + ResponseEntity result = new TestRestTemplate().exchange(RequestEntity + .delete(new URI("http://localhost:" + port + "/admin/" + name)).build(), + String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + return result.getBody(); } @AfterClass @@ -52,18 +70,49 @@ public class FunctionExtractingFunctionCatalogIntegrationTests { } } + @Test + public void listing() { + assertThat(new TestRestTemplate() + .getForObject("http://localhost:" + port + "/admin", String.class)) + .startsWith("{").contains("sample"); + } + @Test public void words() { assertThat(new TestRestTemplate() - .getForObject("http://localhost:" + port + "/words", String.class)) - .isEqualTo("{\"value\":\"foo\"}{\"value\":\"bar\"}"); + .getForObject("http://localhost:" + port + "/sample/words", String.class)) + .isEqualTo("[{\"value\":\"foo\"},{\"value\":\"bar\"}]"); } @Test public void uppercase() { assertThat(new TestRestTemplate().postForObject( - "http://localhost:" + port + "/uppercase", "{\"value\":\"foo\"}", - String.class)).isEqualTo("{\"value\":\"FOO\"}"); + "http://localhost:" + port + "/sample/uppercase", "{\"value\":\"foo\"}", + String.class)).isEqualTo("[{\"value\":\"FOO\"}]"); + } + + @Test + public void another() throws Exception { + deploy("strings", "maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT"); + assertThat(new TestRestTemplate().getForObject( + "http://localhost:" + port + "/strings/words", String.class)) + .isEqualTo("[\"foo\",\"bar\"]"); + } + + @Test + public void cycle() throws Exception { + String undeploy = undeploy("sample"); + assertThat(undeploy.contains("\"name\":\"sample\"")); + assertThat(undeploy.contains( + "\"path\":\"maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT\"")); + ResponseEntity result = new TestRestTemplate().exchange(RequestEntity + .get(new URI("http://localhost:" + port + "/sample/words")).build(), + String.class); + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + deploy("sample", "maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"); + assertThat(new TestRestTemplate().postForObject( + "http://localhost:" + port + "/sample/uppercase", "{\"value\":\"foo\"}", + String.class)).isEqualTo("[{\"value\":\"FOO\"}]"); } } diff --git a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogTests.java b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogTests.java index 4ced74bb0..4b750a468 100644 --- a/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogTests.java +++ b/spring-cloud-function-deployer/src/test/java/org/springframework/cloud/function/deployer/FunctionExtractingFunctionCatalogTests.java @@ -15,21 +15,13 @@ */ package org.springframework.cloud.function.deployer; -import java.util.Arrays; -import java.util.Collections; - import org.junit.AfterClass; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; -import org.springframework.boot.loader.thin.ArchiveUtils; import org.springframework.boot.loader.tools.LogbackInitializer; -import org.springframework.cloud.deployer.spi.core.AppDefinition; -import org.springframework.cloud.deployer.spi.core.AppDeploymentRequest; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; import static org.assertj.core.api.Assertions.assertThat; @@ -55,8 +47,10 @@ public class FunctionExtractingFunctionCatalogTests { @Before public void init() throws Exception { if (id == null) { - id = deploy("maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT"); + deploy("sample", "maven://com.example:function-sample:1.0.0.BUILD-SNAPSHOT"); // "--debug"); + id = deploy("pojos", + "maven://com.example:function-sample-pojo:1.0.0.BUILD-SNAPSHOT"); } } @@ -64,38 +58,63 @@ public class FunctionExtractingFunctionCatalogTests { public static void close() { if (id != null) { deployer.undeploy("sample"); + deployer.undeploy("pojos"); } } + @Test + public void listFunctions() throws Exception { + assertThat(deployer.getFunctionNames()).contains("sample/uppercase", + "pojos/uppercase"); + } + + @Test + public void nameFunction() throws Exception { + assertThat(deployer.getName(deployer.lookupFunction("sample/uppercase"))) + .isEqualTo("sample/uppercase"); + } + @Test public void deployAndExtractFunctions() throws Exception { // This one can only work if you change the boot classpath to contain reactor-core // and reactive-streams expected.expect(ClassCastException.class); @SuppressWarnings("unchecked") - Flux result = (Flux) deployer.lookupFunction("uppercase") + Flux result = (Flux) deployer.lookupFunction("pojos/uppercase") .apply(Flux.just("foo")); assertThat(result.blockFirst()).isEqualTo("FOO"); } + @Test + public void listConsumers() throws Exception { + assertThat(deployer.getConsumerNames()).isEmpty(); + } + @Test public void deployAndExtractConsumers() throws Exception { - assertThat(deployer.lookupConsumer("sink")).isNull(); + assertThat(deployer.lookupConsumer("pojos/sink")).isNull(); + } + + @Test + public void listSuppliers() throws Exception { + assertThat(deployer.getSupplierNames()).contains("sample/words", "pojos/words"); + } + + @Test + public void nameSupplier() throws Exception { + assertThat(deployer.getName(deployer.lookupSupplier("sample/words"))) + .isEqualTo("sample/words"); } @Test public void deployAndExtractSuppliers() throws Exception { - assertThat(deployer.lookupSupplier("words")).isNotNull(); + assertThat(deployer.lookupSupplier("sample/words")).isNotNull(); + assertThat(deployer.lookupSupplier("pojos/words")).isNotNull(); } - private static String deploy(String jarName, String... args) throws Exception { - Resource resource = new FileSystemResource( - ArchiveUtils.getArchiveRoot(ArchiveUtils.getArchive(jarName))); - AppDefinition definition = new AppDefinition(resource.getFilename(), - Collections.emptyMap()); - AppDeploymentRequest request = new AppDeploymentRequest(definition, resource, - Collections.emptyMap(), Arrays.asList(args)); - String deployed = deployer.deploy("sample", request); + private static String deploy(String name, String path, String... args) + throws Exception { + String deployed = deployer.deploy(name, path, args); return deployed; }