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 9401f8b Add PHP 7.1 as a kind (#2415) 9401f8b is described below commit 9401f8b29ae2ddc470d9e543f0f790776ae0b028 Author: Rob Allen <r...@akrabat.com> AuthorDate: Mon Jul 24 17:38:34 2017 +0100 Add PHP 7.1 as a kind (#2415) * Implement PHP 7.1 kind * Add tests for PHP 7.1 action * Add PHP action documentation Build the Docker container from php:7.1-alpine and implement the HTTP server using PHP's built in server. Note that when using a zip file, the router requires that the `main` function is stored in `index.php`. Note about the runner: The runner sets the exit code to 1 if it has set the last line of stdout to a string suitable for presentation to the user. Therefore, if the exit code is not one, then display a generic message. If there's a runtime error in the action (i.e. not spotted by linter), then looking for the main() function will find it. Render the error to the logs so that the user knows what's happened. Note about vendor folder in a PHP zip: If the PHP vendor file has a vendor directory, then this directory needs to be used rather than the one supplied in the action container. To do this, we require src/vendor/autoload.php which will exist if the zip file contains it. For the two cases where (1) zip file does not contain a vendor folder, or (2) when running a non-binary code action, we move the container's vendor folder into src/. --- ansible/group_vars/all | 6 + core/php7.1Action/Dockerfile | 50 +++ core/php7.1Action/build.gradle | 2 + core/php7.1Action/composer.json | 11 + core/php7.1Action/router.php | 342 ++++++++++++++ core/php7.1Action/runner.php | 69 +++ docs/actions.md | 60 +++ docs/reference.md | 24 + settings.gradle | 1 + .../Php71ActionContainerTests.scala | 496 +++++++++++++++++++++ tools/build/redo | 5 + tools/cli/go-whisk-cli/commands/action.go | 2 + 12 files changed, 1068 insertions(+) diff --git a/ansible/group_vars/all b/ansible/group_vars/all index 7f2f269..4a8ff4e 100644 --- a/ansible/group_vars/all +++ b/ansible/group_vars/all @@ -83,6 +83,12 @@ runtimesManifest: attachmentType: "application/java-archive" sentinelledLogs: false requireMain: true + php: + - kind: "php:7.1" + default: true + deprecated: false + image: + name: "action-php-v7.1" blackboxes: - name: "dockerskeleton" diff --git a/core/php7.1Action/Dockerfile b/core/php7.1Action/Dockerfile new file mode 100644 index 0000000..c5b74ae --- /dev/null +++ b/core/php7.1Action/Dockerfile @@ -0,0 +1,50 @@ +FROM php:7.1-alpine + +RUN \ + + apk update && apk upgrade && \ + + # install dependencies + apk add \ + postgresql-dev \ + icu \ + icu-libs \ + icu-dev \ + freetype-dev \ + libjpeg-turbo-dev \ + libpng-dev \ + libxml2-dev \ + + && \ + + # install useful PHP extensions + docker-php-ext-install \ + opcache \ + mysqli \ + pdo_mysql \ + pdo_pgsql \ + intl \ + bcmath \ + zip \ + gd \ + soap \ + + && \ + + # install Composer + cd /tmp && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer + +# create src directory to store action files +RUN mkdir -p /action/src + +# install Composer dependencies +COPY composer.json /action +RUN cd /action && /usr/bin/composer install --no-plugins --no-scripts --prefer-dist --no-dev -o && rm composer.lock + +# copy required files +COPY router.php /action +COPY runner.php /action + +# Run webserver on port 8080 +EXPOSE 8080 +CMD [ "php", "-S", "0.0.0.0:8080", "-d", "expose_php=0", "-d", "html_errors=0", "-d", "error_reporting=E_ALL", "/action/router.php" ] diff --git a/core/php7.1Action/build.gradle b/core/php7.1Action/build.gradle new file mode 100644 index 0000000..1e4c161 --- /dev/null +++ b/core/php7.1Action/build.gradle @@ -0,0 +1,2 @@ +ext.dockerImageName = 'action-php-v7.1' +apply from: '../../gradle/docker.gradle' diff --git a/core/php7.1Action/composer.json b/core/php7.1Action/composer.json new file mode 100644 index 0000000..5aaa8fc --- /dev/null +++ b/core/php7.1Action/composer.json @@ -0,0 +1,11 @@ +{ + "config": { + "platform": { + "php": "7.1" + } + }, + "require": { + "guzzlehttp/guzzle": "^6.3", + "ramsey/uuid": "^3.6" + } +} diff --git a/core/php7.1Action/router.php b/core/php7.1Action/router.php new file mode 100644 index 0000000..63e3a75 --- /dev/null +++ b/core/php7.1Action/router.php @@ -0,0 +1,342 @@ +<?php +/* + * 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. + */ + +/** + * router.php + * + * This file is the API client for the action. The controller POSTs /init to set up the action and + * then POSTs to /run to invoke it. + */ + +// set up an output buffer to redirect any script output to stdout, rather than the default +// php://output, so that it goes to the logs, not the HTTP client. +ob_start(function ($data) { + file_put_contents("php://stdout", $data); + return ''; +}, 1, PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_FLUSHABLE | PHP_OUTPUT_HANDLER_REMOVABLE); + +const ACTION_SRC_FILENAME = 'index.php'; +const SRC_DIR = __DIR__ . '/src'; +const ACTION_CONFIG_FILE = __DIR__. '/config.json'; +const ACTION_SRC_FILE = SRC_DIR . '/' . ACTION_SRC_FILENAME; +const ACTION_RUNNER_FILE = __DIR__. '/runner.php'; +const TMP_ZIP_FILE = '/action.zip'; + +// execute the revelant endpoint +$result = route($_SERVER['REQUEST_URI']); + +// send response +$body = json_encode((object)$result); +header('Content-Type: application/json'); +header("Content-Length: " . mb_strlen($body)); +ob_end_clean(); +echo $body; +exit; + +/** + * executes the relevant method for a given URL and return an array of data to send to the client + */ +function route(string $uri) : array +{ + try { + switch ($uri) { + case '/init': + return init(); + + case '/run': + return run(); + + default: + throw new RuntimeException('Unexpected call to ' . $_SERVER["REQUEST_URI"], 500); + } + } catch (Throwable $e) { + $code = $e->getCode() < 400 ? 500 : $e->getCode(); + + if ($code != 502) { + writeTo("php://stdout", 'Error: ' . $e->getMessage()); + } + writeSentinels(); + + http_response_code($code); + return ['error' => $e->getMessage()]; + } + + return ''; +} + +/** + * Handle the /init endpoint + * + * This end point is called once per container creation. It gives us the code we need + * to run and the name of the function within that code that's the entry point. As PHP + * has a setup/teardown model, we store the function name to a config file for retrieval + * in the /run end point. + * + * @return array Data to return to the client + */ +function init() : array +{ + // data is POSTed to us as a JSON string + $post = file_get_contents('php://input'); + $data = json_decode($post, true)['value'] ?? []; + + $name = $data['name'] ?? ''; // action name + $main = $data['main'] ?? 'main'; // function to call (default: main) + $code = trim($data['code'] ?? ''); // source code to run + $binary = $data['binary'] ?? false; // code is binary? + + if (!$code) { + throw new RuntimeException("No code to execute"); + } + + if ($binary) { + // binary code is a zip file that's been base64 encoded, so unzip it + unzipString($code, SRC_DIR); + + // if the zip file didn't contain a vendor directory, move our vendor into the src folder + if (! file_exists(SRC_DIR . '/vendor/autoload.php')) { + exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor')); + } + + // check that we have the expected action source file + if (! file_exists(ACTION_SRC_FILE)) { + throw new RuntimeException('Zipped actions must contain ' . ACTION_SRC_FILENAME . ' at the root.', 500); + } + } else { + // non-binary code is a text string, so save to disk + file_put_contents(ACTION_SRC_FILE, $code); + + // move vendor folder into the src folder + exec('mv ' . escapeshellarg(__DIR__ . '/vendor') . ' ' . escapeshellarg(SRC_DIR . '/vendor')); + } + + // is action file valid PHP? run `php -l` to find out + list($returnCode, $stdout, $stderr) = runPHP(['-l', '-f', ACTION_SRC_FILE]); + if ($returnCode != 0) { + writeTo("php://stderr", $stderr); + writeTo("php://stdout", $stdout); + + $message = 'PHP syntax error in ' . ($binary ? ACTION_SRC_FILENAME : 'action.'); + throw new RuntimeException($message, 500); + } + + // does the action have the expected function name? + $testCode = 'require "' . ACTION_SRC_FILENAME . '"; exit((int)(! function_exists("' . $main .'")));'; + list($returnCode, $stdout, $stderr) = runPHP(['-r', $testCode]); + if ($returnCode != 0) { + writeTo("php://stderr", $stderr); + writeTo("php://stdout", $stdout); + throw new RuntimeException("The function $main is missing."); + } + + // write config file for use by /run + $config = [ + 'file' => ACTION_SRC_FILENAME, + 'function' => $main, + 'name' => $name, + ]; + file_put_contents(ACTION_CONFIG_FILE, json_encode($config)); + + return ["OK" => true]; +} + +/** + * Handle the /run endpoint + * + * This end point is called once per action invocation. We load the function name from + * the config file and then invoke it. Note that as PHP writes to php://output, we + * capture in an output buffer and write the buffer to stdout for the OpenWhisk logs. + * + * @return array Data to return to the client + */ +function run() : array +{ + if (! file_exists(ACTION_SRC_FILE)) { + error_log('NO ACTION FILE: ' . ACTION_SRC_FILE); + throw new RuntimeException('!Could not find action file: ' . ACTION_SRC_FILENAME, 500); + } + + // load config to pass to runner + if (! file_exists(ACTION_CONFIG_FILE)) { + error_log('NO CONFIG FILE: ' . ACTION_CONFIG_FILE); + throw new RuntimeException('Could not find config file', 500); + } + $config = file_get_contents(ACTION_CONFIG_FILE); + + // Extract the posted data + $post = json_decode(file_get_contents('php://input'), true); + if (!is_array($post)) { + $post = []; + } + + // assign environment variables from the posted data + $env = array_filter($_ENV, function ($k) { + // only pass OpenWhisk environment variables to the action + return stripos($k, '__OW_') === 0; + }, ARRAY_FILTER_USE_KEY); + $env['PHP_VERSION'] = $_ENV['PHP_VERSION']; + foreach (['api_key', 'namespace', 'action_name', 'activation_id', 'deadline'] as $param) { + if (array_key_exists($param, $post)) { + $env['__OW_' . strtoupper($param)] = $post[$param]; + } + } + + // extract the function arguments from the posted data's "value" field + $args = '{}'; + if (array_key_exists('value', $post) && is_array($post['value'])) { + $args = json_encode($post['value']); + } + $env['WHISK_INPUT'] = $args; + + // run the action + list($returnCode, $stdout, $stderr) = runPHP( + ['-d', 'error_reporting=E_ALL', '-f', ACTION_RUNNER_FILE, '--', $config], + $args, + $env + ); + + // separate the esponse to send back to the client: it's the last line of stdout + $pos = strrpos($stdout, PHP_EOL); + if ($pos == false) { + // just one line of output + $lastLine = $stdout; + $stdout = ''; + } else { + $pos++; + $lastLine = trim(substr($stdout, $pos)); + $stdout = trim(substr($stdout, 0, $pos)); + } + + // write out the action's stderr and stdout + writeTo("php://stderr", $stderr); + writeTo("php://stdout", $stdout); + + $output = json_decode($lastLine, true); + if ($returnCode != 0 || !is_array($output)) { + // an error occurred while running the action + // the return code will be 1 if the stdout is printable to the user + if ($returnCode != 1) { + // otherwise put out a generic message and send $lastLine to stdout + writeTo("php://stdout", $lastLine); + $lastLine = 'An error occurred running the action.'; + } + throw new RuntimeException($lastLine, 502); + } + + // write sentinels as action is completed + writeSentinels(); + + return $output; +} + +/** + * Unzip a base64 encoded string to a directory + */ +function unzipString(string $b64Data, $dir): void +{ + file_put_contents(TMP_ZIP_FILE, base64_decode($b64Data)); + + $zip = new ZipArchive(); + $res = $zip->open(TMP_ZIP_FILE); + if ($res !== true) { + $reasons = [ + ZipArchive::ER_EXISTS => "File already exists.", + ZipArchive::ER_INCONS => "Zip archive inconsistent.", + ZipArchive::ER_INVAL => "Invalid argument.", + ZipArchive::ER_MEMORY => "Malloc failure.", + ZipArchive::ER_NOENT => "No such file.", + ZipArchive::ER_NOZIP => "Not a zip archive.", + ZipArchive::ER_OPEN => "Can't open file.", + ZipArchive::ER_READ => "Read error.", + ZipArchive::ER_SEEK => "Seek error.", + ]; + $reason = $reasons[$res] ?? "Unknown error: $res."; + throw new RuntimeException("Failed to open zip file: $reason", 500); + } + + $res = $zip->extractTo($dir . '/'); + $zip->close(); +} + +/** + * Write the OpenWhisk sentinels to stdout and stderr so that it knows that we've finished + * writing data to them. + * + * @return void + */ +function writeSentinels() : void +{ + // write out sentinels as we've finished all log output + writeTo("php://stderr", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX"); + writeTo("php://stdout", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX"); +} + +/** + * Run the PHP command in a separate process + * + * This ensures that if the action causes a fatal error, we can handle it. + * + * @param array $args arguments to the PHP executable + * @param string $stdin stdin to pass to the process + * @param array $env environment variables to set for the process + * @return array array containing [int return code, string stdout string stderr] + */ +function runPHP(array $args, string $stdin = '', array $env = []) : array +{ + $cmd = '/usr/local/bin/php ' . implode(' ', array_map('escapeshellarg', $args)); + + $process = proc_open( + $cmd, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + SRC_DIR, + $env + ); + + // write to the process' stdin + $bytes = fwrite($pipes[0], $stdin); + fclose($pipes[0]); + + // read the process' stdout + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + // read the process' stderr + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + // close process & get return code + $returnCode = proc_close($process); + + // tidy up paths in any PHP stack traces + $stderr = str_replace(__DIR__ . '/', '', trim($stderr)); + $stdout = str_replace(__DIR__ . '/', '', trim($stdout)); + + return [$returnCode, $stdout, $stderr]; +} + +function writeTo($pipe, $text) +{ + if ($text) { + file_put_contents($pipe, $text . PHP_EOL); + } +} diff --git a/core/php7.1Action/runner.php b/core/php7.1Action/runner.php new file mode 100644 index 0000000..0e91747 --- /dev/null +++ b/core/php7.1Action/runner.php @@ -0,0 +1,69 @@ +<?php +/* + * 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. + */ + +/** + * runner.php + * + * This file runs the action code provided by the user. It is executed in the PHP CLI environment + * by router.php and will require the index.php file and call the main() function (or whatever has + * been configured). + * + * The configuration information is passed in as a JSON object as the first argument to this script + * and the OpenWhisk action argumentsare passed in as a JSON object via stdin. + */ + +// read config from argv[1] and assign +if ($argc != 2) { + file_put_contents("php://stderr", 'Expected a single config parameter'); + exit(1); +} + +$config = json_decode($argv[1], true); +if (!is_array($config)) { + file_put_contents("php://stderr", "Invalid config: {$argv[1]}."); + exit(1); +} + +$_actionFile = $config['file'] ?? 'index.php'; +$_functionName = $config['function'] ?? 'main'; +unset($argv[1], $config); + +// does the action file exist? +if (! file_exists($_actionFile)) { + file_put_contents("php://stderr", "Could not find action file: $_actionFile."); + exit(1); +} + +// run the action with arguments from stdin +require __DIR__ . '/src/vendor/autoload.php'; +require $_actionFile; + +$result = $_functionName(json_decode(file_get_contents('php://stdin') ?? [], true)); + +if (is_scalar($result)) { + file_put_contents("php://stderr", 'Result must be an array but has type "' + . gettype($result) . '": ' . (string)$result . "\n"); + file_put_contents("php://stdout", 'The action did not return a dictionary.'); + exit(1); +} elseif (is_object($result) && method_exists($result, 'getArrayCopy')) { + $result = $result->getArrayCopy(); +} + +// cast result to an object for json_encode to ensure that an empty array becomes "{}" +echo "\n"; +echo json_encode((object)$result); diff --git a/docs/actions.md b/docs/actions.md index cc8f1b7..7570b9e 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -12,6 +12,7 @@ Learn how to create, invoke, and debug actions in your preferred development env * [Swift](#creating-swift-actions) * [Python](#creating-python-actions) * [Java](#creating-java-actions) +* [PHP](#creating-php-actions) * [Docker](#creating-docker-actions) In addition, learn about: @@ -607,6 +608,65 @@ wsk action create helloPython --kind python:3 helloPython.zip While the steps above are shown for Python 3.6, you can do the same for Python 2.7 as well. + +## Creating PHP actions + +The process of creating PHP actions is similar to that of JavaScript actions. The following sections guide you through creating and invoking a single PHP action, and adding parameters to that action. + +### Creating and invoking a PHP action + +An action is simply a top-level PHP function. For example, create a file called `hello.php` with the following source code: + +```php +<?php +function main(array $args) : array +{ + $name = $args["name"] ?? "stranger"; + $greeting = "Hello $name!"; + echo $greeting; + return ["greeting" => $greeting]; +} +``` + +PHP actions always consume an associative array and return an associative array. The entry method for the action is `main` by default but may be specified explicitly when creating the action with the `wsk` CLI using `--main`, as with any other action type. + +You can create an OpenWhisk action called `helloPHP` from this function as follows: + +``` +wsk action create helloPHP hello.php +``` + +The CLI automatically infers the type of the action from the source file extension. For `.php` source files, the action runs using a PHP 7.1 runtime. See the PHP [reference](./reference.md#php-actions) for more information. + +Action invocation is the same for PHP actions as it is for JavaScript actions: + +``` +wsk action invoke --result helloPHP --param name World +``` + +```json + { + "greeting": "Hello World!" + } +``` + +### Packaging PHP actions in zip files + +You can package a PHP action along with other files and dependent packages in a zip file. +The filename of the source file containing the entry point (e.g., `main`) must be `index.php`. +For example, to create an action that includes a second file called `helper.php`, first create an archive containing your source files: + +```bash +zip -r helloPHP.zip index.php helper.php +``` + +and then create the action: + +```bash +wsk action create helloPHP --kind php:7.1 helloPHP.zip +``` + + ## Creating Swift actions The process of creating Swift actions is similar to that of JavaScript actions. The following sections guide you through creating and invoking a single swift action, and adding parameters to that action. diff --git a/docs/reference.md b/docs/reference.md index ee81cc7..2c37ab4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -373,6 +373,30 @@ Swift 3.1.1 actions can use the following packages: - SwiftyJSON version 15.0.1, https://github.com/IBM-Swift/SwiftyJSON - Watson Developer Cloud SDK version 0.16.0, https://github.com/watson-developer-cloud/swift-sdk +## PHP actions + +PHP actions are executed using PHP 7.1. To use this runtime, specify the `wsk` CLI parameter `--kind php:7.1` when creating or updating an action. This is the default when creating an action with file that has a `.php` extension. + +The following PHP extensions are available in addition to the standard ones: + +- bcmath +- curl +- gd +- intl +- mbstring +- mysqli +- pdo_mysql +- pdo_pgsql +- pdo_sqlite +- soap +- zip + +### Composer packages + +The following Composer packages are also available: + +- guzzlehttp/guzzle v6.3.0 +- ramsey/uuid v3.6.1 ## Docker actions diff --git a/settings.gradle b/settings.gradle index 868fcd7..375b6de 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ include 'core:python2Action' include 'core:swift3Action' include 'core:swift3.1.1Action' include 'core:javaAction' +include 'core:php7.1Action' include 'tools:cli' diff --git a/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala b/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala new file mode 100644 index 0000000..bfb53bf --- /dev/null +++ b/tests/src/test/scala/actionContainers/Php71ActionContainerTests.scala @@ -0,0 +1,496 @@ +/* + * 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 org.junit.runner.RunWith +import org.scalatest.junit.JUnitRunner + +import ActionContainer.withContainer +import ResourceHelpers.ZipBuilder + +import common.WskActorSystem +import spray.json._ + +@RunWith(classOf[JUnitRunner]) +class Php71ActionContainerTests extends BasicActionRunnerTests with WskActorSystem { + // note: "out" will not be empty as the PHP web server outputs a message when + // it starts up + val enforceEmptyOutputStream = false + + lazy val php71ContainerImageName = "action-php-v7.1" + + override def withActionContainer(env: Map[String, String] = Map.empty)(code: ActionContainer => Unit) = { + withContainer(php71ContainerImageName, env)(code) + } + + def withPhp71Container(code: ActionContainer => Unit) = withActionContainer()(code) + + behavior of php71ContainerImageName + + testEcho(Seq { + ("PHP", """ + |<?php + |function main(array $args) : array { + | echo 'hello stdout'; + | error_log('hello stderr'); + | return $args; + |} + """.stripMargin) + }) + + testNotReturningJson( + """ + |<?php + |function main(array $args) { + | return "not a json object"; + |} + """.stripMargin) + + testUnicode(Seq { + ("PHP", """ + |<?php + |function main(array $args) : array { + | $str = $args['delimiter'] . " ☃ " . $args['delimiter']; + | echo $str . "\n"; + | return ["winter" => $str]; + |} + """.stripMargin.trim) + }) + + testEnv(Seq { + ("PHP", """ + |<?php + |function main(array $args) : array { + | return [ + | "env" => $_ENV, + | "api_host" => $_ENV['__OW_API_HOST'], + | "api_key" => $_ENV['__OW_API_KEY'], + | "namespace" => $_ENV['__OW_NAMESPACE'], + | "action_name" => $_ENV['__OW_ACTION_NAME'], + | "activation_id" => $_ENV['__OW_ACTIVATION_ID'], + | "deadline" => $_ENV['__OW_DEADLINE'], + | ]; + |} + """.stripMargin.trim) + }, enforceEmptyOutputStream) + + it should "fail to initialize with bad code" in { + val (out, err) = withPhp71Container { c => + val code = """ + |<?php + | 10 PRINT "Hello world!" + | 20 GOTO 10 + """.stripMargin + + val (initCode, error) = c.init(initPayload(code)) + initCode should not be (200) + error shouldBe a[Some[_]] + error.get shouldBe a[JsObject] + error.get.fields("error").toString should include("PHP syntax error") + } + + // Somewhere, the logs should mention an error occurred. + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("error") + (o + e).toLowerCase should include("syntax") + }) + } + + it should "fail to initialize with no code" in { + val (out, err) = withPhp71Container { c => + val code = "" + + val (initCode, error) = c.init(initPayload(code)) + + initCode should not be (200) + error shouldBe a[Some[_]] + error.get shouldBe a[JsObject] + error.get.fields("error").toString should include("No code to execute") + } + } + + it should "return some error on action error" in { + val (out, err) = withPhp71Container { c => + val code = """ + |<?php + | function main(array $args) : array { + | throw new Exception ("nooooo"); + | } + """.stripMargin + + val (initCode, _) = c.init(initPayload(code)) + initCode should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runCode should not be (200) + + runRes shouldBe defined + runRes.get.fields.get("error") shouldBe defined + // runRes.get.fields("error").toString.toLowerCase should include("nooooo") + } + + // Somewhere, the logs should be the error text + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("nooooo") + }) + + } + + it should "support application errors" in { + withPhp71Container { c => + val code = """ + |<?php + | function main(array $args) : array { + | return [ "error" => "sorry" ]; + | } + """.stripMargin; + + val (initCode, error) = c.init(initPayload(code)) + initCode should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runCode should be(200) // action writer returning an error is OK + + runRes shouldBe defined + runRes.get.fields.get("error") shouldBe defined + runRes.get.fields("error").toString.toLowerCase should include("sorry") + } + } + + it should "fail gracefully when an action has a fatal error" in { + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | function main(array $args) : array { + | eval("class Error {};"); + | return [ "hello" => "world" ]; + | } + """.stripMargin; + + + val (initCode, _) = c.init(initPayload(code)) + initCode should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runCode should be(502) + + runRes shouldBe defined + runRes.get.fields.get("error") shouldBe defined + runRes.get.fields("error").toString should include("An error occurred running the action.") + } + + // Somewhere, the logs should be the error text + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("fatal error") + }) + } + + it should "suport returning a stdClass" in { + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | function main($params) { + | $obj = new stdClass(); + | $obj->hello = 'world'; + | return $obj; + | } + """.stripMargin + + + val (initCode, _) = c.init(initPayload(code)) + initCode should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runCode should be(200) // action writer returning an error is OK + + runRes shouldBe defined + runRes.get.fields.get("hello") shouldBe defined + runRes.get.fields("hello").toString.toLowerCase should include("world") + } + } + + it should "support returning an object with a getArrayCopy() method" in { + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | function main($params) { + | $obj = new ArrayObject(); + | $obj['hello'] = 'world'; + | return $obj; + | } + """.stripMargin + + + val (initCode, _) = c.init(initPayload(code)) + initCode should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runCode should be(200) // action writer returning an error is OK + + runRes shouldBe defined + runRes.get.fields.get("hello") shouldBe defined + runRes.get.fields.get("hello") shouldBe Some(JsString("world")) + } + } + + it should "support the documentation examples (1)" in { + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | function main($params) { + | if ($params['payload'] == 0) { + | return; + | } else if ($params['payload'] == 1) { + | return ['payload' => 'Hello, World!'] ; // indicates normal completion + | } else if ($params['payload'] == 2) { + | return ['error' => 'payload must be 0 or 1']; // indicates abnormal completion + | } + | } + """.stripMargin + + c.init(initPayload(code))._1 should be(200) + + val (c1, r1) = c.run(runPayload(JsObject("payload" -> JsNumber(0)))) + val (c2, r2) = c.run(runPayload(JsObject("payload" -> JsNumber(1)))) + val (c3, r3) = c.run(runPayload(JsObject("payload" -> JsNumber(2)))) + + c1 should be(200) + r1 should be(Some(JsObject())) + + c2 should be(200) + r2 should be(Some(JsObject("payload" -> JsString("Hello, World!")))) + + c3 should be(200) // application error, not container or system + r3.get.fields.get("error") shouldBe Some(JsString("payload must be 0 or 1")) + } + } + + it should "have Guzzle and Uuid packages available" in { + // GIVEN that it should "error when requiring a non-existent package" (see test above for this) + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | use Ramsey\Uuid\Uuid; + | use GuzzleHttp\Client; + | function main(array $args) { + | Uuid::uuid4(); + | new Client(); + | } + """.stripMargin + + val (initCode, _) = c.init(initPayload(code)) + + initCode should be(200) + + // WHEN I run an action that calls a Guzzle & a Uuid method + val (runCode, out) = c.run(runPayload(JsObject())) + + // THEN it should pass only when these packages are available + runCode should be(200) + } + } + + it should "support large-ish actions" in { + val thought = " I took the one less traveled by, and that has made all the difference." + val assignment = " $x = \"" + thought + "\";\n" + + val code = """ + | <?php + | function main(array $args) { + | $x = "hello"; + """.stripMargin + (assignment * 7000) + """ + | $x = "world"; + | return [ "message" => $x ]; + | } + """.stripMargin + + // Lest someone should make it too easy. + code.length should be >= 500000 + + val (out, err) = withPhp71Container { c => + c.init(initPayload(code))._1 should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + + runCode should be(200) + runRes.get.fields.get("message") shouldBe defined + runRes.get.fields.get("message") shouldBe Some(JsString("world")) + } + } + + val exampleOutputDotPhp: String = """ + | <?php + | function output($data) { + | return ['result' => $data]; + | } + """.stripMargin + + it should "support zip-encoded packages" in { + val srcs = Seq( + Seq("output.php") -> exampleOutputDotPhp, + Seq("index.php") -> """ + | <?php + | require __DIR__ . '/output.php'; + | function main(array $args) { + | $name = $args['name'] ?? 'stranger'; + | return output($name); + | } + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + val (out, err) = withPhp71Container { c => + + c.init(initPayload(code))._1 should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + + runCode should be(200) + runRes.get.fields.get("result") shouldBe defined + runRes.get.fields.get("result") shouldBe Some(JsString("stranger")) + } + } + + it should "support replacing vendor in zip-encoded packages " in { + val srcs = Seq( + Seq("vendor/autoload.php") -> exampleOutputDotPhp, + Seq("index.php") -> """ + | <?php + | function main(array $args) { + | $name = $args['name'] ?? 'stranger'; + | return output($name); + | } + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + val (out, err) = withPhp71Container { c => + + c.init(initPayload(code))._1 should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + + runCode should be(200) + runRes.get.fields.get("result") shouldBe defined + runRes.get.fields.get("result") shouldBe Some(JsString("stranger")) + } + } + + it should "fail gracefully on invalid zip files" in { + // Some text-file encoded to base64. + val code = "Q2VjaSBuJ2VzdCBwYXMgdW4gemlwLgo=" + + val (out, err) = withPhp71Container { c => + + val (initCode, error) = c.init(initPayload(code)) + initCode should not be(200) + error shouldBe a[Some[_]] + error.get shouldBe a[JsObject] + error.get.fields("error").toString should include("Failed to open zip file") + } + + // Somewhere, the logs should mention the failure + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("error") + (o + e).toLowerCase should include("failed to open zip file") + }) + } + + 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) = withPhp71Container { 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("zipped actions must contain index.php at the root.") + }) + } + + it should "fail gracefully on valid zip files with invalid code in index.php" in { + val (out, err) = withPhp71Container { c => + val srcs = Seq( + Seq("index.php") -> """ + | <?php + | 10 PRINT "Hello world!" + | 20 GOTO 10 + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + val (initCode, error) = c.init(initPayload(code)) + initCode should not be (200) + error shouldBe a[Some[_]] + error.get shouldBe a[JsObject] + error.get.fields("error").toString should include("PHP syntax error in index.php") + } + + // Somewhere, the logs should mention an error occurred. + checkStreams(out, err, { + case (o, e) => + (o + e).toLowerCase should include("error") + (o + e).toLowerCase should include("syntax") + }) + } + + it should "support actions using non-default entry point" in { + val (out, err) = withPhp71Container { c => + val code = """ + | <?php + | function niam(array $args) { + | return [result => "it works"]; + | } + """.stripMargin + + c.init(initPayload(code, main = "niam"))._1 should be(200) + val (runCode, runRes) = c.run(runPayload(JsObject())) + runRes.get.fields.get("result") shouldBe Some(JsString("it works")) + } + } + + it should "support zipped actions using non-default entry point" in { + val srcs = Seq( + Seq("index.php") -> """ + | <?php + | function niam(array $args) { + | return [result => "it works"]; + | } + """.stripMargin) + + val code = ZipBuilder.mkBase64Zip(srcs) + + withPhp71Container { c => + c.init(initPayload(code, main = "niam"))._1 should be(200) + + val (runCode, runRes) = c.run(runPayload(JsObject())) + runRes.get.fields.get("result") shouldBe Some(JsString("it works")) + } + } +} diff --git a/tools/build/redo b/tools/build/redo index d0acc8f..9ffe233 100755 --- a/tools/build/redo +++ b/tools/build/redo @@ -302,6 +302,11 @@ Components = [ yaml = False, gradle = 'core:javaAction'), + makeComponent('action-php-v7.1', + 'build PHP v7.1 action container', + yaml = False, + gradle = 'core:php7.1Action'), + makeComponent('dockersdk', 'build docker action SDK (to deploy, use edge component)', yaml = False, diff --git a/tools/cli/go-whisk-cli/commands/action.go b/tools/cli/go-whisk-cli/commands/action.go index 97e361f..162e380 100644 --- a/tools/cli/go-whisk-cli/commands/action.go +++ b/tools/cli/go-whisk-cli/commands/action.go @@ -465,6 +465,8 @@ func getExec(args []string, params ActionFlags) (*whisk.Exec, error) { exec.Kind = "python:default" } else if ext == ".jar" { exec.Kind = "java:default" + } else if ext == ".php" { + exec.Kind = "php:default" } else { if ext == ".zip" { return nil, zipKindError() -- To stop receiving notification emails like this one, please contact ['"commits@openwhisk.apache.org" <commits@openwhisk.apache.org>'].