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 2019/11/01 14:06:55 UTC

[openwhisk] 02/02: Install and launch the Playground UI at startup.

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/openwhisk.git

commit 7062549035eb45f376653ce2433fcbe70e6fd806
Author: Chetan Mehrotra <ch...@apache.org>
AuthorDate: Fri Oct 4 17:11:24 2019 +0530

    Install and launch the Playground UI at startup.
    
    - Launch playground by default. Use `--no-ui` to opt out
    - Disable auto bootstrap of user and action when using external ArtifactStores
    - Document the `--enable-bootstrap` flag when using external stores
    - Add playground UI screenshot
    - Disable playground for all tests by default
    - Prepull default images before the UI is launched
    - Do prepull for nightly image even if they are present
---
 core/standalone/README.md                          |  69 +++++++----
 .../resources/playground/ui/playgroundFunctions.js |   2 +-
 .../openwhisk/core/ExecManifestSupport.scala       |  30 +++++
 .../openwhisk/standalone/PlaygroundLauncher.scala  | 128 +++++++++++++++++++++
 .../standalone/StandaloneDockerSupport.scala       |  12 ++
 .../openwhisk/standalone/StandaloneOpenWhisk.scala |  76 +++++++++---
 .../org/apache/openwhisk/standalone/Wsk.scala      |  81 +++++++++++++
 docs/images/playground-ui.png                      | Bin 0 -> 62459 bytes
 .../standalone/StandaloneServerFixture.scala       |   4 +
 9 files changed, 365 insertions(+), 37 deletions(-)

diff --git a/core/standalone/README.md b/core/standalone/README.md
index 7ec4178..d7d7456 100644
--- a/core/standalone/README.md
+++ b/core/standalone/README.md
@@ -26,8 +26,12 @@ executed as a normal java application from command line.
 java -jar openwhisk-standalone.jar
 ```
 
-This should start the OpenWhisk server on port 3233 by default. Once the server is started then [configure the cli][1]
-and then try out the [samples][2].
+This should start the OpenWhisk server on port 3233 by default and launch a Playground UI at port 3232.
+
+![Playground UI](../../docs/images/playground-ui.png)
+
+The Playground UI can be used to try out simple actions. To make use of all OpenWhisk features [configure the cli][1] and
+then try out the [samples][2].
 
 This server by default uses a memory based store and does not depend on any other external service like Kafka and CouchDB.
 It only needs Docker and Java to for running.
@@ -75,24 +79,25 @@ $ java -jar openwhisk-standalone.jar -h
  \   \  /  \/    \___/| .__/ \___|_| |_|__/\__|_| |_|_|___/_|\_\
   \___\/ tm           |_|
 
-  -m, --manifest  <arg>            Manifest json defining the supported runtimes
-  -c, --config-file  <arg>         application.conf which overrides the default
-                                   standalone.conf
-      --api-gw                     Enable API Gateway support
-      --couchdb                    Enable CouchDB support
-      --user-events                Enable User Events along with Prometheus and
-                                   Grafana
-      --kafka                      Enable embedded Kafka support
-      --kafka-ui                   Enable Kafka UI
-
-      --all                        Enables all the optional services supported
-                                   by Standalone OpenWhisk like CouchDB, Kafka
-                                   etc
-      --api-gw-port  <arg>         API Gateway Port
-      --clean                      Clean any existing state like database
-  -d, --data-dir  <arg>            Directory used for storage
-      --dev-kcf                    Enables KubernetesContainerFactory for local
-                                   development
+  -m, --manifest  <arg>               Manifest JSON defining the supported
+                                      runtimes
+  -c, --config-file  <arg>            application.conf which overrides the
+                                      default standalone.conf
+      --api-gw                        Enable API Gateway support
+      --couchdb                       Enable CouchDB support
+      --user-events                   Enable User Events along with Prometheus
+                                      and Grafana
+      --kafka                         Enable embedded Kafka support
+      --kafka-ui                      Enable Kafka UI
+
+      --all                           Enables all the optional services
+                                      supported by Standalone OpenWhisk like
+                                      CouchDB, Kafka etc
+      --api-gw-port  <arg>            API Gateway Port
+      --clean                         Clean any existing state like database
+  -d, --data-dir  <arg>               Directory used for storage
+      --dev-kcf                       Enables KubernetesContainerFactory for
+                                      local development
       --dev-mode                      Developer mode speeds up the startup by
                                       disabling preflight checks and avoiding
                                       explicit pulls.
@@ -102,6 +107,13 @@ $ java -jar openwhisk-standalone.jar -h
                                       configuring Prometheus to connect to
                                       existing running service instance
       --disable-color-logging         Disables colored logging
+      --enable-bootstrap              Enable bootstrap of default users and
+                                      actions like those needed for Api Gateway
+                                      or Playground UI. By default bootstrap is
+                                      done by default when using Memory store or
+                                      default CouchDB support. When using other
+                                      stores enable this flag to get bootstrap
+                                      done
       --kafka-docker-port  <arg>      Kafka port for use by docker based
                                       services. If not specified then 9091 or
                                       some random free port (if 9091 is busy)
@@ -109,14 +121,21 @@ $ java -jar openwhisk-standalone.jar -h
       --kafka-port  <arg>             Kafka port. If not specified then 9092 or
                                       some random free port (if 9092 is busy)
                                       would be used
+      --no-ui                         Disable Playground UI
+      --ui-port  <arg>                Playground UI server port. If not specified
+                                      then 3232 or some random free port (if
+                                      org.apache.openwhisk.standalone.StandaloneOpenWhisk$@75a1cd57
+                                      is busy) would be used
   -p, --port  <arg>                   Server port
   -v, --verbose
       --zk-port  <arg>                Zookeeper port. If not specified then 2181
                                       or some random free port (if 2181 is busy)
                                       would be used
   -h, --help                          Show help message
+      --version                       Show version of this program
 
 OpenWhisk standalone server
+
 ```
 
 Sections below would illustrate some of the supported options
@@ -224,11 +243,15 @@ whisk {
 
 Then pass this config file via `-c` option.
 
-#### Using Api Gateway
+Note that Standalone OpenWhisk will not bootstrap users and actions (e.g., API Gateway and Playground UI)
+when using an external database unless explicitly requested with `--enable-bootstrap`. This is to ensure
+that default users and actions are not added to your external artifact store.
+
+#### Using API Gateway
 
-Api Gateway mode can be enabled via `--api-gw` flag. In this mode upon launch a separate container for [OpenWhisk Api gateway][3]
+API Gateway mode can be enabled via `--api-gw` flag. In this mode upon launch a separate container for [OpenWhisk API gateway][3]
 would be launched on port `3234` (can be changed with `--api-gw-port`). In this mode you can make use of the
-[api gateway][4] support.
+[API Gateway][4] support.
 
 #### Using Kafka
 
diff --git a/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js b/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js
index 35c33ab..10f789b 100644
--- a/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js
+++ b/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js
@@ -18,7 +18,7 @@
 $(document).ready(function(){
   // This is the location of the supporting API
   // The host value may get replaced in PlaygroundLauncher to a specific host
-  window.APIHOST=window.location ? window.location.origin : ''
+  window.APIHOST='http://localhost:3233'
 
   // To install in a different namespace, change this value
   window.PLAYGROUND='whisk.system'
diff --git a/core/standalone/src/main/scala/org/apache/openwhisk/core/ExecManifestSupport.scala b/core/standalone/src/main/scala/org/apache/openwhisk/core/ExecManifestSupport.scala
new file mode 100644
index 0000000..2a2fd9b
--- /dev/null
+++ b/core/standalone/src/main/scala/org/apache/openwhisk/core/ExecManifestSupport.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 org.apache.openwhisk.core
+
+import org.apache.openwhisk.core.entity.ExecManifest
+
+/**
+ * Helper utility class to enable access to core scoped ExecManifest and related classes
+ */
+object ExecManifestSupport {
+  def getDefaultImage(familyName: String): Option[String] = {
+    val runtimes = ExecManifest.runtimesManifest
+    runtimes.resolveDefaultRuntime(s"$familyName:default").map(_.image.resolveImageName())
+  }
+}
diff --git a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/PlaygroundLauncher.scala b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/PlaygroundLauncher.scala
new file mode 100644
index 0000000..b588c44
--- /dev/null
+++ b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/PlaygroundLauncher.scala
@@ -0,0 +1,128 @@
+/*
+ * 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 org.apache.openwhisk.standalone
+
+import java.nio.charset.StandardCharsets.UTF_8
+
+import akka.actor.ActorSystem
+import akka.http.scaladsl.model.{ContentType, HttpCharsets, HttpEntity, MediaTypes, StatusCodes}
+import akka.http.scaladsl.server.Route
+import akka.http.scaladsl.server.directives.FileAndResourceDirectives.ResourceFile
+import akka.stream.ActorMaterializer
+import akka.stream.scaladsl.{Sink, Source}
+import org.apache.commons.io.IOUtils
+import org.apache.commons.lang3.SystemUtils
+import org.apache.openwhisk.common.{Logging, TransactionId}
+import org.apache.openwhisk.core.ExecManifestSupport
+import org.apache.openwhisk.http.BasicHttpService
+import pureconfig.loadConfigOrThrow
+
+import scala.concurrent.duration._
+import scala.concurrent.{Await, ExecutionContext}
+import scala.util.{Failure, Success, Try}
+import scala.sys.process._
+
+class PlaygroundLauncher(host: String, controllerPort: Int, pgPort: Int, authKey: String, devMode: Boolean)(
+  implicit logging: Logging,
+  ec: ExecutionContext,
+  actorSystem: ActorSystem,
+  materializer: ActorMaterializer,
+  tid: TransactionId) {
+  private val interface = loadConfigOrThrow[String]("whisk.controller.interface")
+  private val jsFileName = "playgroundFunctions.js"
+  private val jsContentType = ContentType(MediaTypes.`application/javascript`, HttpCharsets.`UTF-8`)
+
+  private val uiPath = {
+    //Depending on fact the run is done from within IDE or from terminal the classpath prefix needs to be adapted
+    val res = getClass.getResource(s"/playground/ui/$jsFileName")
+    Try(ResourceFile(res)) match {
+      case Success(_) => "playground/ui"
+      case Failure(_) => "BOOT-INF/classes/playground/ui"
+    }
+  }
+
+  private val jsFileContent = {
+    val js = resourceToString(jsFileName, "ui")
+    val content = js.replace("window.APIHOST='http://localhost:3233'", s"window.APIHOST='http://$host:$controllerPort'")
+    content.getBytes(UTF_8)
+  }
+
+  private val pg = "playground"
+  private val pgUrl = s"http://${StandaloneDockerSupport.getLocalHostName()}:$pgPort/$pg"
+
+  private val wsk = new Wsk(host, controllerPort, authKey)
+
+  def run(): ServiceContainer = {
+    BasicHttpService.startHttpService(PlaygroundService.route, pgPort, None, interface)(actorSystem, materializer)
+    ServiceContainer(pgPort, pgUrl, "Playground")
+  }
+
+  def install(): Unit = {
+    val actions = List("delete", "fetch", "run", "userpackage")
+    val f = Source(actions)
+      .mapAsync(1) { name =>
+        val actionName = s"playground-$name"
+        val js = resourceToString(s"playground-$name.js", "actions")
+        val r = wsk.updatePgAction(actionName, js)
+        r.foreach(_ => logging.info(this, s"Installed action $actionName"))
+        r
+      }
+      .runWith(Sink.ignore)
+    Await.result(f, 5.minutes)
+    Try {
+      if (!devMode) {
+        prePullDefaultImages()
+      }
+      launchBrowser(pgUrl)
+      logging.info(this, s"Launched browser $pgUrl")
+    }.failed.foreach(t => logging.warn(this, "Failed to launch browser " + t))
+  }
+
+  private def launchBrowser(url: String): Unit = {
+    if (SystemUtils.IS_OS_MAC) {
+      s"open $url".!!
+    } else if (SystemUtils.IS_OS_WINDOWS) {
+      s"""start "$url" """.!!
+    } else if (SystemUtils.IS_OS_LINUX) {
+      s"xdg-open $url".!!
+    }
+  }
+
+  private def prePullDefaultImages(): Unit = {
+    ExecManifestSupport.getDefaultImage("nodejs").foreach { imageName =>
+      StandaloneDockerSupport.prePullImage(imageName)
+    }
+  }
+
+  object PlaygroundService extends BasicHttpService {
+    override def routes(implicit transid: TransactionId): Route =
+      path(PathEnd | Slash | pg) { redirect(s"/$pg/ui/index.html", StatusCodes.Found) } ~
+        pathPrefix(pg / "ui" / Segment) { fileName =>
+          get {
+            if (fileName == jsFileName) {
+              complete(HttpEntity(jsContentType, jsFileContent))
+            } else {
+              getFromResource(s"$uiPath/$fileName")
+            }
+          }
+        }
+  }
+
+  private def resourceToString(name: String, resType: String) =
+    IOUtils.resourceToString(s"/playground/$resType/$name", UTF_8)
+}
diff --git a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneDockerSupport.scala b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneDockerSupport.scala
index 7181efc..d8ca898 100644
--- a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneDockerSupport.scala
+++ b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneDockerSupport.scala
@@ -129,6 +129,18 @@ object StandaloneDockerSupport {
     else hostIpLinux
   }
 
+  def prePullImage(imageName: String)(implicit logging: Logging): Unit = {
+    //docker images openwhisk/action-nodejs-v10:nightly
+    //REPOSITORY                    TAG                 IMAGE ID            CREATED             SIZE
+    //openwhisk/action-nodejs-v10   nightly             dbb0f8e1a050        5 days ago          967MB
+    val imageResult = s"$dockerCmd images $imageName".!!
+    val imageExist = imageResult.linesIterator.toList.size > 1
+    if (!imageExist || imageName.contains(":nightly")) {
+      logging.info(this, s"Docker Pre pulling $imageName")
+      s"$dockerCmd pull $imageName".!!
+    }
+  }
+
   private lazy val hostIpLinux: String = {
     //Gets the hostIp for linux https://github.com/docker/for-linux/issues/264#issuecomment-387525409
     // Typical output would be like and we need line with default
diff --git a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneOpenWhisk.scala b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneOpenWhisk.scala
index d1b0c14..9108d7f 100644
--- a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneOpenWhisk.scala
+++ b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/StandaloneOpenWhisk.scala
@@ -44,6 +44,7 @@ import scala.util.{Failure, Success, Try}
 import KafkaLauncher._
 
 class Conf(arguments: Seq[String]) extends ScallopConf(Conf.expandAllMode(arguments)) {
+  import StandaloneOpenWhisk.preferredPgPort
   banner(StandaloneOpenWhisk.banner)
   footer("\nOpenWhisk standalone server")
   StandaloneOpenWhisk.gitInfo.foreach(g => version(s"Git Commit - ${g.commitId}"))
@@ -101,10 +102,23 @@ class Conf(arguments: Seq[String]) extends ScallopConf(Conf.expandAllMode(argume
 
   val devKcf = opt[Boolean](descr = "Enables KubernetesContainerFactory for local development")
 
+  val noUi = opt[Boolean](descr = "Disable Playground UI", noshort = true)
+  val uiPort = opt[Int](
+    descr = s"Playground UI server port. If not specified then $preferredPgPort or some random free port " +
+      s"(if $StandaloneOpenWhisk is busy) would be used",
+    noshort = true)
+
   val devUserEventsPort = opt[Int](
     descr = "Specify the port for the user-event service. This mode can be used for local " +
       "development of user-event service by configuring Prometheus to connect to existing running service instance")
 
+  val enableBootstrap = opt[Boolean](
+    descr =
+      "Enable bootstrap of default users and actions like those needed for Api Gateway or Playground UI. " +
+        "By default bootstrap is done by default when using Memory store or default CouchDB support. " +
+        "When using other stores enable this flag to get bootstrap done",
+    noshort = true)
+
   mainOptions = Seq(manifest, configFile, apiGw, couchdb, userEvents, kafka, kafkaUi)
 
   verify()
@@ -188,6 +202,10 @@ object StandaloneOpenWhisk extends SLF4JLogging {
 
   val wskPath = System.getProperty("whisk.standalone.wsk", "wsk")
 
+  val preferredPgPort = 3232
+
+  private val systemUser = "whisk.system"
+
   def main(args: Array[String]): Unit = {
     val conf = new Conf(args)
 
@@ -204,14 +222,12 @@ object StandaloneOpenWhisk extends SLF4JLogging {
     implicit val logger: Logging = createLogging(actorSystem, conf)
     implicit val ec: ExecutionContext = actorSystem.dispatcher
 
+    val owPort = conf.port()
     val (dataDir, workDir) = initializeDirs(conf)
     val (dockerClient, dockerSupport) = prepareDocker(conf)
 
     val defaultSvcs = Seq(
-      ServiceContainer(
-        conf.port(),
-        s"http://${StandaloneDockerSupport.getLocalHostName()}:${conf.port()}",
-        "Controller"))
+      ServiceContainer(owPort, s"http://${StandaloneDockerSupport.getLocalHostName()}:$owPort", "Controller"))
 
     val (apiGwApiPort, apiGwSvcs) = if (conf.apiGw()) {
       startApiGateway(conf, dockerClient, dockerSupport)
@@ -227,14 +243,20 @@ object StandaloneOpenWhisk extends SLF4JLogging {
         startUserEvents(conf.port(), kafkaDockerPort, conf.devUserEventsPort.toOption, workDir, dataDir, dockerClient)
       else Seq.empty
 
-    val svcs = Seq(defaultSvcs, apiGwSvcs, couchSvcs.toList, kafkaSvcs, userEventSvcs).flatten
+    val pgLauncher = if (conf.noUi()) None else Some(createPgLauncher(owPort, conf))
+    val pgSvc = pgLauncher.map(pg => Seq(pg.run())).getOrElse(Seq.empty)
+
+    val svcs = Seq(defaultSvcs, apiGwSvcs, couchSvcs.toList, kafkaSvcs, userEventSvcs, pgSvc).flatten
     new ServiceInfoLogger(conf, svcs, dataDir).run()
 
     startServer(conf)
     new ServerStartupCheck(conf.serverUrl, "OpenWhisk").waitForServerToStart()
 
-    if (conf.apiGw()) {
-      installRouteMgmt(conf, workDir, apiGwApiPort)
+    if (canInstallUserAndActions(conf)) {
+      if (conf.apiGw()) {
+        installRouteMgmt(conf, workDir, apiGwApiPort)
+      }
+      pgLauncher.foreach(_.install())
     }
   }
 
@@ -253,7 +275,9 @@ object StandaloneOpenWhisk extends SLF4JLogging {
 
   def startServer(
     conf: Conf)(implicit actorSystem: ActorSystem, materializer: ActorMaterializer, logging: Logging): Unit = {
-    bootstrapUsers()
+    if (canInstallUserAndActions(conf)) {
+      bootstrapUsers()
+    }
     startController()
   }
 
@@ -423,12 +447,9 @@ object StandaloneOpenWhisk extends SLF4JLogging {
   }
 
   private def installRouteMgmt(conf: Conf, workDir: File, apiGwApiPort: Int)(implicit logging: Logging): Unit = {
-    val user = "whisk.system"
     val apiGwHostv2 = s"http://${StandaloneDockerSupport.getLocalHostIp()}:$apiGwApiPort/v2"
-    val authKey = getUsers().getOrElse(
-      user,
-      throw new Exception(s"Did not found auth key for $user which is needed to install the api management package"))
-    val installer = InstallRouteMgmt(workDir, authKey, conf.serverUrl, "/" + user, Uri(apiGwHostv2), wskPath)
+    val authKey = systemAuthKey
+    val installer = InstallRouteMgmt(workDir, authKey, conf.serverUrl, "/" + systemUser, Uri(apiGwHostv2), wskPath)
     installer.run()
   }
 
@@ -536,4 +557,33 @@ object StandaloneOpenWhisk extends SLF4JLogging {
   private def configureDevMode(): Unit = {
     setSysProp("whisk.docker.standalone.container-factory.pull-standard-images", "false")
   }
+
+  private def createPgLauncher(
+    owPort: Int,
+    conf: Conf)(implicit logging: Logging, as: ActorSystem, ec: ExecutionContext, materializer: ActorMaterializer) = {
+    implicit val tid: TransactionId = TransactionId(systemPrefix + "playground")
+    val pgPort = getPort(conf.uiPort.toOption, preferredPgPort)
+    new PlaygroundLauncher(StandaloneDockerSupport.getLocalHostName(), owPort, pgPort, systemAuthKey, conf.devMode())
+  }
+
+  private def systemAuthKey: String = {
+    getUsers().getOrElse(systemUser, throw new Exception(s"Did not found auth key for $systemUser"))
+  }
+
+  private def canInstallUserAndActions(conf: Conf)(implicit logging: Logging, actorSystem: ActorSystem): Boolean = {
+    val config = actorSystem.settings.config
+    val artifactStore = config.getString("whisk.spi.ArtifactStoreProvider")
+    if (conf.couchdb() || artifactStore == "org.apache.openwhisk.core.database.memory.MemoryArtifactStoreProvider") {
+      true
+    } else if (conf.enableBootstrap()) {
+      logging.info(this, "Bootstrap is enabled for external ArtifactStore")
+      true
+    } else {
+      logging.info(
+        this,
+        s"Bootstrap is not enabled as connecting to external ArtifactStore. " +
+          s"Start with ${conf.enableBootstrap.name} to bootstrap default users and action")
+      false
+    }
+  }
 }
diff --git a/core/standalone/src/main/scala/org/apache/openwhisk/standalone/Wsk.scala b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/Wsk.scala
new file mode 100644
index 0000000..b663d29
--- /dev/null
+++ b/core/standalone/src/main/scala/org/apache/openwhisk/standalone/Wsk.scala
@@ -0,0 +1,81 @@
+/*
+ * 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 org.apache.openwhisk.standalone
+
+import akka.Done
+import akka.actor.ActorSystem
+import akka.http.scaladsl.model.Uri.Query
+import akka.http.scaladsl.model.headers.{Accept, Authorization, BasicHttpCredentials}
+import akka.http.scaladsl.model.{HttpHeader, HttpMethods, MediaTypes, Uri}
+import org.apache.openwhisk.core.database.PutException
+import org.apache.openwhisk.http.PoolingRestClient
+import spray.json._
+
+import scala.concurrent.{ExecutionContext, Future}
+
+class Wsk(host: String, port: Int, authKey: String)(implicit system: ActorSystem) extends DefaultJsonProtocol {
+  import PoolingRestClient._
+  private implicit val ec: ExecutionContext = system.dispatcher
+  private val client = new PoolingRestClient("http", host, port, 10)
+  private val baseHeaders: List[HttpHeader] = {
+    val Array(username, password) = authKey.split(':')
+    List(Authorization(BasicHttpCredentials(username, password)), Accept(MediaTypes.`application/json`))
+  }
+
+  def updatePgAction(name: String, content: String): Future[Done] = {
+    val js = actionJson(name, content)
+    val params = Map("overwrite" -> "true")
+    val uri = Uri(s"/api/v1/namespaces/_/actions/$name").withQuery(Query(params))
+    client.requestJson[JsObject](mkJsonRequest(HttpMethods.PUT, uri, js, baseHeaders)).map {
+      case Right(_)     => Done
+      case Left(status) => throw PutException(s"Error creating action $name " + status)
+    }
+  }
+
+  private def actionJson(name: String, code: String) = {
+    s"""{
+      |    "namespace": "_",
+      |    "name": "$name",
+      |    "exec": {
+      |        "kind": "nodejs:default",
+      |        "code": ${quote(code)}
+      |    },
+      |    "annotations": [{
+      |        "key": "provide-api-key",
+      |        "value": true
+      |    }, {
+      |        "key": "web-export",
+      |        "value": true
+      |    }, {
+      |        "key": "raw-http",
+      |        "value": false
+      |    }, {
+      |        "key": "final",
+      |        "value": true
+      |    }],
+      |    "parameters": [{
+      |        "key": "__ignore_certs",
+      |        "value": true
+      |    }]
+      |}""".stripMargin.parseJson.asJsObject
+  }
+
+  private def quote(code: String) = {
+    JsString(code).compactPrint
+  }
+}
diff --git a/docs/images/playground-ui.png b/docs/images/playground-ui.png
new file mode 100644
index 0000000..71a6ebf
Binary files /dev/null and b/docs/images/playground-ui.png differ
diff --git a/tests/src/test/scala/org/apache/openwhisk/standalone/StandaloneServerFixture.scala b/tests/src/test/scala/org/apache/openwhisk/standalone/StandaloneServerFixture.scala
index 71a842f..e8d97f0 100644
--- a/tests/src/test/scala/org/apache/openwhisk/standalone/StandaloneServerFixture.scala
+++ b/tests/src/test/scala/org/apache/openwhisk/standalone/StandaloneServerFixture.scala
@@ -58,6 +58,8 @@ trait StandaloneServerFixture extends TestSuite with BeforeAndAfterAll with Stre
 
   protected def dumpStartupLogs: Boolean = false
 
+  protected def disablePlayGround: Boolean = true
+
   protected val dataDirPath: String = FilenameUtils.concat(FileUtils.getTempDirectoryPath, "standalone")
 
   override def beforeAll(): Unit = {
@@ -70,6 +72,7 @@ trait StandaloneServerFixture extends TestSuite with BeforeAndAfterAll with Stre
         System.setProperty(WHISK_SERVER, serverUrl)
         super.beforeAll()
         println(s"Running standalone server from ${standaloneServerJar.getAbsolutePath}")
+        val pgArgs = if (disablePlayGround) Seq("--no-ui") else Seq.empty
         val args = Seq(
           Seq(
             "java",
@@ -81,6 +84,7 @@ trait StandaloneServerFixture extends TestSuite with BeforeAndAfterAll with Stre
             ++ Seq("-jar", standaloneServerJar.getAbsolutePath, "--disable-color-logging", "--data-dir", dataDirPath)
             ++ configFileOpts
             ++ manifestFileOpts
+            ++ pgArgs
             ++ extraArgs,
           Seq("-p", serverPort.toString)).flatten