diff --git a/tests/src/actionContainers/ActionContainer.scala b/tests/src/actionContainers/ActionContainer.scala index abcc72c34479337073ef483128ea7b289d842f90..c5350a3d4c3f90759bec1362c7f0bf284e01109f 100644 --- a/tests/src/actionContainers/ActionContainer.scala +++ b/tests/src/actionContainers/ActionContainer.scala @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package actionContainers import java.io.ByteArrayOutputStream diff --git a/tests/src/actionContainers/JavaActionContainerTests.scala b/tests/src/actionContainers/JavaActionContainerTests.scala index 54b8da86cf6575a6cd4090d3fcf0ac97bb878466..77bfff7a1efe2f40eca1713195502e026c9c7287 100644 --- a/tests/src/actionContainers/JavaActionContainerTests.scala +++ b/tests/src/actionContainers/JavaActionContainerTests.scala @@ -23,9 +23,9 @@ import org.scalatest.junit.JUnitRunner import spray.json._ import ActionContainer.withContainer -import common.WskActorSystem +import ResourceHelpers.JarBuilder -import collection.JavaConverters._ +import common.WskActorSystem @RunWith(classOf[JUnitRunner]) class JavaActionContainerTests extends FlatSpec with Matchers with WskActorSystem { @@ -273,134 +273,3 @@ class JavaActionContainerTests extends FlatSpec with Matchers with WskActorSyste classLoaderTest("thread") } } - -/** - * A convenience object to compile and package Java sources into a JAR, and to - * encode that JAR as a base 64 string. The compilation options include the - * current classpath, which is why Google GSON is readily available (though not - * packaged in the JAR). - */ -object JarBuilder { - import java.net.URI - import java.net.URLClassLoader - import java.nio.file.Files - import java.nio.file.Path - import java.nio.file.Paths - import java.nio.file.SimpleFileVisitor - import java.nio.file.FileVisitResult - import java.nio.file.FileSystems - import java.nio.file.attribute.BasicFileAttributes - import java.nio.charset.StandardCharsets - import java.util.Base64 - - import javax.tools.ToolProvider - - def mkBase64Jar(sources: Seq[(Seq[String], String)]): String = { - // Note that this pipeline doesn't delete any of the temporary files. - val binDir = compile(sources) - val jarPath = makeJar(binDir) - val base64 = toBase64(jarPath) - base64 - } - - def mkBase64Jar(source: (Seq[String], String)): String = { - mkBase64Jar(Seq(source)) - } - - private def compile(sources: Seq[(Seq[String], String)]): Path = { - require(!sources.isEmpty) - - // A temporary directory for the source files. - val srcDir = Files.createTempDirectory("src").toAbsolutePath() - - // The absolute paths of the source file - val srcAbsPaths = for ((sourceName, sourceContent) <- sources) yield { - // The relative path of the source file - val srcRelPath = Paths.get(sourceName.head, sourceName.tail: _*) - // The absolute path of the source file - val srcAbsPath = srcDir.resolve(srcRelPath) - // Create parent directories if needed. - Files.createDirectories(srcAbsPath.getParent) - // Writing contents - Files.write(srcAbsPath, sourceContent.getBytes(StandardCharsets.UTF_8)) - - srcAbsPath - } - - // A temporary directory for the destination files. - val binDir = Files.createTempDirectory("bin").toAbsolutePath() - - // Preparing the compiler - val compiler = ToolProvider.getSystemJavaCompiler() - val fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8) - - // Collecting all files to be compiled - val compUnit = fileManager.getJavaFileObjectsFromFiles(srcAbsPaths.map(_.toFile).asJava) - - // Setting the options - val compOptions = Seq( - "-d", binDir.toAbsolutePath().toString(), - "-classpath", buildClassPath()) - val compTask = compiler.getTask(null, fileManager, null, compOptions.asJava, null, compUnit) - - // ...and off we go. - compTask.call() - - binDir - } - - private def buildClassPath(): String = { - val bcp = System.getProperty("java.class.path") - - val list = this.getClass().getClassLoader() match { - case ucl: URLClassLoader => - bcp :: ucl.getURLs().map(_.getFile().toString()).toList - - case _ => - List(bcp) - } - - list.mkString(System.getProperty("path.separator")) - } - - private def makeJar(binDir: Path): Path = { - // Any temporary file name for the jar. - val jarPath = Files.createTempFile("output", ".jar").toAbsolutePath() - val jarUri = new URI("jar:" + jarPath.toUri().getScheme(), jarPath.toAbsolutePath().toString(), null) - - // OK, that's a hack. Doing this because newFileSystem wants to create that file. - jarPath.toFile().delete() - - // We "mount" it as a zip filesystem, so we can just copy files to it. - val fs = FileSystems.newFileSystem(jarUri, Map(("create" -> "true")).asJava) - - // Traversing all files in the bin directory... - Files.walkFileTree(binDir, new SimpleFileVisitor[Path]() { - override def visitFile(path: Path, attributes: BasicFileAttributes) = { - // The path relative to the bin dir - val relPath = binDir.relativize(path) - // The corresponding path in the jar - val jarRelPath = fs.getPath(relPath.toString()) - - // Creating the directory structure if it doesn't exist. - if (!Files.exists(jarRelPath.getParent())) { - Files.createDirectories(jarRelPath.getParent()) - } - - // Finally we can copy that file. - Files.copy(path, jarRelPath) - - FileVisitResult.CONTINUE - } - }) - - fs.close() - - jarPath - } - - private def toBase64(path: Path): String = { - val encoder = Base64.getEncoder() - new String(encoder.encode(Files.readAllBytes(path)), StandardCharsets.UTF_8) - } -} diff --git a/tests/src/actionContainers/NodeJs6ActionContainerTests.scala b/tests/src/actionContainers/NodeJs6ActionContainerTests.scala index 212d18dcecfc67208f83243e0edc562770bee12d..7b7992f838d196c4bb76bf670e13d791585d0d2d 100644 --- a/tests/src/actionContainers/NodeJs6ActionContainerTests.scala +++ b/tests/src/actionContainers/NodeJs6ActionContainerTests.scala @@ -16,6 +16,8 @@ package actionContainers +import whisk.core.entity.NodeJS6Exec + import org.junit.runner.RunWith import org.scalatest.junit.JUnitRunner @@ -27,6 +29,8 @@ class NodeJs6ActionContainerTests extends NodeJsActionContainerTests { override lazy val nodejsContainerImageName = "nodejs6action" + override def exec(code: String) = NodeJS6Exec(code, None) + behavior of nodejsContainerImageName it should "support default function parameters" in { diff --git a/tests/src/actionContainers/NodeJsActionContainerTests.scala b/tests/src/actionContainers/NodeJsActionContainerTests.scala index 475b8e59e4ca07757bd9fb4e29135d1759945796..9844185d4042f712f072e5626aa7c807a0ca4ffd 100644 --- a/tests/src/actionContainers/NodeJsActionContainerTests.scala +++ b/tests/src/actionContainers/NodeJsActionContainerTests.scala @@ -18,7 +18,11 @@ package actionContainers import org.junit.runner.RunWith import org.scalatest.junit.JUnitRunner +import whisk.core.entity.{ NodeJSAbstractExec, NodeJSExec } + import ActionContainer.withContainer +import ResourceHelpers.ZipBuilder + import common.WskActorSystem import spray.json._ @@ -33,11 +37,17 @@ class NodeJsActionContainerTests extends BasicActionRunnerTests with WskActorSys def withNodeJsContainer(code: ActionContainer => Unit) = withActionContainer()(code) - override def initPayload(code: String) = JsObject( - "value" -> JsObject( - "name" -> JsString("dummyAction"), - "code" -> JsString(code), - "main" -> JsString("main"))) + def exec(code: String): NodeJSAbstractExec = NodeJSExec(code, None) + + override def initPayload(code: String) = { + val e = exec(code) + JsObject( + "value" -> JsObject( + "name" -> JsString("dummyAction"), + "code" -> JsString(e.code), + "binary" -> JsBoolean(e.binary), + "main" -> JsString("main"))) + } behavior of nodejsContainerImageName @@ -434,4 +444,79 @@ class NodeJsActionContainerTests extends BasicActionRunnerTests with WskActorSys e shouldBe empty }) } + + it should "support zip-encoded npm package actions" in { + val srcs = Seq( + Seq("package.json") -> """ + | { + | "name": "wskaction", + | "version": "1.0.0", + | "description": "An OpenWhisk action as an npm package.", + | "main": "index.js", + | "author": "info@openwhisk.org", + | "license": "Apache-2.0" + | } + """.stripMargin, + Seq("index.js") -> """ + | exports.main = function (args) { + | var name = typeof args["name"] === "string" ? args["name"] : "stranger"; + | + | return { + | greeting: "Hello " + name + ", from an npm package action." + | }; + | } + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + val (out, err) = withNodeJsContainer { c => + c.init(initPayload(code))._1 should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + + runCode should be(200) + runRes.get.fields.get("greeting") shouldBe Some(JsString("Hello stranger, from an npm package action.")) + } + + checkStreams(out, err, { + case (o, e) => + o shouldBe empty + e shouldBe empty + }) + } + + it should "fail gracefully on invalid zip files" in { + // Some text-file encoded to base64. + val code = "Q2VjaSBuJ2VzdCBwYXMgdW4gemlwLgo=" + + val (out, err) = withNodeJsContainer { c => + c.init(initPayload(code))._1 should not be (200) + } + + // Somewhere, the logs should mention the connection to the archive. + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("error") + (o + e).toLowerCase should include("uncompressing") + }) + } + + it should "fail gracefully on valid zip files that are not actions" in { + val srcs = Seq( + Seq("hello") -> """ + | Hello world! + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + val (out, err) = withNodeJsContainer { c => + c.init(initPayload(code))._1 should not be (200) + } + + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("error") + (o + e).toLowerCase should include("module_not_found") + }) + } } diff --git a/tests/src/actionContainers/ResourceHelpers.scala b/tests/src/actionContainers/ResourceHelpers.scala new file mode 100644 index 0000000000000000000000000000000000000000..b74bfbcaa14f40e3f69bc909db795eb8bd2e641b --- /dev/null +++ b/tests/src/actionContainers/ResourceHelpers.scala @@ -0,0 +1,187 @@ +/* + * Copyright 2015-2016 IBM Corporation + * + * Licensed 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 java.net.URI +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.FileVisitResult +import java.nio.file.FileSystems +import java.nio.file.attribute.BasicFileAttributes +import java.nio.charset.StandardCharsets +import java.util.Base64 + +import javax.tools.ToolProvider + +import collection.JavaConverters._ + +/** + * A collection of utility objects to create ephemeral action resources based + * on file contents. + */ +object ResourceHelpers { + /** Creates a zip file based on the contents of a top-level directory. */ + object ZipBuilder { + def mkBase64Zip(sources: Seq[(Seq[String], String)]): String = { + val (tmpDir, _) = writeSourcesToTempDirectory(sources) + val archive = makeZipFromDir(tmpDir) + readAsBase64(archive) + } + } + + /** + * A convenience object to compile and package Java sources into a JAR, and to + * encode that JAR as a base 64 string. The compilation options include the + * current classpath, which is why Google GSON is readily available (though not + * packaged in the JAR). + */ + object JarBuilder { + def mkBase64Jar(sources: Seq[(Seq[String], String)]): String = { + // Note that this pipeline doesn't delete any of the temporary files. + val binDir = compile(sources) + val jarPath = makeJarFromDir(binDir) + val base64 = readAsBase64(jarPath) + base64 + } + + def mkBase64Jar(source: (Seq[String], String)): String = { + mkBase64Jar(Seq(source)) + } + + private def compile(sources: Seq[(Seq[String], String)]): Path = { + require(!sources.isEmpty) + + // The absolute paths of the source file + val (srcDir, srcAbsPaths) = writeSourcesToTempDirectory(sources) + + // A temporary directory for the destination files. + val binDir = Files.createTempDirectory("bin").toAbsolutePath() + + // Preparing the compiler + val compiler = ToolProvider.getSystemJavaCompiler() + val fileManager = compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8) + + // Collecting all files to be compiled + val compUnit = fileManager.getJavaFileObjectsFromFiles(srcAbsPaths.map(_.toFile).asJava) + + // Setting the options + val compOptions = Seq( + "-d", binDir.toAbsolutePath().toString(), + "-classpath", buildClassPath()) + val compTask = compiler.getTask(null, fileManager, null, compOptions.asJava, null, compUnit) + + // ...and off we go. + compTask.call() + + binDir + } + + private def buildClassPath(): String = { + val bcp = System.getProperty("java.class.path") + + val list = this.getClass().getClassLoader() match { + case ucl: URLClassLoader => + bcp :: ucl.getURLs().map(_.getFile().toString()).toList + + case _ => + List(bcp) + } + + list.mkString(System.getProperty("path.separator")) + } + } + + /** + * Creates a temporary directory and reproduces the desired file structure + * in it. Returns the path of the temporary directory and the path of each + * file as represented in it. + */ + private def writeSourcesToTempDirectory(sources: Seq[(Seq[String], String)]): (Path, Seq[Path]) = { + // A temporary directory for the source files. + val srcDir = Files.createTempDirectory("src").toAbsolutePath() + + val srcAbsPaths = for ((sourceName, sourceContent) <- sources) yield { + // The relative path of the source file + val srcRelPath = Paths.get(sourceName.head, sourceName.tail: _*) + // The absolute path of the source file + val srcAbsPath = srcDir.resolve(srcRelPath) + // Create parent directories if needed. + Files.createDirectories(srcAbsPath.getParent) + // Writing contents + Files.write(srcAbsPath, sourceContent.getBytes(StandardCharsets.UTF_8)) + + srcAbsPath + } + + (srcDir, srcAbsPaths) + } + + private def makeZipFromDir(dir: Path): Path = makeArchiveFromDir(dir, ".zip") + + private def makeJarFromDir(dir: Path): Path = makeArchiveFromDir(dir, ".jar") + + /** + * Compresses all files beyond a directory into a zip file. + * Note that Jar files are just zip files. + */ + private def makeArchiveFromDir(dir: Path, extension: String): Path = { + // Any temporary file name for the archive. + val arPath = Files.createTempFile("output", extension).toAbsolutePath() + + // We "mount" it as a filesystem, so we can just copy files into it. + val dstUri = new URI("jar:" + arPath.toUri().getScheme(), arPath.toAbsolutePath().toString(), null) + // OK, that's a hack. Doing this because newFileSystem wants to create that file. + arPath.toFile().delete() + val fs = FileSystems.newFileSystem(dstUri, Map(("create" -> "true")).asJava) + + // Traversing all files in the bin directory... + Files.walkFileTree(dir, new SimpleFileVisitor[Path]() { + override def visitFile(path: Path, attributes: BasicFileAttributes) = { + // The path relative to the src dir + val relPath = dir.relativize(path) + + // The corresponding path in the zip + val arRelPath = fs.getPath(relPath.toString()) + + // If this file is not top-level in the src dir... + if (relPath.getParent() != null) { + // ...create the directory structure if it doesn't exist. + if (!Files.exists(arRelPath.getParent())) { + Files.createDirectories(arRelPath.getParent()) + } + } + + // Finally we can copy that file. + Files.copy(path, arRelPath) + + FileVisitResult.CONTINUE + } + }) + + fs.close() + + arPath + } + + /** Reads the contents of a (possibly binary) file into a base64-encoded String */ + private def readAsBase64(path: Path): String = { + val encoder = Base64.getEncoder() + new String(encoder.encode(Files.readAllBytes(path)), StandardCharsets.UTF_8) + } +}