You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openwhisk.apache.org by ra...@apache.org on 2018/05/31 21:24:58 UTC

[incubator-openwhisk] branch master updated: Augment RuntimeManifest with stem cell configuration per kind. (#3716)

This is an automated email from the ASF dual-hosted git repository.

rabbah pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-openwhisk.git


The following commit(s) were added to refs/heads/master by this push:
     new 5f2dedb  Augment RuntimeManifest with stem cell configuration per kind. (#3716)
5f2dedb is described below

commit 5f2dedb185216f7586e6b91de30866bcb50415a7
Author: rodric rabbah <ro...@gmail.com>
AuthorDate: Thu May 31 17:24:40 2018 -0400

    Augment RuntimeManifest with stem cell configuration per kind. (#3716)
    
    Constructs prewarming configuration from runtime manifest and its stem cell declarations.
    As an example:
    
      "nodejs": [
        {
          "kind": "nodejs:6",
          "default": true,
          "image": {
            "name": "nodejs6action"
          },
          "deprecated": false,
          "stemCells": [{
            "count": 2,
            "memory": "256 MB"
          }]
        }
      ]
    
    The configuration is parsed so that the count must be positive and the memory size a valid instance of ByteSize.
    The invokers will create the stemcells for the runtimes as specified in the manifest.
    
    The stem cell details are not reported in the deployment manifest (i.e., curl https://apihost).
    
    Co-authored-by: Himavanth <co...@yahoo.com>
    Co-authored-by: Rodric Rabbah <ro...@gmail.com>
---
 ansible/files/runtimes.json                        |   6 +-
 .../scala/whisk/core/entity/ExecManifest.scala     |  65 ++++++---
 .../src/main/scala/whisk/core/entity/Size.scala    |  11 ++
 .../whisk/core/containerpool/ContainerPool.scala   |  56 ++++----
 .../scala/whisk/core/invoker/InvokerReactive.scala |  18 +--
 .../containerpool/test/ContainerPoolTests.scala    |   8 +-
 .../scala/whisk/core/entity/test/ExecHelpers.scala |  15 ++-
 .../whisk/core/entity/test/ExecManifestTests.scala | 145 ++++++++++++++++++++-
 8 files changed, 254 insertions(+), 70 deletions(-)

diff --git a/ansible/files/runtimes.json b/ansible/files/runtimes.json
index 1866b69..44cb30f 100644
--- a/ansible/files/runtimes.json
+++ b/ansible/files/runtimes.json
@@ -14,7 +14,11 @@
                 "image": {
                     "name": "nodejs6action"
                 },
-                "deprecated": false
+                "deprecated": false,
+                "stemCells": [{
+                    "count": 2,
+                    "memory": "256 MB"
+                }]
             },
             {
                 "kind": "nodejs:8",
diff --git a/common/scala/src/main/scala/whisk/core/entity/ExecManifest.scala b/common/scala/src/main/scala/whisk/core/entity/ExecManifest.scala
index 668ef60..7436d16 100644
--- a/common/scala/src/main/scala/whisk/core/entity/ExecManifest.scala
+++ b/common/scala/src/main/scala/whisk/core/entity/ExecManifest.scala
@@ -122,6 +122,7 @@ protected[core] object ExecManifest {
    * @param requireMain true iff main entry point is not optional
    * @param sentinelledLogs true iff the runtime generates stdout/stderr log sentinels after an activation
    * @param image optional image name, otherwise inferred via fixed mapping (remove colons and append 'action')
+   * @param stemCells optional list of stemCells to be initialized by invoker per kind
    */
   protected[core] case class RuntimeManifest(kind: String,
                                              image: ImageName,
@@ -129,17 +130,17 @@ protected[core] object ExecManifest {
                                              default: Option[Boolean] = None,
                                              attached: Option[Attached] = None,
                                              requireMain: Option[Boolean] = None,
-                                             sentinelledLogs: Option[Boolean] = None) {
-
-    protected[entity] def toJsonSummary = {
-      JsObject(
-        "kind" -> kind.toJson,
-        "image" -> image.publicImageName.toJson,
-        "deprecated" -> deprecated.getOrElse(false).toJson,
-        "default" -> default.getOrElse(false).toJson,
-        "attached" -> attached.isDefined.toJson,
-        "requireMain" -> requireMain.getOrElse(false).toJson)
-    }
+                                             sentinelledLogs: Option[Boolean] = None,
+                                             stemCells: Option[List[StemCell]] = None)
+
+  /**
+   * A stemcell configuration read from the manifest for a container image to be initialized by the container pool.
+   *
+   * @param count the number of stemcell containers to create
+   * @param memory the max memory this stemcell will allocate
+   */
+  protected[entity] case class StemCell(count: Int, memory: ByteSize) {
+    require(count > 0, "count must be positive")
   }
 
   /**
@@ -240,6 +241,14 @@ protected[core] object ExecManifest {
 
     val knownContainerRuntimes: Set[String] = runtimes.flatMap(_.versions.map(_.kind))
 
+    val manifests: Map[String, RuntimeManifest] = {
+      runtimes.flatMap {
+        _.versions.map { m =>
+          m.kind -> m
+        }
+      }.toMap
+    }
+
     def skipDockerPull(image: ImageName): Boolean = {
       blackboxImages.contains(image) ||
       image.prefix.flatMap(p => bypassPullForLocalImages.map(_ == p)).getOrElse(false)
@@ -248,7 +257,16 @@ protected[core] object ExecManifest {
     def toJson: JsObject = {
       runtimes
         .map { family =>
-          family.name -> family.versions.map(_.toJsonSummary)
+          family.name -> family.versions.map {
+            case rt =>
+              JsObject(
+                "kind" -> rt.kind.toJson,
+                "image" -> rt.image.publicImageName.toJson,
+                "deprecated" -> rt.deprecated.getOrElse(false).toJson,
+                "default" -> rt.default.getOrElse(false).toJson,
+                "attached" -> rt.attached.isDefined.toJson,
+                "requireMain" -> rt.requireMain.getOrElse(false).toJson)
+          }
         }
         .toMap
         .toJson
@@ -262,12 +280,17 @@ protected[core] object ExecManifest {
       }
     }
 
-    val manifests: Map[String, RuntimeManifest] = {
-      runtimes.flatMap {
-        _.versions.map { m =>
-          m.kind -> m
+    /**
+     * Collects all runtimes for which there is a stemcell configuration defined
+     *
+     * @return list of runtime manifests with stemcell configurations
+     */
+    def stemcells: Map[RuntimeManifest, List[StemCell]] = {
+      manifests
+        .flatMap {
+          case (_, m) => m.stemCells.map(m -> _)
         }
-      }.toMap
+        .filter(_._2.nonEmpty)
     }
 
     private val defaultRuntimes: Map[String, String] = {
@@ -286,5 +309,11 @@ protected[core] object ExecManifest {
   }
 
   protected[entity] implicit val imageNameSerdes = jsonFormat3(ImageName.apply)
-  protected[entity] implicit val runtimeManifestSerdes = jsonFormat7(RuntimeManifest)
+
+  protected[entity] implicit val stemCellSerdes = {
+    import whisk.core.entity.size.serdes
+    jsonFormat2(StemCell.apply)
+  }
+
+  protected[entity] implicit val runtimeManifestSerdes = jsonFormat8(RuntimeManifest)
 }
diff --git a/common/scala/src/main/scala/whisk/core/entity/Size.scala b/common/scala/src/main/scala/whisk/core/entity/Size.scala
index 61e27b4..2d5b7d9 100644
--- a/common/scala/src/main/scala/whisk/core/entity/Size.scala
+++ b/common/scala/src/main/scala/whisk/core/entity/Size.scala
@@ -21,6 +21,8 @@ import java.nio.charset.StandardCharsets
 
 import com.typesafe.config.ConfigValue
 import pureconfig._
+import spray.json._
+import whisk.core.entity.ByteSize.formatError
 
 object SizeUnits extends Enumeration {
 
@@ -124,6 +126,15 @@ object size {
   // Creation of an intermediary Config object is necessary here, since "getBytes" is only part of that interface.
   implicit val pureconfigReader =
     ConfigReader[ConfigValue].map(v => ByteSize(v.atKey("key").getBytes("key"), SizeUnits.BYTE))
+
+  protected[entity] implicit val serdes = new RootJsonFormat[ByteSize] {
+    def write(b: ByteSize) = JsString(b.toString)
+
+    def read(value: JsValue): ByteSize = value match {
+      case JsString(s) => ByteSize.fromString(s)
+      case _           => deserializationError(formatError)
+    }
+  }
 }
 
 trait SizeConversion {
diff --git a/core/invoker/src/main/scala/whisk/core/containerpool/ContainerPool.scala b/core/invoker/src/main/scala/whisk/core/containerpool/ContainerPool.scala
index 1835569..d26ebdc 100644
--- a/core/invoker/src/main/scala/whisk/core/containerpool/ContainerPool.scala
+++ b/core/invoker/src/main/scala/whisk/core/containerpool/ContainerPool.scala
@@ -18,15 +18,9 @@
 package whisk.core.containerpool
 
 import scala.collection.immutable
-
 import whisk.common.{AkkaLogging, LoggingMarkers, TransactionId}
-
 import akka.actor.{Actor, ActorRef, ActorRefFactory, Props}
-
-import whisk.core.entity.ByteSize
-import whisk.core.entity.CodeExec
-import whisk.core.entity.EntityName
-import whisk.core.entity.ExecutableWhiskAction
+import whisk.core.entity._
 import whisk.core.entity.size._
 import whisk.core.connector.MessageFeed
 
@@ -60,7 +54,7 @@ case class WorkerData(data: ContainerData, state: WorkerState)
  */
 class ContainerPool(childFactory: ActorRefFactory => ActorRef,
                     feed: ActorRef,
-                    prewarmConfig: Option[PrewarmingConfig] = None,
+                    prewarmConfig: List[PrewarmingConfig] = List.empty,
                     poolConfig: ContainerPoolConfig)
     extends Actor {
   implicit val logging = new AkkaLogging(context.system.log)
@@ -71,7 +65,8 @@ class ContainerPool(childFactory: ActorRefFactory => ActorRef,
   val logMessageInterval = 10.seconds
 
   prewarmConfig.foreach { config =>
-    logging.info(this, s"pre-warming ${config.count} ${config.exec.kind} containers")(TransactionId.invokerWarmup)
+    logging.info(this, s"pre-warming ${config.count} ${config.exec.kind} ${config.memoryLimit.toString}")(
+      TransactionId.invokerWarmup)
     (1 to config.count).foreach { _ =>
       prewarmContainer(config.exec, config.memoryLimit)
     }
@@ -204,26 +199,25 @@ class ContainerPool(childFactory: ActorRefFactory => ActorRef,
    * @param kind the kind you want to invoke
    * @return the container iff found
    */
-  def takePrewarmContainer(action: ExecutableWhiskAction): Option[(ActorRef, ContainerData)] =
-    prewarmConfig.flatMap { config =>
-      val kind = action.exec.kind
-      val memory = action.limits.memory.megabytes.MB
-      prewarmedPool
-        .find {
-          case (_, PreWarmedData(_, `kind`, `memory`)) => true
-          case _                                       => false
-        }
-        .map {
-          case (ref, data) =>
-            // Move the container to the usual pool
-            freePool = freePool + (ref -> data)
-            prewarmedPool = prewarmedPool - ref
-            // Create a new prewarm container
-            prewarmContainer(config.exec, config.memoryLimit)
-
-            (ref, data)
-        }
-    }
+  def takePrewarmContainer(action: ExecutableWhiskAction): Option[(ActorRef, ContainerData)] = {
+    val kind = action.exec.kind
+    val memory = action.limits.memory.megabytes.MB
+    prewarmedPool
+      .find {
+        case (_, PreWarmedData(_, `kind`, `memory`)) => true
+        case _                                       => false
+      }
+      .map {
+        case (ref, data) =>
+          // Move the container to the usual pool
+          freePool = freePool + (ref -> data)
+          prewarmedPool = prewarmedPool - ref
+          // Create a new prewarm container
+          // NOTE: prewarming ignores the action code in exec, but this is dangerous as the field is accessible to the factory
+          prewarmContainer(action.exec, memory)
+          (ref, data)
+      }
+  }
 
   /** Removes a container and updates state accordingly. */
   def removeContainer(toDelete: ActorRef) = {
@@ -282,9 +276,9 @@ object ContainerPool {
   def props(factory: ActorRefFactory => ActorRef,
             poolConfig: ContainerPoolConfig,
             feed: ActorRef,
-            prewarmConfig: Option[PrewarmingConfig] = None) =
+            prewarmConfig: List[PrewarmingConfig] = List.empty) =
     Props(new ContainerPool(factory, feed, prewarmConfig, poolConfig))
 }
 
-/** Contains settings needed to perform container prewarming */
+/** Contains settings needed to perform container prewarming. */
 case class PrewarmingConfig(count: Int, exec: CodeExec[_], memoryLimit: ByteSize)
diff --git a/core/invoker/src/main/scala/whisk/core/invoker/InvokerReactive.scala b/core/invoker/src/main/scala/whisk/core/invoker/InvokerReactive.scala
index b132dd8..20cbbd4 100644
--- a/core/invoker/src/main/scala/whisk/core/invoker/InvokerReactive.scala
+++ b/core/invoker/src/main/scala/whisk/core/invoker/InvokerReactive.scala
@@ -33,7 +33,6 @@ import whisk.core.containerpool._
 import whisk.core.containerpool.logging.LogStoreProvider
 import whisk.core.database._
 import whisk.core.entity._
-import whisk.core.entity.size._
 import whisk.http.Messages
 import whisk.spi.SpiLoader
 
@@ -173,14 +172,17 @@ class InvokerReactive(
       ContainerProxy
         .props(containerFactory.createContainer, ack, store, logsProvider.collectLogs, instance, poolConfig))
 
-  private val prewarmKind = "nodejs:6"
-  private val prewarmExec = ExecManifest.runtimesManifest
-    .resolveDefaultRuntime(prewarmKind)
-    .map(manifest => CodeExecAsString(manifest, "", None))
-    .get
+  val prewarmingConfigs: List[PrewarmingConfig] = {
+    ExecManifest.runtimesManifest.stemcells.flatMap {
+      case (mf, cells) =>
+        cells.map { cell =>
+          PrewarmingConfig(cell.count, new CodeExecAsString(mf, "", None), cell.memory)
+        }
+    }.toList
+  }
 
-  private val pool = actorSystem.actorOf(
-    ContainerPool.props(childFactory, poolConfig, activationFeed, Some(PrewarmingConfig(2, prewarmExec, 256.MB))))
+  private val pool =
+    actorSystem.actorOf(ContainerPool.props(childFactory, poolConfig, activationFeed, prewarmingConfigs))
 
   /** Is called when an ActivationMessage is read from Kafka */
   def processActivationMessage(bytes: Array[Byte]): Future[Unit] = {
diff --git a/tests/src/test/scala/whisk/core/containerpool/test/ContainerPoolTests.scala b/tests/src/test/scala/whisk/core/containerpool/test/ContainerPoolTests.scala
index b61e6f8..3fe1253 100644
--- a/tests/src/test/scala/whisk/core/containerpool/test/ContainerPoolTests.scala
+++ b/tests/src/test/scala/whisk/core/containerpool/test/ContainerPoolTests.scala
@@ -240,7 +240,7 @@ class ContainerPoolTests
 
     val pool =
       system.actorOf(
-        ContainerPool.props(factory, ContainerPoolConfig(0, 0), feed.ref, Some(PrewarmingConfig(1, exec, memoryLimit))))
+        ContainerPool.props(factory, ContainerPoolConfig(0, 0), feed.ref, List(PrewarmingConfig(1, exec, memoryLimit))))
     containers(0).expectMsg(Start(exec, memoryLimit))
   }
 
@@ -250,7 +250,7 @@ class ContainerPoolTests
 
     val pool =
       system.actorOf(
-        ContainerPool.props(factory, ContainerPoolConfig(1, 1), feed.ref, Some(PrewarmingConfig(1, exec, memoryLimit))))
+        ContainerPool.props(factory, ContainerPoolConfig(1, 1), feed.ref, List(PrewarmingConfig(1, exec, memoryLimit))))
     containers(0).expectMsg(Start(exec, memoryLimit))
     containers(0).send(pool, NeedWork(preWarmedData(exec.kind)))
     pool ! runMessage
@@ -265,7 +265,7 @@ class ContainerPoolTests
 
     val pool = system.actorOf(
       ContainerPool
-        .props(factory, ContainerPoolConfig(1, 1), feed.ref, Some(PrewarmingConfig(1, alternativeExec, memoryLimit))))
+        .props(factory, ContainerPoolConfig(1, 1), feed.ref, List(PrewarmingConfig(1, alternativeExec, memoryLimit))))
     containers(0).expectMsg(Start(alternativeExec, memoryLimit)) // container0 was prewarmed
     containers(0).send(pool, NeedWork(preWarmedData(alternativeExec.kind)))
     pool ! runMessage
@@ -281,7 +281,7 @@ class ContainerPoolTests
     val pool =
       system.actorOf(
         ContainerPool
-          .props(factory, ContainerPoolConfig(1, 1), feed.ref, Some(PrewarmingConfig(1, exec, alternativeLimit))))
+          .props(factory, ContainerPoolConfig(1, 1), feed.ref, List(PrewarmingConfig(1, exec, alternativeLimit))))
     containers(0).expectMsg(Start(exec, alternativeLimit)) // container0 was prewarmed
     containers(0).send(pool, NeedWork(preWarmedData(exec.kind, alternativeLimit)))
     pool ! runMessage
diff --git a/tests/src/test/scala/whisk/core/entity/test/ExecHelpers.scala b/tests/src/test/scala/whisk/core/entity/test/ExecHelpers.scala
index 688fcd5..7ff2810 100644
--- a/tests/src/test/scala/whisk/core/entity/test/ExecHelpers.scala
+++ b/tests/src/test/scala/whisk/core/entity/test/ExecHelpers.scala
@@ -26,6 +26,7 @@ import whisk.core.WhiskConfig
 import whisk.core.entity._
 import whisk.core.entity.ArgNormalizer.trim
 import whisk.core.entity.ExecManifest._
+import whisk.core.entity.size._
 
 import spray.json._
 import spray.json.DefaultJsonProtocol._
@@ -59,7 +60,12 @@ trait ExecHelpers extends Matchers with WskActorSystem with StreamLogging {
 
   protected def js6(code: String, main: Option[String] = None) = {
     CodeExecAsString(
-      RuntimeManifest(NODEJS6, imagename(NODEJS6), default = Some(true), deprecated = Some(false)),
+      RuntimeManifest(
+        NODEJS6,
+        imagename(NODEJS6),
+        default = Some(true),
+        deprecated = Some(false),
+        stemCells = Some(List(StemCell(2, 256.MB)))),
       trim(code),
       main.map(_.trim))
   }
@@ -70,7 +76,12 @@ trait ExecHelpers extends Matchers with WskActorSystem with StreamLogging {
 
   protected def js6MetaData(main: Option[String] = None, binary: Boolean) = {
     CodeExecMetaDataAsString(
-      RuntimeManifest(NODEJS6, imagename(NODEJS6), default = Some(true), deprecated = Some(false)),
+      RuntimeManifest(
+        NODEJS6,
+        imagename(NODEJS6),
+        default = Some(true),
+        deprecated = Some(false),
+        stemCells = Some(List(StemCell(2, 256.MB)))),
       binary,
       main.map(_.trim))
   }
diff --git a/tests/src/test/scala/whisk/core/entity/test/ExecManifestTests.scala b/tests/src/test/scala/whisk/core/entity/test/ExecManifestTests.scala
index 71f0237..690e449 100644
--- a/tests/src/test/scala/whisk/core/entity/test/ExecManifestTests.scala
+++ b/tests/src/test/scala/whisk/core/entity/test/ExecManifestTests.scala
@@ -19,12 +19,14 @@ package whisk.core.entity.test
 
 import common.{StreamLogging, WskActorSystem}
 import org.junit.runner.RunWith
-import org.scalatest.{FlatSpec, Matchers}
 import org.scalatest.junit.JUnitRunner
-import spray.json.DefaultJsonProtocol._
+import org.scalatest.{FlatSpec, Matchers}
 import spray.json._
+import spray.json.DefaultJsonProtocol._
 import whisk.core.entity.ExecManifest
 import whisk.core.entity.ExecManifest._
+import whisk.core.entity.size._
+import whisk.core.entity.ByteSize
 
 import scala.util.Success
 
@@ -63,10 +65,11 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     val k1 = RuntimeManifest("k1", ImageName("???"))
     val k2 = RuntimeManifest("k2", ImageName("???"), default = Some(true))
     val p1 = RuntimeManifest("p1", ImageName("???"))
-    val mf = manifestFactory(JsObject("ks" -> Set(k1, k2).toJson, "p1" -> Set(p1).toJson))
+    val s1 = RuntimeManifest("s1", ImageName("???"), stemCells = Some(List(StemCell(2, 256.MB))))
+    val mf = manifestFactory(JsObject("ks" -> Set(k1, k2).toJson, "p1" -> Set(p1).toJson, "s1" -> Set(s1).toJson))
     val runtimes = ExecManifest.runtimes(mf, RuntimeManifestConfig()).get
 
-    Seq("k1", "k2", "p1").foreach {
+    Seq("k1", "k2", "p1", "s1").foreach {
       runtimes.knownContainerRuntimes.contains(_) shouldBe true
     }
 
@@ -75,9 +78,11 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     runtimes.resolveDefaultRuntime("k1") shouldBe Some(k1)
     runtimes.resolveDefaultRuntime("k2") shouldBe Some(k2)
     runtimes.resolveDefaultRuntime("p1") shouldBe Some(p1)
+    runtimes.resolveDefaultRuntime("s1") shouldBe Some(s1)
 
     runtimes.resolveDefaultRuntime("ks:default") shouldBe Some(k2)
     runtimes.resolveDefaultRuntime("p1:default") shouldBe Some(p1)
+    runtimes.resolveDefaultRuntime("s1:default") shouldBe Some(s1)
   }
 
   it should "read a valid configuration without default prefix, default tag" in {
@@ -85,9 +90,15 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     val i2 = RuntimeManifest("i2", ImageName("???", Some("ppp")), default = Some(true))
     val j1 = RuntimeManifest("j1", ImageName("???", Some("ppp"), Some("ttt")))
     val k1 = RuntimeManifest("k1", ImageName("???", None, Some("ttt")))
+    val s1 = RuntimeManifest("s1", ImageName("???"), stemCells = Some(List(StemCell(2, 256.MB))))
 
     val mf =
-      JsObject("runtimes" -> JsObject("is" -> Set(i1, i2).toJson, "js" -> Set(j1).toJson, "ks" -> Set(k1).toJson))
+      JsObject(
+        "runtimes" -> JsObject(
+          "is" -> Set(i1, i2).toJson,
+          "js" -> Set(j1).toJson,
+          "ks" -> Set(k1).toJson,
+          "ss" -> Set(s1).toJson))
     val rmc = RuntimeManifestConfig(defaultImagePrefix = Some("pre"), defaultImageTag = Some("test"))
     val runtimes = ExecManifest.runtimes(mf, rmc).get
 
@@ -95,6 +106,9 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     runtimes.resolveDefaultRuntime("i2").get.image.publicImageName shouldBe "ppp/???:test"
     runtimes.resolveDefaultRuntime("j1").get.image.publicImageName shouldBe "ppp/???:ttt"
     runtimes.resolveDefaultRuntime("k1").get.image.publicImageName shouldBe "pre/???:ttt"
+    runtimes.resolveDefaultRuntime("s1").get.image.publicImageName shouldBe "pre/???:test"
+    runtimes.resolveDefaultRuntime("s1").get.stemCells.get(0).count shouldBe 2
+    runtimes.resolveDefaultRuntime("s1").get.stemCells.get(0).memory shouldBe 256.MB
   }
 
   it should "read a valid configuration with blackbox images but without default prefix or tag" in {
@@ -143,7 +157,7 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     an[IllegalArgumentException] should be thrownBy ExecManifest.runtimes(mf, RuntimeManifestConfig()).get
   }
 
-  it should "reject finding a default when none is specified for multiple versions" in {
+  it should "reject finding a default when none specified for multiple versions in the same family" in {
     val k1 = RuntimeManifest("k1", ImageName("???"))
     val k2 = RuntimeManifest("k2", ImageName("???"))
     val mf = manifestFactory(JsObject("ks" -> Set(k1, k2).toJson))
@@ -180,4 +194,123 @@ class ExecManifestTests extends FlatSpec with WskActorSystem with StreamLogging
     manifest.get.skipDockerPull(ImageName(prefix = Some("localpre"), name = "y")) shouldBe true
   }
 
+  it should "de/serialize stem cell configuration" in {
+    val cell = StemCell(3, 128.MB)
+    val cellAsJson = JsObject("count" -> JsNumber(3), "memory" -> JsString("128 MB"))
+    stemCellSerdes.write(cell) shouldBe cellAsJson
+    stemCellSerdes.read(cellAsJson) shouldBe cell
+
+    an[IllegalArgumentException] shouldBe thrownBy {
+      StemCell(-1, 128.MB)
+    }
+
+    an[IllegalArgumentException] shouldBe thrownBy {
+      StemCell(0, 128.MB)
+    }
+
+    an[IllegalArgumentException] shouldBe thrownBy {
+      val cellAsJson = JsObject("count" -> JsNumber(0), "memory" -> JsString("128 MB"))
+      stemCellSerdes.read(cellAsJson)
+    }
+
+    the[IllegalArgumentException] thrownBy {
+      val cellAsJson = JsObject("count" -> JsNumber(1), "memory" -> JsString("128"))
+      stemCellSerdes.read(cellAsJson)
+    } should have message {
+      ByteSize.formatError
+    }
+  }
+
+  it should "parse manifest from JSON string" in {
+    val json = """
+                 |{ "runtimes": {
+                 |    "nodef": [
+                 |      {
+                 |        "kind": "nodejs:6",
+                 |        "image": {
+                 |          "name": "nodejsaction"
+                 |        },
+                 |        "stemCells": [{
+                 |          "count": 1,
+                 |          "memory": "128 MB"
+                 |        }]
+                 |      }, {
+                 |        "kind": "nodejs:8",
+                 |        "default": true,
+                 |        "image": {
+                 |          "name": "nodejsaction"
+                 |        },
+                 |        "stemCells": [{
+                 |          "count": 1,
+                 |          "memory": "128 MB"
+                 |        }, {
+                 |          "count": 1,
+                 |          "memory": "256 MB"
+                 |        }]
+                 |      }
+                 |    ],
+                 |    "pythonf": [{
+                 |      "kind": "python",
+                 |      "image": {
+                 |        "name": "pythonaction"
+                 |      },
+                 |      "stemCells": [{
+                 |        "count": 2,
+                 |        "memory": "256 MB"
+                 |      }]
+                 |    }],
+                 |    "swiftf": [{
+                 |      "kind": "swift",
+                 |      "image": {
+                 |        "name": "swiftaction"
+                 |      },
+                 |      "stemCells": []
+                 |    }],
+                 |    "phpf": [{
+                 |      "kind": "php",
+                 |      "image": {
+                 |        "name": "phpaction"
+                 |      }
+                 |    }]
+                 |  }
+                 |}
+                 |""".stripMargin.parseJson.asJsObject
+
+    val js6 = RuntimeManifest("nodejs:6", ImageName("nodejsaction"), stemCells = Some(List(StemCell(1, 128.MB))))
+    val js8 = RuntimeManifest(
+      "nodejs:8",
+      ImageName("nodejsaction"),
+      default = Some(true),
+      stemCells = Some(List(StemCell(1, 128.MB), StemCell(1, 256.MB))))
+    val py = RuntimeManifest("python", ImageName("pythonaction"), stemCells = Some(List(StemCell(2, 256.MB))))
+    val sw = RuntimeManifest("swift", ImageName("swiftaction"), stemCells = Some(List.empty))
+    val ph = RuntimeManifest("php", ImageName("phpaction"))
+    val mf = ExecManifest.runtimes(json, RuntimeManifestConfig()).get
+
+    mf shouldBe {
+      Runtimes(
+        Set(
+          RuntimeFamily("nodef", Set(js6, js8)),
+          RuntimeFamily("pythonf", Set(py)),
+          RuntimeFamily("swiftf", Set(sw)),
+          RuntimeFamily("phpf", Set(ph))),
+        Set.empty,
+        None)
+    }
+
+    def stemCellFactory(m: RuntimeManifest, cells: List[StemCell]) = cells.map { c =>
+      (m.kind, m.image, c.count, c.memory)
+    }
+
+    mf.stemcells.flatMap {
+      case (m, cells) =>
+        cells.map { c =>
+          (m.kind, m.image, c.count, c.memory)
+        }
+    }.toList should contain theSameElementsAs List(
+      (js6.kind, js6.image, 1, 128.MB),
+      (js8.kind, js8.image, 1, 128.MB),
+      (js8.kind, js8.image, 1, 256.MB),
+      (py.kind, py.image, 2, 256.MB))
+  }
 }

-- 
To stop receiving notification emails like this one, please contact
rabbah@apache.org.