Skip to content
Snippets Groups Projects
ActionProxyContainerTests.scala 12.06 KiB
/*
 * 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 runtime.actionContainers

import java.io.File
import java.util.Base64

import org.apache.commons.io.FileUtils
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner

import ActionContainer.withContainer
import common.TestUtils
import common.WskActorSystem
import spray.json.DefaultJsonProtocol._
import spray.json._

@RunWith(classOf[JUnitRunner])
class ActionProxyContainerTests extends BasicActionRunnerTests with WskActorSystem {

  override def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit) = {
    withContainer("dockerskeleton", env)(code)
  }

  val codeNotReturningJson = """
           |#!/bin/sh
           |echo not a json object
        """.stripMargin.trim

  /** Standard code samples, should print 'hello' to stdout and echo the input args. */
  val stdCodeSamples = {
    val bash = """
                |#!/bin/bash
                |echo 'hello stdout'
                |echo 'hello stderr' 1>&2
                |if [[ -z $1 || $1 == '{}' ]]; then
                |   echo '{ "msg": "Hello from bash script!" }'
                |else
                |   echo $1 # echo the arguments back as the result
                |fi
            """.stripMargin.trim

    val python = """
                |#!/usr/bin/env python
                |from __future__ import print_function
                |import sys
                |print('hello stdout')
                |print('hello stderr', file=sys.stderr)
                |print(sys.argv[1])
            """.stripMargin.trim

    val perl = """
                |#!/usr/bin/env perl
                |print STDOUT "hello stdout\n";
                |print STDERR "hello stderr\n";
                |print $ARGV[0];
            """.stripMargin.trim

    // excluding perl as it not installed in alpine based image
    Seq(("bash", bash), ("python", python))
  }

  val stdUnicodeSamples = {
    // python 3 in base image
    val python = """
                |#!/usr/bin/env python
                |import json, sys
                |j = json.loads(sys.argv[1])
                |sep = j["delimiter"]
                |s = sep + " ☃ " + sep
                |print(s)
                |print(json.dumps({"winter": s}))
            """.stripMargin.trim

    Seq(("python", python))
  }

  /** Standard code samples, should print 'hello' to stdout and echo the input args. */
  val stdEnvSamples = {
    val bash = """
                |#!/bin/bash
                |echo "{ \
                |\"api_host\": \"$__OW_API_HOST\", \"api_key\": \"$__OW_API_KEY\", \
                |\"namespace\": \"$__OW_NAMESPACE\", \"action_name\": \"$__OW_ACTION_NAME\", \
                |\"activation_id\": \"$__OW_ACTIVATION_ID\", \"deadline\": \"$__OW_DEADLINE\" }"
            """.stripMargin.trim

    val python =
      """
                |#!/usr/bin/env python
                |import os
                |
                |print('{ "api_host": "%s", "api_key": "%s", "namespace": "%s", "action_name" : "%s", "activation_id": "%s", "deadline": "%s" }' % (
                |  os.environ['__OW_API_HOST'], os.environ['__OW_API_KEY'],
                |  os.environ['__OW_NAMESPACE'], os.environ['__OW_ACTION_NAME'],
                |  os.environ['__OW_ACTIVATION_ID'], os.environ['__OW_DEADLINE']))
            """.stripMargin.trim

    val perl =
      """
                |#!/usr/bin/env perl
                |$a = $ENV{'__OW_API_HOST'};
                |$b = $ENV{'__OW_API_KEY'};
                |$c = $ENV{'__OW_NAMESPACE'};
                |$d = $ENV{'__OW_ACTION_NAME'};
                |$e = $ENV{'__OW_ACTIVATION_ID'};
                |$f = $ENV{'__OW_DEADLINE'};
                |print "{ \"api_host\": \"$a\", \"api_key\": \"$b\", \"namespace\": \"$c\", \"action_name\": \"$d\", \"activation_id\": \"$e\", \"deadline\": \"$f\" }";
            """.stripMargin.trim

    // excluding perl as it not installed in alpine based image
    Seq(("bash", bash), ("python", python))
  }

  behavior of "openwhisk/dockerskeleton"

  it should "run sample without init" in {
    val (out, err) = withActionContainer() { c =>
      val (runCode, out) = c.run(JsObject())
      runCode should be(200)
      out should be(Some(JsObject("error" -> JsString("This is a stub action. Replace it with custom logic."))))
    }

    checkStreams(out, err, {
      case (o, _) => o should include("This is a stub action")
    })
  }

  it should "run sample with 'null' init" in {
    val (out, err) = withActionContainer() { c =>
      val (initCode, _) = c.init(initPayload(null))
      initCode should be(200)

      val (runCode, out) = c.run(JsObject())
      runCode should be(200)
      out should be(Some(JsObject("error" -> JsString("This is a stub action. Replace it with custom logic."))))
    }

    checkStreams(out, err, {
      case (o, _) => o should include("This is a stub action")
    })
  }

  it should "run sample with init that does nothing" in {
    val (out, err) = withActionContainer() { c =>
      val (initCode, _) = c.init(JsObject())
      initCode should be(200)
      val (runCode, out) = c.run(JsObject())
      runCode should be(200)
      out should be(Some(JsObject("error" -> JsString("This is a stub action. Replace it with custom logic."))))
    }

    checkStreams(out, err, {
      case (o, _) => o should include("This is a stub action")
    })
  }

  it should "respond with 404 for bad run argument" in {
    val (out, err) = withActionContainer() { c =>
      val (runCode, out) = c.run(runPayload(JsString("A")))
      runCode should be(404)
    }

    checkStreams(out, err, {
      case (o, e) =>
        o shouldBe empty
        e shouldBe empty
    })
  }

  it should "fail to run a bad script" in {
    val (out, err) = withActionContainer() { c =>
      val (initCode, _) = c.init(initPayload(""))
      initCode should be(200)
      val (runCode, out) = c.run(JsNull)
      runCode should be(502)
      out should be(Some(JsObject("error" -> JsString("The action did not return a dictionary."))))
    }

    checkStreams(out, err, {
      case (o, _) => o should include("error")
    })
  }

  it should "extract and run a compatible zip exec" in {
    val zip = FileUtils.readFileToByteArray(new File(TestUtils.getTestActionFilename("blackbox.zip")))
    val contents = Base64.getEncoder.encodeToString(zip)

    val (out, err) = withActionContainer() { c =>
      val (initCode, err) =
        c.init(JsObject("value" -> JsObject("code" -> JsString(contents), "binary" -> JsBoolean(true))))
      initCode should be(200)
      val (runCode, out) = c.run(JsObject())
      runCode should be(200)
      out.get should be(JsObject("msg" -> JsString("hello zip")))
    }

    checkStreams(out, err, {
      case (o, e) =>
        o shouldBe "This is an example zip used with the docker skeleton action."
        e shouldBe empty
    })
  }

  testNotReturningJson(codeNotReturningJson, checkResultInLogs = true)
  testEcho(stdCodeSamples)
  testUnicode(stdUnicodeSamples)
  testEnv(stdEnvSamples)
}

trait BasicActionRunnerTests extends ActionProxyContainerTestUtils {
  def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit): (String, String)

  /**
   * Runs tests for actions which do not return a dictionary and confirms expected error messages.
   * @param codeNotReturningJson code to execute, should not return a JSON object
   * @param checkResultInLogs should be true iff the result of the action is expected to appear in stdout or stderr
   */
  def testNotReturningJson(codeNotReturningJson: String, checkResultInLogs: Boolean = true) = {
    it should "run and report an error for script not returning a json object" in {
      val (out, err) = withActionContainer() { c =>
        val (initCode, _) = c.init(initPayload(codeNotReturningJson))
        initCode should be(200)
        val (runCode, out) = c.run(JsObject())
        runCode should be(502)
        out should be(Some(JsObject("error" -> JsString("The action did not return a dictionary."))))
      }

      checkStreams(out, err, {
        case (o, e) =>
          if (checkResultInLogs) {
            (o + e) should include("not a json object")
          } else {
            o shouldBe empty
            e shouldBe empty
          }
      })
    }
  }

  /**
   * Runs tests for code samples which are expected to echo the input arguments
   * and print hello [stdout, stderr].
   */
  def testEcho(stdCodeSamples: Seq[(String, String)]) = {
    stdCodeSamples.foreach { s =>
      it should s"run a ${s._1} script" in {
        val argss = List(
          JsObject("string" -> JsString("hello")),
          JsObject("string" -> JsString("❄ ☃ ❄")),
          JsObject("numbers" -> JsArray(JsNumber(42), JsNumber(1))),
          // JsObject("boolean" -> JsBoolean(true)), // fails with swift3 returning boolean: 1
          JsObject("object" -> JsObject("a" -> JsString("A"))))

        val (out, err) = withActionContainer() { c =>
          val (initCode, _) = c.init(initPayload(s._2))
          initCode should be(200)

          for (args <- argss) {
            val (runCode, out) = c.run(runPayload(args))
            runCode should be(200)
            out should be(Some(args))
          }
        }
        checkStreams(out, err, {
          case (o, e) =>
            o should include("hello stdout")
            e should include("hello stderr")
        }, argss.length)
      }
    }
  }

  def testUnicode(stdUnicodeSamples: Seq[(String, String)]) = {
    stdUnicodeSamples.foreach { s =>
      it should s"run a ${s._1} action and handle unicode in source, input params, logs, and result" in {
        val (out, err) = withActionContainer() { c =>
          val (initCode, _) = c.init(initPayload(s._2))
          initCode should be(200)

          val (runCode, runRes) = c.run(runPayload(JsObject("delimiter" -> JsString("❄"))))
          runRes.get.fields.get("winter") shouldBe Some(JsString("❄ ☃ ❄"))
        }

        checkStreams(out, err, {
          case (o, _) =>
            o.toLowerCase should include("❄ ☃ ❄")
        })
      }
    }
  }

  /** Runs tests for code samples which are expected to return the expected standard environment {auth, edge}. */
  def testEnv(stdEnvSamples: Seq[(String, String)],
              enforceEmptyOutputStream: Boolean = true,
              enforceEmptyErrorStream: Boolean = true) = {
    stdEnvSamples.foreach { s =>
      it should s"run a ${s._1} script and confirm expected environment variables" in {
        val props = Seq(
          "api_host" -> "xyz",
          "api_key" -> "abc",
          "namespace" -> "zzz",
          "action_name" -> "xxx",
          "activation_id" -> "iii",
          "deadline" -> "123")
        val env = props.map { case (k, v) => s"__OW_${k.toUpperCase()}" -> v }

        val (out, err) = withActionContainer(env.take(1).toMap) { c =>
          val (initCode, _) = c.init(initPayload(s._2))
          initCode should be(200)

          val (runCode, out) = c.run(runPayload(JsObject(), Some(props.toMap.toJson.asJsObject)))
          runCode should be(200)
          out shouldBe defined
          props.map {
            case (k, v) =>
              withClue(k) {
                out.get.fields(k) shouldBe JsString(v)
              }

          }
        }

        checkStreams(out, err, {
          case (o, e) =>
            if (enforceEmptyOutputStream) o shouldBe empty
            if (enforceEmptyErrorStream) e shouldBe empty
        })
      }
    }
  }
}