Actionloop java (#84)

diff --git a/.gitignore b/.gitignore
index f65f024..1baf0a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,11 @@
 results
 *.retry
 
+# Java artifacts
+*.class
+*.jar
+*.zip
+
 # Environments
 /ansible/environments/*
 !/ansible/environments/distributed
@@ -70,3 +75,4 @@
 !tests/dat/actions/python_virtualenv_dir.zip
 !tests/dat/actions/python_virtualenv_name.zip
 !tests/dat/actions/zippedaction.zip
+
diff --git a/.travis.yml b/.travis.yml
index 685e8e8..936b1ef 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,7 +8,11 @@
 - 2.12.7
 services:
 - docker
-
+# required to support multi-stage build
+addons:
+   apt:
+     packages:
+       - docker-ce
 notifications:
   email: false
   webhooks:
diff --git a/core/java8/proxy/src/main/java/org/apache/openwhisk/runtime/java/action/JarLoader.java b/core/java8/proxy/src/main/java/org/apache/openwhisk/runtime/java/action/JarLoader.java
index 03f7c90..e17d0a9 100644
--- a/core/java8/proxy/src/main/java/org/apache/openwhisk/runtime/java/action/JarLoader.java
+++ b/core/java8/proxy/src/main/java/org/apache/openwhisk/runtime/java/action/JarLoader.java
@@ -68,7 +68,6 @@
         if (m.getReturnType() != JsonObject.class || !Modifier.isStatic(modifiers) || !Modifier.isPublic(modifiers)) {
             throw new NoSuchMethodException("main");
         }
-
         this.mainMethod = m;
     }
 
diff --git a/core/java8actionloop/Dockerfile b/core/java8actionloop/Dockerfile
new file mode 100644
index 0000000..53b4f35
--- /dev/null
+++ b/core/java8actionloop/Dockerfile
@@ -0,0 +1,48 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+FROM golang:1.12 as builder
+RUN env CGO_ENABLED=0 go get github.com/apache/incubator-openwhisk-runtime-go/main
+
+FROM adoptopenjdk/openjdk8:x86_64-ubuntu-jdk8u212-b03
+
+RUN rm -rf /var/lib/apt/lists/* && apt-get clean && apt-get update \
+	&& apt-get install -y --no-install-recommends locales python vim \
+	&& rm -rf /var/lib/apt/lists/* \
+	&& locale-gen en_US.UTF-8
+
+ENV LANG="en_US.UTF-8" \
+	LANGUAGE="en_US:en" \
+	LC_ALL="en_US.UTF-8" \
+	VERSION=8 \
+	UPDATE=212 \
+	BUILD=3
+
+RUN locale-gen en_US.UTF-8 ;\
+    mkdir -p /javaAction/action /usr/java/src /usr/java/lib
+
+WORKDIR /javaAction
+COPY --from=builder /go/bin/main /bin/proxy
+
+ADD https://search.maven.org/remotecontent?filepath=com/google/code/gson/gson/2.8.5/gson-2.8.5.jar /usr/java/lib/gson-2.8.5.jar
+ADD lib/src/Launcher.java /usr/java/src/Launcher.java
+RUN cd /usr/java/src ;\
+    javac -cp /usr/java/lib/gson-2.8.5.jar Launcher.java ;\
+    jar cvf /usr/java/lib/launcher.jar *.class
+ADD bin/compile /bin/compile
+ENV OW_COMPILER=/bin/compile
+ENV OW_SAVE_JAR=exec.jar
+ENTRYPOINT /bin/proxy
diff --git a/core/java8actionloop/Makefile b/core/java8actionloop/Makefile
new file mode 100644
index 0000000..61e98d4
--- /dev/null
+++ b/core/java8actionloop/Makefile
@@ -0,0 +1,55 @@
+IMG=actionloop-java-v8:latest
+PREFIX=docker.io/openwhisk
+INVOKE=python ../../tools/invoke.py
+MAIN_JAR=../../example/main.jar
+
+build:
+	docker build -t $(IMG) .
+
+push: build
+	docker login
+	docker tag $(IMG) $(PREFIX)/$(IMG) 
+	docker push $(PREFIX)/$(IMG)
+
+clean:
+	docker rmi -f $(IMG)
+
+start: build
+	docker run -p 8080:8080 -ti -v $(PWD):/proxy $(IMG)
+
+debug: build
+	docker run -p 8080:8080 -ti --entrypoint=/bin/bash -v $(PWD):/mnt -e OW_COMPILER=/mnt/bin/compile $(IMG)
+
+.PHONY: build push clean start debug
+
+$(MAIN_JAR):
+	$(MAKE) $< -C ../../example main.jar
+
+## You need to execute make start in another terminal 
+
+test-source:
+	$(INVOKE) init ../../example/Main.java
+	$(INVOKE) run '{}'
+	$(INVOKE) run '{"name":"Mike"}'
+
+test-source-hello:
+	$(INVOKE) init action.Hello#hello ../../example/action/Hello.java
+	$(INVOKE) run '{}'
+	$(INVOKE) run '{"name":"Mike"}'
+
+test-jar: $(MAIN_JAR)
+	$(INVOKE) init action.Hello#hello $(MAIN_JAR)
+	$(INVOKE) run '{}'
+	$(INVOKE) run '{"name":"Mike"}'
+
+test-src-zip:
+	$(MAKE) -C ../../example src.zip
+	$(INVOKE) init ../../example/src.zip
+	$(INVOKE) run '{}'
+	$(INVOKE) run '{"name":"Mike"}'
+
+test-bin-zip:
+	$(MAKE) -C ../../example bin.zip
+	$(INVOKE) init ../../example/bin.zip
+	$(INVOKE) run '{}'
+	$(INVOKE) run '{"name":"Mike"}'
diff --git a/core/java8actionloop/bin/compile b/core/java8actionloop/bin/compile
new file mode 100755
index 0000000..6f992b6
--- /dev/null
+++ b/core/java8actionloop/bin/compile
@@ -0,0 +1,129 @@
+#!/usr/bin/env python
+"""Java Action Builder
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+
+from __future__ import print_function
+from os.path import abspath, exists, dirname
+import os, sys, codecs, subprocess, shutil, logging
+
+def copy(src, dst):
+    with codecs.open(src, 'r', 'utf-8') as s:
+        body = s.read()
+        with codecs.open(dst, 'w', 'utf-8') as d:
+            d.write(body)
+
+def find_with_ext(basedir, ext):
+    result = []
+    for root, _, files in os.walk(basedir, topdown=False):
+        for name in files:
+            if name.endswith(ext):
+                result.append(os.path.join(root,name))
+    return result
+
+def javac(sources, classpath, target_dir):
+    cmd = [ "javac", 
+            "-encoding", "UTF-8",
+            "-cp", ":".join(classpath),
+            "-d", target_dir
+    ]+sources
+    #print(cmd)
+    logging.info(" ".join(cmd))
+    p = subprocess.Popen(cmd,
+                         stdout=subprocess.PIPE,
+                         stderr=subprocess.PIPE)
+    (o, e) = p.communicate()
+    if isinstance(o, bytes) and not isinstance(o, str):
+        o = o.decode('utf-8')
+    if isinstance(e, bytes) and not isinstance(e, str):
+        e = e.decode('utf-8')
+    ok = True
+    if o:
+        ok = False
+        sys.stdout.write(o)
+        sys.stdout.flush()
+    if e:
+        ok = False
+        sys.stderr.write(e)
+        sys.stderr.flush()
+    return ok
+
+def build(source_dir, classpath, target_dir, mainClass):
+ 
+    # copy exec to <main>.java if it is there
+    src = "%s/exec" % source_dir
+    if os.path.isfile(src):
+        main_java = "%s/%s.java" % (source_dir, mainClass.split(".")[-1])
+        copy(src,main_java)
+        logging.info("renamed exec to %s", main_java)
+
+    # look for sources and compile
+    sources = find_with_ext(source_dir, ".java")
+    if len(sources) > 0:
+        jars = find_with_ext(source_dir, ".jar")
+        logging.info("compiling  %d sources with %d jars", len(sources),len(jars))
+        return javac(sources, classpath+jars, source_dir)
+    
+    return True 
+
+def write_exec(target_dir, classpath, main):
+    launcher = "%s/exec" % target_dir
+    jars = find_with_ext(target_dir, ".jar")
+    jars.append(target_dir)
+    cmd = """#!/bin/bash
+cd "%s"
+/opt/java/openjdk/bin/java -Dfile.encoding=UTF-8 -cp "%s" Launcher "%s" "$@"
+""" %( target_dir, ":".join(classpath+jars), main)
+    with codecs.open(launcher, 'w', 'utf-8') as d:
+        d.write(cmd)
+    os.chmod(launcher, 0o755)
+
+def parseMain(main):
+    if main == "main":
+        return "Main", "main"
+    a = main.split("#")
+    if(len(a)==1):
+        return a[0], "main"
+    return a[0], a[1]
+
+def assemble(argv):
+    mainClass, mainMethod = parseMain(argv[1])
+    logging.info("%s %s", mainClass, mainMethod)
+    source_dir = os.path.abspath(argv[2])
+    target_dir = os.path.abspath(argv[3])
+    classpath = ["/usr/java/lib/launcher.jar", "/usr/java/lib/gson-2.8.5.jar"]
+
+    # build   
+    if build(source_dir, classpath, target_dir, mainClass):
+        shutil.rmtree(target_dir)
+        shutil.move(source_dir, target_dir)
+        logging.info("moved %s to %s", source_dir, target_dir)
+        # write the launcher is it is there
+        write_exec(target_dir, classpath, "%s#%s" % (mainClass, mainMethod))
+        # launch it to check it can load with immediate exit - it should not produce any output
+        subprocess.call(["%s/exec" % target_dir, "-exit"])
+    
+    sys.stdout.flush()
+    sys.stderr.flush()
+    
+if __name__ == '__main__':
+    if len(sys.argv) < 4:
+        sys.stdout.write("usage: <main-class> <source-dir> <target-dir>\n")
+        sys.exit(1)
+    logging.basicConfig(filename="/var/log/compile.log")
+    assemble(sys.argv)
diff --git a/core/java8actionloop/build.gradle b/core/java8actionloop/build.gradle
new file mode 100644
index 0000000..1a7ddcf
--- /dev/null
+++ b/core/java8actionloop/build.gradle
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ext.dockerImageName = 'actionloop-java-v8'
+apply from: '../../gradle/docker.gradle'
diff --git a/core/java8actionloop/lib/src/Launcher.java b/core/java8actionloop/lib/src/Launcher.java
new file mode 100644
index 0000000..ef571e9
--- /dev/null
+++ b/core/java8actionloop/lib/src/Launcher.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.io.*;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import com.google.gson.*;
+import java.security.Permission;
+import java.lang.reflect.InvocationTargetException;
+
+class Launcher {
+
+    private static String mainClassName = "Main";
+    private static String mainMethodName = "main";
+    private static Class mainClass = null;
+    private static Method mainMethod = null;
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private static void augmentEnv(Map<String, String> newEnv) {
+        try {
+            for (Class cl : Collections.class.getDeclaredClasses()) {
+                if ("java.util.Collections$UnmodifiableMap".equals(cl.getName())) {
+                    Field field = cl.getDeclaredField("m");
+                    field.setAccessible(true);
+                    Object obj = field.get(System.getenv());
+                    Map<String, String> map = (Map<String, String>) obj;
+                    map.putAll(newEnv);
+                }
+            }
+        } catch (Exception e) {}
+    }
+
+    private static void initMain(String[] args) throws Exception {
+        if(args.length > 0)
+            mainClassName = args[0];
+        int pos = mainClassName.indexOf("#");
+        if(pos != -1) {
+            if(pos + 1 != mainClassName.length())
+                mainMethodName = args[0].substring(pos+1);
+            mainClassName = args[0].substring(0,pos);
+        }
+
+        mainClass = Class.forName(mainClassName);
+        Method m = mainClass.getMethod(mainMethodName, new Class[] { JsonObject.class });
+        m.setAccessible(true);
+        int modifiers = m.getModifiers();
+        if (m.getReturnType() != JsonObject.class || !Modifier.isStatic(modifiers) || !Modifier.isPublic(modifiers)) {
+            throw new NoSuchMethodException(mainMethodName);
+        }
+        mainMethod = m;
+    }
+
+    private static JsonObject invokeMain(JsonObject arg, Map<String, String> env) throws Exception {
+        augmentEnv(env);
+        return (JsonObject) mainMethod.invoke(null, arg);
+    }
+
+    private static SecurityManager defaultSecurityManager = null;
+    private static void installSecurityManager() {
+        defaultSecurityManager = System.getSecurityManager();
+        System.setSecurityManager(new SecurityManager() {
+            @Override
+            public void checkPermission(Permission p) {
+                // Not throwing means accepting anything.
+            }
+
+            @Override
+            public void checkPermission(Permission p, Object ctx) {
+                // Not throwing means accepting anything.
+            }
+
+            @Override
+            public void checkExit(int status) {
+                super.checkExit(status);
+                throw new SecurityException("System.exit(" + status + ") called from within an action.");
+            }
+        });
+    }
+
+    private static void uninstallSecurityManager() {
+        if(defaultSecurityManager != null) {
+            System.setSecurityManager(defaultSecurityManager);
+        }
+    }
+
+    public static void main(String[] args) throws Exception {
+
+        initMain(args);
+
+        // exit after main class loading if "exit" specified
+        // used to check healthy launch after init
+        if(args.length >1 && args[1] == "-exit")
+            System.exit(0);
+
+        // install a security manager to prevent exit
+        installSecurityManager();
+
+        BufferedReader in = new BufferedReader(
+                new InputStreamReader(System.in, "UTF-8"));
+        PrintWriter out = new PrintWriter(
+                new OutputStreamWriter(
+                        new FileOutputStream("/dev/fd/3"), "UTF-8"));
+        JsonParser json = new JsonParser();
+        JsonObject empty = json.parse("{}").getAsJsonObject();
+        String input = "";
+        while (true) {
+            try {
+                input = in.readLine();
+                if (input == null)
+                    break;
+                JsonElement element = json.parse(input);
+                JsonObject payload = empty.deepCopy();
+                HashMap<String, String> env = new HashMap<String, String>();
+                if (element.isJsonObject()) {
+                    // collect payload and environment
+                    for (Map.Entry<String, JsonElement> entry : element.getAsJsonObject().entrySet()) {
+                        if (entry.getKey().equals("value")) {
+                            if (entry.getValue().isJsonObject())
+                                payload = entry.getValue().getAsJsonObject();
+                        } else {
+                            env.put(String.format("__OW_%s", entry.getKey().toUpperCase()),
+                                    entry.getValue().getAsString());
+                        }
+                    }
+                    augmentEnv(env);
+                }
+                JsonElement response = invokeMain(payload, env);
+                out.println(response.toString());
+            } catch(NullPointerException npe) {
+                System.out.println("the action returned null");
+                npe.printStackTrace(System.err);
+                JsonObject error = new JsonObject();
+                error.addProperty("error", "the action returned null");
+                out.println(error.toString());
+                out.flush();
+            } catch(InvocationTargetException ite) {
+                Throwable ex = ite;
+                if(ite.getCause() != null)
+                    ex = ite.getCause();
+                ex.printStackTrace(System.err);
+                JsonObject error = new JsonObject();
+                error.addProperty("error", ex.getMessage());
+                out.println(error.toString());
+                out.flush();
+            } catch (Exception ex) {
+                ex.printStackTrace(System.err);
+                JsonObject error = new JsonObject();
+                error.addProperty("error", ex.getMessage());
+                out.println(error.toString());
+                out.flush();
+            }
+            out.flush();
+            System.out.flush();
+            System.err.flush();
+        }
+        uninstallSecurityManager();
+    }
+}
+
diff --git a/settings.gradle b/settings.gradle
index 464ae37..b9557da 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -19,6 +19,7 @@
 
 include 'core:java8'
 include 'core:java8:proxy'
+include 'core:java8actionloop'
 
 rootProject.name = 'runtime-java'
 
diff --git a/tests/src/test/scala/actionContainers/JavaActionContainerTests.scala b/tests/src/test/scala/actionContainers/JavaActionContainerTests.scala
index a990f33..052847c 100644
--- a/tests/src/test/scala/actionContainers/JavaActionContainerTests.scala
+++ b/tests/src/test/scala/actionContainers/JavaActionContainerTests.scala
@@ -24,13 +24,21 @@
 import spray.json._
 import actionContainers.ResourceHelpers.JarBuilder
 import actionContainers.ActionContainer.withContainer
+import org.scalatest.Matchers
 
 @RunWith(classOf[JUnitRunner])
-class JavaActionContainerTests extends BasicActionRunnerTests with WskActorSystem {
+class JavaActionContainerTests extends BasicActionRunnerTests with WskActorSystem with Matchers {
+
+  val image = "java8action"
+  val errPrefix = "\"An error has occurred (see logs for details):"
+  val checkStreamsAtInit = true
+  // this is true for the Java based runtime but not for actionloop
+  // since it does not parse the output and the invoker change the status anyway
+  val runtimeDetectErrors = true
 
   // Helpers specific to java actions
   override def withActionContainer(env: Map[String, String] = Map.empty)(
-    code: ActionContainer => Unit): (String, String) = withContainer("java8action", env)(code)
+    code: ActionContainer => Unit): (String, String) = withContainer(image, env)(code)
 
   behavior of "Java action"
 
@@ -48,23 +56,23 @@
       JarBuilder.mkBase64Jar(
         Seq("example", "HelloWhisk.java") ->
           """
-          | package example;
-          |
-          | import com.google.gson.JsonObject;
-          |
-          | public class HelloWhisk {
-          |     public static JsonObject main(JsonObject args) {
-          |         JsonObject response = new JsonObject();
-          |         response.addProperty("api_host", System.getenv("__OW_API_HOST"));
-          |         response.addProperty("api_key", System.getenv("__OW_API_KEY"));
-          |         response.addProperty("namespace", System.getenv("__OW_NAMESPACE"));
-          |         response.addProperty("action_name", System.getenv("__OW_ACTION_NAME"));
-          |         response.addProperty("activation_id", System.getenv("__OW_ACTIVATION_ID"));
-          |         response.addProperty("deadline", System.getenv("__OW_DEADLINE"));
-          |         return response;
-          |     }
-          | }
-        """.stripMargin.trim),
+            | package example;
+            |
+            | import com.google.gson.JsonObject;
+            |
+            | public class HelloWhisk {
+            |     public static JsonObject main(JsonObject args) {
+            |         JsonObject response = new JsonObject();
+            |         response.addProperty("api_host", System.getenv("__OW_API_HOST"));
+            |         response.addProperty("api_key", System.getenv("__OW_API_KEY"));
+            |         response.addProperty("namespace", System.getenv("__OW_NAMESPACE"));
+            |         response.addProperty("action_name", System.getenv("__OW_ACTION_NAME"));
+            |         response.addProperty("activation_id", System.getenv("__OW_ACTIVATION_ID"));
+            |         response.addProperty("deadline", System.getenv("__OW_DEADLINE"));
+            |         return response;
+            |     }
+            | }
+          """.stripMargin.trim),
       main = "example.HelloWhisk")
   }
 
@@ -73,19 +81,20 @@
       JarBuilder.mkBase64Jar(
         Seq("example", "HelloWhisk.java") ->
           """
-          | package example;
-          |
-          | import com.google.gson.JsonObject;
-          |
-          | public class HelloWhisk {
-          |     public static JsonObject main(JsonObject args) {
-          |         System.out.println("hello stdout");
-          |         System.err.println("hello stderr");
-          |         return args;
-          |     }
-          | }
-        """.stripMargin.trim),
+            | package example;
+            |
+            | import com.google.gson.JsonObject;
+            |
+            | public class HelloWhisk {
+            |     public static JsonObject main(JsonObject args) {
+            |         System.out.println("hello stdout");
+            |         System.err.println("hello stderr");
+            |         return args;
+            |     }
+            | }
+          """.stripMargin.trim),
       "example.HelloWhisk")
+
   }
 
   override val testUnicode = {
@@ -93,21 +102,21 @@
       JarBuilder.mkBase64Jar(
         Seq("example", "HelloWhisk.java") ->
           """
-          | package example;
-          |
-          | import com.google.gson.JsonObject;
-          |
-          | public class HelloWhisk {
-          |     public static JsonObject main(JsonObject args) {
-          |         String delimiter = args.getAsJsonPrimitive("delimiter").getAsString();
-          |         JsonObject response = new JsonObject();
-          |          String str = delimiter + " ☃ " + delimiter;
-          |          System.out.println(str);
-          |          response.addProperty("winter", str);
-          |          return response;
-          |     }
-          | }
-        """.stripMargin),
+            | package example;
+            |
+            | import com.google.gson.JsonObject;
+            |
+            | public class HelloWhisk {
+            |     public static JsonObject main(JsonObject args) {
+            |         String delimiter = args.getAsJsonPrimitive("delimiter").getAsString();
+            |         JsonObject response = new JsonObject();
+            |          String str = delimiter + " ☃ " + delimiter;
+            |          System.out.println(str);
+            |          response.addProperty("winter", str);
+            |          return response;
+            |     }
+            | }
+          """.stripMargin),
       "example.HelloWhisk")
   }
 
@@ -115,15 +124,15 @@
     JarBuilder.mkBase64Jar(
       Seq("example", "HelloWhisk.java") ->
         s"""
-        | package example;
-        |
+           | package example;
+           |
         | import com.google.gson.JsonObject;
-        |
+           |
         | public class HelloWhisk {
-        |     public static JsonObject $main(JsonObject args) {
-        |         return args;
-        |     }
-        | }
+           |     public static JsonObject $main(JsonObject args) {
+           |         return args;
+           |     }
+           | }
       """.stripMargin.trim)
   }
 
@@ -145,21 +154,27 @@
         val (initCode, out) = c.init(initPayload(echo("hello"), s"example.HelloWhisk$m"))
         initCode shouldBe 502
 
-        out shouldBe {
-          val error = m match {
-            case c if c == "x" || c == "!" => s"java.lang.ClassNotFoundException: example.HelloWhisk$c"
-            case "#bogus"                  => "java.lang.NoSuchMethodException: example.HelloWhisk.bogus(com.google.gson.JsonObject)"
-            case _                         => "java.lang.NoSuchMethodException: example.HelloWhisk.main(com.google.gson.JsonObject)"
-          }
-          Some(JsObject("error" -> s"An error has occurred (see logs for details): $error".toJson))
+        val expected = m match {
+          case c if c == "x" || c == "!" => s"$errPrefix java.lang.ClassNotFoundException: example.HelloWhisk$c"
+          case "#bogus" =>
+            s"$errPrefix java.lang.NoSuchMethodException: example.HelloWhisk.bogus(com.google.gson.JsonObject)"
+          case _ => s"$errPrefix java.lang.NoSuchMethodException: example.HelloWhisk.main(com.google.gson.JsonObject)"
         }
+
+        val error = out.get.fields.get("error").get.toString()
+
+        println(error)
+        println(expected)
+
+        error should startWith(expected)
       }
 
-      checkStreams(out, err, {
-        case (o, e) =>
-          o shouldBe empty
-          e should not be empty
-      })
+      if (checkStreamsAtInit)
+        checkStreams(out, err, {
+          case (o, e) =>
+            o shouldBe empty
+            e should not be empty
+        })
     }
   }
 
@@ -178,10 +193,11 @@
     }
 
     // Somewhere, the logs should contain an exception.
-    checkStreams(out, err, {
-      case (o, e) =>
-        (o + e).toLowerCase should include("exception")
-    })
+    if (checkStreamsAtInit)
+      checkStreams(out, err, {
+        case (o, e) =>
+          (o + e).toLowerCase should include("exception")
+      })
   }
 
   it should "return some error on action error" in {
@@ -204,7 +220,10 @@
       initCode should be(200)
 
       val (runCode, runRes) = c.run(runPayload(JsObject.empty))
-      runCode should not be (200)
+      if (runtimeDetectErrors)
+        runCode should not be (200)
+      else
+        runCode should be(200)
 
       runRes shouldBe defined
       runRes.get.fields.get("error") shouldBe defined
@@ -302,7 +321,10 @@
       initCode should be(200)
 
       val (runCode, runRes) = c.run(runPayload(JsObject.empty))
-      runCode should not be (200)
+      if (runtimeDetectErrors)
+        runCode should not be (200)
+      else
+        runCode should be(200)
 
       runRes shouldBe defined
       runRes.get.fields.get("error") shouldBe defined
@@ -334,7 +356,10 @@
       initCode should be(200)
 
       val (runCode, runRes) = c.run(runPayload(JsObject.empty))
-      runCode should not be (200)
+      if (runtimeDetectErrors)
+        runCode should not be (200)
+      else
+        runCode should be(200)
 
       runRes shouldBe defined
       runRes.get.fields.get("error") shouldBe defined
diff --git a/tests/src/test/scala/actionContainers/JavaActionLoopContainerTests.scala b/tests/src/test/scala/actionContainers/JavaActionLoopContainerTests.scala
new file mode 100644
index 0000000..486a742
--- /dev/null
+++ b/tests/src/test/scala/actionContainers/JavaActionLoopContainerTests.scala
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package actionContainers
+
+import common.WskActorSystem
+import org.junit.runner.RunWith
+import org.scalatest.junit.JUnitRunner
+
+@RunWith(classOf[JUnitRunner])
+class JavaActionLoopContainerTests extends JavaActionContainerTests with WskActorSystem {
+  override val errPrefix = "\"Exception in thread \\\"main\\\""
+  override val checkStreamsAtInit = false
+  override val image = "actionloop-java-v8"
+  override val runtimeDetectErrors = false
+}
diff --git a/tests/src/test/scala/actionContainers/JavaActionLoopSourceTests.scala b/tests/src/test/scala/actionContainers/JavaActionLoopSourceTests.scala
new file mode 100644
index 0000000..ec627b5
--- /dev/null
+++ b/tests/src/test/scala/actionContainers/JavaActionLoopSourceTests.scala
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package actionContainers
+
+import actionContainers.ActionContainer.withContainer
+import common.WskActorSystem
+import org.junit.runner.RunWith
+import org.scalatest.junit.JUnitRunner
+
+@RunWith(classOf[JUnitRunner])
+class JavaActionLoopSourceTests extends BasicActionRunnerTests with WskActorSystem {
+
+  val image = "actionloop-java-v8"
+
+  // Helpers specific to java actions
+  override def withActionContainer(env: Map[String, String] = Map.empty)(
+    code: ActionContainer => Unit): (String, String) = withContainer(image, env)(code)
+
+  behavior of "Java actionloop"
+
+  override val testNoSourceOrExec = {
+    TestConfig("")
+  }
+
+  override val testNotReturningJson = {
+    // skip this test since and add own below (see Nuller)
+    TestConfig("", skipTest = true)
+  }
+
+  override val testEnv = {
+    TestConfig(
+      """
+          |package example;
+          |
+          |import com.google.gson.JsonObject;
+          |
+          |public class HelloWhisk {
+          |     public static JsonObject main(JsonObject args) {
+          |         JsonObject response = new JsonObject();
+          |         response.addProperty("api_host", System.getenv("__OW_API_HOST"));
+          |         response.addProperty("api_key", System.getenv("__OW_API_KEY"));
+          |         response.addProperty("namespace", System.getenv("__OW_NAMESPACE"));
+          |         response.addProperty("action_name", System.getenv("__OW_ACTION_NAME"));
+          |         response.addProperty("activation_id", System.getenv("__OW_ACTIVATION_ID"));
+          |         response.addProperty("deadline", System.getenv("__OW_DEADLINE"));
+          |         return response;
+          |     }
+          |}
+        """.stripMargin.trim,
+      main = "example.HelloWhisk")
+  }
+
+  override val testEcho = {
+    TestConfig(
+      """
+          |package example;
+          |
+          |import com.google.gson.JsonObject;
+          |
+          |public class HelloWhisk {
+          |     public static JsonObject main(JsonObject args) {
+          |         System.out.println("hello stdout");
+          |         System.err.println("hello stderr");
+          |         return args;
+          |     }
+          |}
+        """.stripMargin.trim,
+      "example.HelloWhisk")
+
+  }
+
+  override val testUnicode = {
+    TestConfig(
+      """
+          |package example;
+          |
+          |import com.google.gson.JsonObject;
+          |
+          |public class HelloWhisk {
+          |     public static JsonObject main(JsonObject args) {
+          |         String delimiter = args.getAsJsonPrimitive("delimiter").getAsString();
+          |         JsonObject response = new JsonObject();
+          |         String str = delimiter + " ☃ " + delimiter;
+          |         System.out.println(str);
+          |         response.addProperty("winter", str);
+          |         return response;
+          |     }
+          |}
+        """.stripMargin,
+      "example.HelloWhisk")
+  }
+
+  def echo(main: String = "main") = {
+    s"""
+        |package example;
+        |
+        |import com.google.gson.JsonObject;
+        |
+        |public class HelloWhisk {
+        |     public static JsonObject $main(JsonObject args) {
+        |         return args;
+        |     }
+        |}
+      """.stripMargin.trim
+  }
+
+  override val testInitCannotBeCalledMoreThanOnce = {
+    TestConfig(echo(), "example.HelloWhisk")
+  }
+
+  override val testEntryPointOtherThanMain = {
+    TestConfig(echo("naim"), "example.HelloWhisk#naim")
+  }
+
+  override val testLargeInput = {
+    TestConfig(echo(), "example.HelloWhisk")
+  }
+
+}
diff --git a/tools/invoke.py b/tools/invoke.py
new file mode 100755
index 0000000..68b2ca8
--- /dev/null
+++ b/tools/invoke.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python
+"""Executable Python script for testing the action proxy.
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+  This script is useful for testing the action proxy (or its derivatives)
+  by simulating invoker interactions. Use it in combination with
+  docker run <image> which starts up the action proxy.
+  Example:
+     docker run -i -t -p 8080:8080 dockerskeleton # locally built images may be referenced without a tag
+     ./invoke.py init <action source file>
+     ./invoke.py run '{"some":"json object as a string"}'
+
+  For additional help, try ./invoke.py -h
+"""
+
+import os
+import re
+import sys
+import json
+import base64
+import requests
+import codecs
+import argparse
+try:
+    import argcomplete
+except ImportError:
+    argcomplete = False
+
+def main():
+    try:
+        args = parseArgs()
+        exitCode = {
+            'init' : init,
+            'run'   : run
+        }[args.cmd](args)
+    except Exception as e:
+        print(e)
+        exitCode = 1
+    sys.exit(exitCode)
+
+def dockerHost():
+    dockerHost = 'localhost'
+    if 'DOCKER_HOST' in os.environ:
+        try:
+            dockerHost = re.compile('tcp://(.*):[\d]+').findall(os.environ['DOCKER_HOST'])[0]
+        except Exception:
+            print('cannot determine docker host from %s' % os.environ['DOCKER_HOST'])
+            sys.exit(-1)
+    return dockerHost
+
+def containerRoute(args, path):
+    return 'http://%s:%s/%s' % (args.host, args.port, path)
+
+def parseArgs():
+    parser = argparse.ArgumentParser(description='initialize and run an OpenWhisk action container')
+    parser.add_argument('-v', '--verbose', help='verbose output', action='store_true')
+    parser.add_argument('--host', help='action container host', default=dockerHost())
+    parser.add_argument('-p', '--port', help='action container port number', default=8080, type=int)
+
+    subparsers = parser.add_subparsers(title='available commands', dest='cmd')
+
+    initmenu = subparsers.add_parser('init', help='initialize container with src or zip/tgz file')
+    initmenu.add_argument('-b', '--binary', help='treat artifact as binary', action='store_true')
+    initmenu.add_argument('main', nargs='?', default='main', help='name of the "main" entry method for the action')
+    initmenu.add_argument('artifact', help='a source file or zip/tgz archive')
+
+    runmenu = subparsers.add_parser('run', help='send arguments to container to run action')
+    runmenu.add_argument('payload', nargs='?', help='the arguments to send to the action, either a reference to a file or an inline JSON object', default=None)
+
+    if argcomplete:
+        argcomplete.autocomplete(parser)
+    return parser.parse_args()
+
+def init(args):
+    main = args.main
+    artifact = args.artifact
+
+    if artifact and (args.binary or artifact.endswith('.zip') or artifact.endswith('tgz') or artifact.endswith('jar')):
+        with open(artifact, 'rb') as fp:
+            contents = fp.read()
+        contents = base64.b64encode(contents)
+        binary = True
+    elif artifact is not '':
+        with(codecs.open(artifact, 'r', 'utf-8')) as fp:
+            contents = fp.read()
+        binary = False
+    else:
+        contents = None
+        binary = False
+
+    r = requests.post(
+        containerRoute(args, 'init'),
+        json = {"value": {"code": contents,
+                          "binary": binary,
+                          "main": main}})
+    print(r.text)
+
+def run(args):
+    value = processPayload(args.payload)
+    if args.verbose:
+        print('Sending value: %s...' % json.dumps(value)[0:40])
+    r = requests.post(containerRoute(args, 'run'), json = {"value": value})
+    print(r.text)
+
+def processPayload(payload):
+    if payload and os.path.exists(payload):
+        with open(payload) as fp:
+            return json.load(fp)
+    try:
+        d = json.loads(payload if payload else '{}')
+        if isinstance(d, dict):
+            return d
+        else:
+            raise
+    except:
+        print('payload must be a JSON object.')
+        sys.exit(-1)
+
+if __name__ == '__main__':
+    main()