blob: 87456d16751556ec8ba51552262e29adff1eb1e4 [file] [log] [blame]
<?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.
*/
namespace OpenWhiskPhpRuntime;
use RuntimeException;
use Throwable;
use ZipArchive;
// 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);
// Register a shutdown function so that we can fail gracefully when a fatal error occurs
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error["type"], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR])) {
$result = ['error' => 'An error occurred running the action.'];
$body = json_encode((object)$result);
header('HTTP/1.0 502 Bad Gateway');
header('Content-Type: application/json');
header("Content-Length: " . mb_strlen($body));
// write out sentinels as we've finished all log output
file_put_contents("php://stdout", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX\n");
file_put_contents("php://stderr", "XXX_THE_END_OF_A_WHISK_ACTIVATION_XXX\n");
ob_end_clean();
echo $body;
exit;
}
});
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 TMP_ZIP_FILE = '/action.zip';
// execute the revelant endpoint
$result = route($_SERVER['REQUEST_URI']);
sendResponse($result);
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':
$result = run();
writeSentinels();
return $result;
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()];
}
}
/**
* Send the response back
*/
function sendResponse(array $result) : void
{
$body = json_encode((object)$result);
header('Content-Type: application/json');
header("Content-Length: " . mb_strlen($body));
ob_end_clean();
echo $body;
}
/**
* 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
{
// check that we haven't already been initialised
if (file_exists(ACTION_CONFIG_FILE)) {
writeTo("php://stdout", 'Error: Cannot initialize the action more than once.');
http_response_code(403);
return ['error' => 'Cannot initialize the action more than once.'];
}
// 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_FILE . '"; 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_FILE,
'function' => $main,
'name' => $name,
];
file_put_contents(ACTION_CONFIG_FILE, '<?php return ' . var_export($config, true) . ';');
// reset OPcache
opcache_reset();
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
{
$config = require ACTION_CONFIG_FILE;
$_actionFile = $config['file'];
$_functionName = $config['function'];
// 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
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 = $post['value'];
}
$_ENV['WHISK_INPUT'] = json_encode($args);
// run the action
require __DIR__ . '/src/vendor/autoload.php';
require $_actionFile;
$result = $_functionName($args);
if (is_array($result)) {
return $result;
} elseif (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.');
throw new RuntimeException('The action did not return a dictionary.', 502);
} elseif (is_object($result)) {
if (method_exists($result, 'getArrayCopy')) {
return $result->getArrayCopy();
} elseif ($result instanceof \stdClass) {
return (array)$result;
}
}
return [];
}
/**
* 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
* @return array array containing [int return code, string stdout string stderr]
*/
function runPHP(array $args, string $stdin = '') : 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
);
// 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);
}
}