diff --git a/.asf.yaml b/.asf.yaml new file mode 100644 index 0000000..513fb6d --- /dev/null +++ b/.asf.yaml @@ -0,0 +1,36 @@ +# +# 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. +# + +github: + description: "Apache OpenWhisk Composer provides a high-level programming model in JavaScript for composing serverless functions" + homepage: https://openwhisk.apache.org/ + labels: + - openwhisk + - apache + - serverless + - faas + - functions-as-a-service + - cloud + - serverless-architectures + - serverless-functions + - functions + - composer + - composition + - node + - node-js + - nodejs + - javascript diff --git a/.gitignore b/.gitignore index 3c3629e..ccccd20 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ node_modules +openwhisk + +package-lock.json +demo.json \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..924e13e --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,23 @@ +/* + * 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. + */ + +module.exports = { + singleQuote: true, + printWidth: 120, + semi: false, + trailingComma: 'none' +}; diff --git a/.travis.yml b/.travis.yml index c13ce0b..c415305 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,40 @@ +# +# 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. +# + language: node_js node_js: - - 6 + - 14 services: - docker env: global: - - IGNORE_CERTS=true + - __OW_IGNORE_CERTS=true - REDIS=redis://172.17.0.1:6379 + - IC_FN_CONFIG_FILE=./test/cf-plugin-config.json + - IC_CONFIG_FILE=./test/cf-config.json +before_install: + - ./travis/scancode.sh before_script: - ./travis/setup.sh +deploy: + provider: npm + email: reggeenr@de.ibm.com + api_key: + secure: lyftFmbsZ0S/kcAjf5nVR1W+a1IrtsSghQ7o9w6A7COTKNwBh1QNnF+yh8OL72Sc4/PgRiV4Z6Jg9ZFCt3bWd3tsRcXqTXXspFC/lHsj//0s6M+a0c9pFr/uhND0hWXrIkVr0VLXNPp/3LMORS7fzjKktx/yksivBcdGHj4SX4sz3wHI3uq6gfb9OQXWqcs7jBOgMClKFeqOLaDAFJzRf9DZFsV9z5Eq2MwCALaoXZBbbmNcq795fioHKnMLzMVlN16aWsuDyFxGn/LLMdlmgaDLiF0KITtLM1V9XNrEETurLD41TcNChlsBcsHA15Seg9bsiKU7PX7+s+kb55eB3Gg9wJYhXfND5OFh+DL3GT7tLPetXda8sHaCkoEKOEdy30HEqPYZLy7X2fYQj7Ety4sqhe6SRkPFYhuS6+r179B/0Xu+KF17y1VG7CWHwTEKotZOLF4TLOudJ9O7+rUI0xOlNXj7tAURjXY6OJ1DMAefpRcwM5xaC8/3OIimK7Ab3BZn4or1PwpzK5qQDnaI/350IAK9wDkLyyT1OFywq7xNyfgKhv5gIgjoYEBAdwNTjjMcrGaTyFxLSoo3SluXXMYy9H3FXfBwNJfbF8mfHf3K/U1IdjlJ4TxlEPoUvPSv1a5M2oswZwKeRM6Mqmal+P0tAJzOY2rsJxTtpwkeMpk= + on: + tags: true + repo: ibm-functions/composer diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3f8f7ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ + + +# Changelog + +## v0.12.0 +* Running sequential compositions no longer requires the action runtime to + contain the `redis` and `uuid` modules. +* The `deploy` command supports additional options: + * `--logsize` and `--memory` to set limits for the conductor action, + * `--basic` and `--bearer` to control the authentication method, + * `--apiversion` to specify the API version of the target OpenWhisk instance. +* The `deploy` method supports passing through `httpOptions`. +* A workaround for Webpack has been implemented (dependency analysis of the + conductor code). +* The `openwhisk-client-js` module has been updated to version `3.20.0`. +* The documentation has been improved. + +## v0.11.0 +* Annotate conductor actions with the `provide-api-key` annotation. +* Add `--kind` and `--timeout` flags to `deploy` command. +* Add `--file` and `-o` flags to `compose` command. +* Update documentation. + +## v0.10.0 + +* Add new [parallel](docs/COMBINATORS.md#parallel) and + [map](docs/COMBINATORS.md#map) combinators to run compositions in parallel + using a [Redis instance](README.md#parallel-compositions-with-redis) to store + intermediate results. +* Add [dynamic](docs/COMBINATORS.md#dynamic) combinator to invoke an action with + a name chosen at run time. +* Add [option](README.md#openwhisk-ssl-configuration) to bypass TLS certificate + validation failures (off by default). +* Add [API](docs/COMPOSITIONS.md#conductor-actions) to generate the conductor + action code from a composition. +* Add [control](docs/COMMANDS.md#debug-flag) over `needle` options and logging. + +## v0.9.0 + +* Initial release as an Apache Incubator project. diff --git a/CLA-CORPORATE.md b/CLA-CORPORATE.md deleted file mode 100644 index 952896b..0000000 --- a/CLA-CORPORATE.md +++ /dev/null @@ -1,56 +0,0 @@ -# International Business Machines, Inc. (IBM) -### Software Grant and Corporate Contributor License Agreement ("Agreement") - -https://github.com/ibm-functions/composer/ - -Thank you for your interest in IBM’s Composer project ("the Project"). - -In order to clarify the intellectual property license granted with Contributions from any person or entity, IBM must have a Contributor License Agreement (CLA) on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of IBM and its users; it does not change your rights to use your own Contributions for any other purpose. - -This version of the Agreement allows an entity (the "Corporation") to submit Contributions to the Project, to authorize Contributions submitted by its designated employees to the Project, and to grant copyright and patent licenses thereto. - -If you have not already done so, please complete and sign, then scan and email a pdf file of this Agreement to tardieu@us.ibm.com. If necessary, send an original signed agreement to: - - IBM Corporation, 1101 Kitchawan Rd Route 134 / PO Box 218 Yorktown Heights, NY 10598 Attn: Olivier Tardieu - -Please read this document carefully before signing and keep a copy for your records. - - Corporation name: - Corporation address: - Point of Contact: - E-Mail: - Telephone: - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the license granted herein to IBM and recipients of software distributed by IBM, You reserve all right, title, and interest in and to Your Contributions. - -1. Definitions. - "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with IBM. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - "Contribution" shall mean the code, documentation or other original works of authorship expressly identified in Schedule B, as well as any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to IBM for inclusion in, or documentation of, the Project managed by IBM (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to IBM or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, IBM for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to IBM and to recipients of software distributed by IBM a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to IBM and to recipients of software distributed by IBM a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) were submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. You represent that You are legally entitled to grant the above license. You represent further that each employee of the Corporation designated on Schedule A below (or in a subsequent written modification to that Schedule) is authorized to submit Contributions on behalf of the Corporation. - -5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). - -6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -7. Should You wish to submit work that is not Your original creation, You may submit it to IBM separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -8. It is your responsibility to notify IBM when any change is required to the list of designated employees authorized to submit Contributions on behalf of the Corporation, or to the Corporation's Point of Contact with IBM. - -Please sign and date: ______________________________________________________________ - - Title: - Corporation: - -Schedule A - -[Initial list of designated employees. NB: authorization is not tied to particular Contributions.] - -Schedule B - -[Identification of optional concurrent software grant. Would be left blank or omitted if there is no concurrent software grant.] - diff --git a/CLA-INDIVIDUAL.md b/CLA-INDIVIDUAL.md deleted file mode 100644 index 7846733..0000000 --- a/CLA-INDIVIDUAL.md +++ /dev/null @@ -1,44 +0,0 @@ -# International Business Machines, Inc. (IBM) -### Individual Contributor License Agreement ("Agreement") - -https://github.com/ibm-functions/composer/ - -Thank you for your interest in the Composer project ("the Project"). - -In order to clarify the intellectual property license granted with Contributions from any person or entity, IBM must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of IBM and its customers; it does not change your rights to use your own Contributions for any other purpose. - -If you have not already done so, please complete and sign, then scan and email a pdf file of this Agreement to tardieu@us.ibm.com. If necessary, send an original signed agreement to: - - IBM Corporation, 1101 Kitchawan Rd Route 134 / PO Box 218 Yorktown Heights, NY 10598 Attn: Olivier Tardieu - -Please read this document carefully before signing and keep a copy for your records. - - Full name: - GitHub Username: - Address: - Country: - E-Mail: - Telephone: - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the Project. Except for the license granted herein to IBM and recipients of software distributed by IBM, You reserve all right, title, and interest in and to Your Contributions. - -1. Definitions. - "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with IBM. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project (”the Work”). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to IBM and to recipients of software distributed by IBM a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to IBM and to recipients of software distributed by IBM a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work to which Your Contribution(s) were submitted, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to the Project, or that your employer has executed a separate Corporate CLA with IBM. - -5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -7. Should You wish to submit work that is not Your original creation, You may submit it to the Project separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -8. You agree to notify IBM of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. - -Please sign and date: ______________________________________________________________ - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be6a15b..c782533 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,33 +1,66 @@ -# Contributing to Composer + -We welcome contributions, but request you follow these guidelines. +[](http://www.apache.org/licenses/LICENSE-2.0) + +# Contributing to Apache OpenWhisk + +Anyone can contribute to the OpenWhisk project, and we welcome your contributions. + +There are multiple ways to contribute: report bugs, improve the docs, and +contribute code, but you must follow these prerequisites and guidelines: - - [Raising issues](#raising-issues) - [Contributor License Agreement](#contributor-license-agreement) + - [Raising issues](#raising-issues) - [Coding Standards](#coding-standards) - -## Raising issues -Please raise any bug reports on the issue tracker. Be sure to -search the list to see if your issue has already been raised. +### Contributor License Agreement + +All contributors must sign and submit an Apache CLA (Contributor License Agreement). -A good bug report is one that make it easy for us to understand what you were -trying to do and what went wrong. Provide as much context as possible so we can try to recreate the issue. +Instructions on how to do this can be found here: +[http://www.apache.org/licenses/#clas](http://www.apache.org/licenses/#clas) +Once submitted, you will receive a confirmation email from the Apache Software Foundation (ASF) and be added to +the following list: http://people.apache.org/unlistedclas.html. -### Contributor License Agreement +Project committers will use this list to verify pull requests (PRs) come from contributors that have signed a CLA. + +We look forward to your contributions! + +## Raising issues + +Please raise any bug reports or enhancement requests on the respective project repository's GitHub issue tracker. Be sure to search the +list to see if your issue has already been raised. + +A good bug report is one that make it easy for us to understand what you were trying to do and what went wrong. +Provide as much context as possible, so we can try to recreate the issue. -In order for us to accept pull-requests, the contributor must first complete -a Contributor License Agreement (CLA). This clarifies the intellectual -property license granted with any contribution. It is for your protection as a -Contributor as well as the protection of IBM and its clients; it does not -change your rights to use your own Contributions for any other purpose. +A good enhancement request comes with an explanation of what you are trying to do and how that enhancement would help you. -You can download the CLAs here: +### Discussion -- [individual](CLA-INDIVIDUAL.md) -- [corporate](CLA-CORPORATE.md) +Please use the project's developer email list to engage our community: +[dev@openwhisk.apache.org](dev@openwhisk.apache.org) +In addition, we provide a "dev" Slack team channel for conversations at: +https://openwhisk-team.slack.com/messages/dev/ ### Coding standards diff --git a/LICENSE b/LICENSE.txt similarity index 99% rename from LICENSE rename to LICENSE.txt index 8dada3e..d645695 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -178,7 +179,7 @@ APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -186,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..5a42896 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,5 @@ +Apache OpenWhisk Composer +Copyright 2016-2020 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). diff --git a/README.md b/README.md index e2b66cf..97ee616 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ + + # @ibm-functions/composer [](https://travis-ci.org/ibm-functions/composer) @@ -5,75 +24,56 @@ [](http://slack.openwhisk.org/) -Composer is a new programming model from [IBM -Research](https://ibm.biz/serverless-research) for composing [IBM Cloud -Functions](https://ibm.biz/openwhisk), built on [Apache -OpenWhisk](https://github.com/apache/incubator-openwhisk). With Composer, -developers can build even more serverless applications including using it for -IoT, with workflow orchestration, conversation services, and devops automation, -to name a few examples. - -Programming compositions for IBM Cloud Functions is supported by a new developer -tool called [IBM Cloud Shell](https://github.com/ibm-functions/shell), or just -_Shell_. Shell offers a CLI and graphical interface for fast, incremental, -iterative, and local development of serverless applications. While we recommend -using Shell, Shell is not required to work with compositions. Compositions may -be managed using a combination of the Composer [compose](docs/COMPOSE.md) command -(for deployment) and the [OpenWhisk -CLI](https://console.bluemix.net/openwhisk/learn/cli) (for configuration, -invocation, and life-cycle management). - -**In contrast to earlier releases of Composer, a Redis server is not required to -run compositions**. Composer now synthesizes OpenWhisk [conductor -actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md) +Composer is a new programming model for composing cloud functions built on +[Apache OpenWhisk](https://github.com/apache/openwhisk). With +Composer, developers can build even more serverless applications including using +it for IoT, with workflow orchestration, conversation services, and devops +automation, to name a few examples. + +Composer synthesizes OpenWhisk [conductor +actions](https://github.com/apache/openwhisk/blob/master/docs/conductors.md) to implement compositions. Compositions have all the attributes and capabilities -of an action (e.g., default parameters, limits, blocking invocation, web -export). +of an action, e.g., default parameters, limits, blocking invocation, web export. This repository includes: -* the [composer](docs/COMPOSER.md) Node.js module for authoring compositions using +* the [composer](composer.js) Node.js module for authoring compositions using JavaScript, -* the [compose](docs/COMPOSE.md) command for deploying compositions, +* the [compose](bin/compose.js) and [deploy](bin/deploy.js) + [commands](docs/COMMANDS.md) for compiling and deploying compositions, * [documentation](docs), [examples](samples), and [tests](test). -Composer and Shell are currently available as _IBM Research previews_. As -Composer and Shell continue to evolve, it may be necessary to redeploy existing -compositions to take advantage of new capabilities. However existing -compositions should continue to run fine without redeployment. - ## Installation Composer is distributed as Node.js package. To install this package, use the Node Package Manager: ``` -npm -g install @ibm-functions/composer +npm install -g @ibm-functions/composer ``` -We recommend to install the package globally (with `-g` option) if you intend to -use the `compose` command to define and deploy compositions. Use a local install -(without `-g` option) if you intend to use `node` instead. The two installations -can coexist. Shell embeds the Composer package, so there is no need to install -Composer explicitly when using Shell. +We recommend installing the package globally (with `-g` option) if you intend to +use the `compose` and `deploy` commands to compile and deploy compositions. ## Defining a composition -A composition is typically defined by means of a Javascript expression as +A composition is typically defined by means of a JavaScript expression as illustrated in [samples/demo.js](samples/demo.js): ```javascript -composer.if( +const composer = require('@ibm-functions/composer') + +module.exports = composer.if( composer.action('authenticate', { action: function ({ password }) { return { value: password === 'abc123' } } }), composer.action('success', { action: function () { return { message: 'success' } } }), composer.action('failure', { action: function () { return { message: 'failure' } } })) ``` -Compositions compose actions using _combinator_ methods. These methods -implement the typical control-flow constructs of a sequential imperative +Compositions compose actions using [combinator](docs/COMBINATORS.md) methods. +These methods implement the typical control-flow constructs of an imperative programming language. This example composition composes three actions named `authenticate`, `success`, and `failure` using the `composer.if` combinator, -which implements the usual conditional construct. It take three actions (or +which implements the usual conditional construct. It takes three actions (or compositions) as parameters. It invokes the first one and, depending on the result of this invocation, invokes either the second or third action. This composition includes the definitions of the three composed actions. If the - actions are defined and deployed elsewhere, the composition code can be shorten + actions are defined and deployed elsewhere, the composition code can be shortened to: ```javascript composer.if('authenticate', 'success', 'failure') @@ -81,113 +81,138 @@ composer.if('authenticate', 'success', 'failure') ## Deploying a composition -One way to deploy a composition is to use the [compose](docs/COMPOSE.md) command: +One way to deploy a composition is to use the `compose` and `deploy` commands: ``` -compose demo.js --deploy demo +compose demo.js > demo.json +deploy demo demo.json -w ``` ``` -ok: created actions /_/authenticate,/_/success,/_/failure,/_/demo +ok: created /_/authenticate,/_/success,/_/failure,/_/demo ``` -The `compose` command synthesizes and deploys an action named `demo` that -implements the composition. It also deploys the composed actions if definitions -are provided for them. +The `compose` command compiles the composition code to a portable JSON format. +The `deploy` command deploys the JSON-encoded composition creating an action +with the given name. It also deploys the composed actions if definitions are +provided for them. The `-w` option authorizes the `deploy` command to overwrite +existing definitions. ## Running a composition The `demo` composition may be invoked like any action, for instance using the -OpenWhisk CLI: +[IBM Cloud CLI](https://cloud.ibm.com/docs/cli): ``` -wsk action invoke demo -p password passw0rd +ibmcloud fn action invoke demo -p password passw0rd ``` ``` -ok: invoked /_/demo with id 4f91f9ed0d874aaa91f9ed0d87baaa07 +ok: invoked /_/demo with id 09ca3c7f8b68489c8a3c7f8b68b89cdc ``` The result of this invocation is the result of the last action in the composition, in this case the `failure` action since the password in incorrect: ``` -wsk activation result 4f91f9ed0d874aaa91f9ed0d87baaa07 +ibmcloud fn activation result 09ca3c7f8b68489c8a3c7f8b68b89cdc ``` ```json { "message": "failure" } ``` -## Execution traces +### Execution traces This invocation creates a trace, i.e., a series of activation records: ``` -wsk activation list +ibmcloud fn activation list ``` -``` -activations -fd89b99a90a1462a89b99a90a1d62a8e demo -eaec119273d94087ac119273d90087d0 failure -3624ad829d4044afa4ad829d40e4af60 demo -a1f58ade9b1e4c26b58ade9b1e4c2614 authenticate -3624ad829d4044afa4ad829d40e4af60 demo -4f91f9ed0d874aaa91f9ed0d87baaa07 demo -``` -The entry with the earliest start time (`4f91f9ed0d874aaa91f9ed0d87baaa07`) +
+Datetime Activation ID Kind Start Duration Status Entity +2019-03-15 16:43:22 e6bea73bf75f4eb7bea73bf75fdeb703 nodejs:10 warm 1ms success guest/demo:0.0.1 +2019-03-15 16:43:21 7efb6b7354c3472cbb6b7354c3272c98 nodejs:10 cold 31ms success guest/failure:0.0.1 +2019-03-15 16:43:21 377cd080f0674e9cbcd080f0679e9c1d nodejs:10 warm 2ms success guest/demo:0.0.1 +2019-03-15 16:43:20 5dceeccbdc7a4caf8eeccbdc7a9caf18 nodejs:10 cold 29ms success guest/authenticate:0.0.1 +2019-03-15 16:43:19 66355a1f012d4ea2b55a1f012dcea264 nodejs:10 cold 104ms success guest/demo:0.0.1 +2019-03-15 16:43:19 09ca3c7f8b68489c8a3c7f8b68b89cdc sequence warm 3.144s success guest/demo:0.0.1 ++ +The entry with the earliest start time (`09ca3c7f8b68489c8a3c7f8b68b89cdc`) summarizes the invocation of the composition while other entries record later activations caused by the composition invocation. There is one entry for each -invocation of a composed action (`a1f58ade9b1e4c26b58ade9b1e4c2614` and -`eaec119273d94087ac119273d90087d0`). The remaining entries record the beginning +invocation of a composed action (`5dceeccbdc7a4caf8eeccbdc7a9caf18` and +`7efb6b7354c3472cbb6b7354c3272c98`). The remaining entries record the beginning and end of the composition as well as the transitions between the composed actions. Compositions are implemented by means of OpenWhisk conductor actions. The [documentation of conductor -actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md) +actions](https://github.com/apache/openwhisk/blob/master/docs/conductors.md) explains execution traces in greater details. -## Getting started -* [Introduction to Serverless - Composition](docs/tutorials/introduction/README.md): Setting up your - programming environment and getting started with Shell and Composer. -* [Building a Translation Slack Bot with Serverless - Composition](docs/tutorials/translateBot/README.md): A more advanced tutorial - using Composition to build a serverless Slack chatbot that does language - translation. -* [Composer Reference](docs/README.md): A comprehensive reference manual for the - Node.js programmer. - -## Videos -* The [IBM Cloud Shell YouTube - channel](https://www.youtube.com/channel/UCcu16nIMNclSujJWDOgUI_g) hosts demo - videos of IBM Cloud Shell, including editing a composition [using a built-in - editor](https://youtu.be/1wmkSYl7EDM) or [an external - editor](https://youtu.be/psqoysnVgE4), and [visualizing a composition's - execution](https://youtu.be/jTaHgDQDZnQ). -* Watch [our presentation at - Serverlessconf'17](https://acloud.guru/series/serverlessconf/view/ibm-cloud-functions) - about Composer and Shell. -* [Conductor Actions and Composer - v2](https://urldefense.proofpoint.com/v2/url?u=https-3A__youtu.be_qkqenC5b1kE&d=DwIGaQ&c=jf_iaSHvJObTbx-siA1ZOg&r=C3zA0dhyHjF4WaOy8EW8kQHtYUl9-dKPdS8OrjFeQmE&m=vCx7thSf3YtT7x3Pe2DaLYw-dcjU1hNIfDkTM_21ObA&s=MGh9y3vSvssj1xTzwEurJ6TewdE7Dr2Ycs10Tix8sNg&e=) - (29:30 minutes into the video): A discussion of the composition runtime. - -## Blog posts -* [Serverless Composition with IBM Cloud - Functions](https://www.raymondcamden.com/2017/10/09/serverless-composition-with-ibm-cloud-functions/) -* [Building Your First Serverless Composition with IBM Cloud - Functions](https://www.raymondcamden.com/2017/10/18/building-your-first-serverless-composition-with-ibm-cloud-functions/) -* [Upgrading Serverless Superman to IBM - Composer](https://www.raymondcamden.com/2017/10/20/upgrading-serverless-superman-to-ibm-composer/) -* [Calling Multiple Serverless Actions and Retaining Values with IBM - Composer](https://www.raymondcamden.com/2017/10/25/calling-multiple-serverless-actions-and-retaining-values-with-ibm-composer/) -* [Serverless Try/Catch/Finally with IBM - Composer](https://www.raymondcamden.com/2017/11/22/serverless-trycatchfinally-with-ibm-composer/) -* [Composing functions into - applications](https://medium.com/openwhisk/composing-functions-into-applications-70d3200d0fac) -* [A composition story: using IBM Cloud Functions to relay SMS to - email](https://medium.com/openwhisk/a-composition-story-using-ibm-cloud-functions-to-relay-sms-to-email-d67fc65d29c) -* [Data Flows in Serverless Cloud-Native - Applications](http://heidloff.net/article/serverless-data-flows) -* [Transforming JSON Data in Serverless - Applications](http://heidloff.net/article/transforming-json-serverless) - -## Contributions -We are looking forward to your feedback and criticism. We encourage you to [join -us on slack](http://ibm.biz/composer-users). File bugs and we will squash them. - -We welcome contributions to Composer and Shell. See -[CONTRIBUTING.md](CONTRIBUTING.md). +While composer does not limit in principle the length of a composition, +OpenWhisk deployments typically enforce a limit on the number of action +invocations in a composition as well as an upper bound on the rate of +invocation. These limits may result in compositions failing to execute to +completion. + +## Parallel compositions with Redis + +Composer offers parallel combinators that make it possible to run actions or +compositions in parallel, for example: +```javascript +composer.parallel('checkInventory', 'detectFraud') +``` + +The width of parallel compositions is not in principle limited by composer, but +issuing many concurrent invocations may hit OpenWhisk limits leading to +failures: failure to execute a branch of a parallel composition or failure to +complete the parallel composition. + +These combinators require access to a Redis instance to hold intermediate +results of parallel compositions. The Redis credentials may be specified at +invocation time or earlier by means of default parameters or package bindings. +The required parameter is named `$composer`. It is a dictionary with a `redis` +field of type dictionary. The `redis` dictionary specifies the `uri` for the +Redis instance and optionally a certificate as a base64-encoded string to enable +TLS connections. Hence, the input parameter object for our order-processing +example should be: +```json +{ + "$composer": { + "redis": { + "uri": "redis://...", + "ca": "optional base64 encoded tls certificate" + } + }, + "order": { ... } +} +``` + +The intent is to store intermediate results in Redis as the parallel composition +is progressing. Redis entries are deleted after completion and, as an added +safety, expire after twenty-four hours. + +# OpenWhisk SSL configuration + +Additional configuration is required when using an OpenWhisk instance with +self-signed certificates to disable SSL certificate validation. The input +parameter object must contain a parameter of type dictionary named `$composer`. +This dictionary must contain a dictionary named `openwhisk`. The `openwhisk` +dictionary must contain a field named `ignore_certs` with value `true`: +```json +{ + "$composer": { + "openwhisk": { + "ignore_certs": true + } + }, + ... +} +``` + +This explicit SSL configuration is currently only necessary when using parallel +combinators or the `async` combinator. + +# Installation from Source + +To install composer from a source release, download the composer source code +from [our GitHub repo](https://github.com/ibm-functions/composer/releases), +rename the release tarball to `composer.tgz` and install it with command: +```shell +npm install -g composer.tgz +``` diff --git a/bin/compose b/bin/compose deleted file mode 100755 index fd16e8a..0000000 --- a/bin/compose +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env node - -'use strict' - -const fs = require('fs') -const vm = require('vm') -const minimist = require('minimist') -const composer = require('../composer') - -const argv = minimist(process.argv.slice(2), { - string: ['apihost', 'auth', 'deploy', 'lower'], - boolean: ['insecure', 'encode', 'json', 'version'], - alias: { auth: 'u', insecure: 'i', version: 'v' } -}) - -if (argv.version) { - console.log(composer.version) - return -} - -let count = 0 -if (argv.json) count++ -if (argv.encode) count++ -if (typeof argv.deploy !== 'undefined') count++ - -if (argv._.length !== 1 || count > 1 || argv.deploy === '') { - console.error('Usage:') - console.error(' compose composition.js[on] command [flags]') - console.error('Commands:') - console.error(' --json output the json representation for the composition (default command)') - console.error(' --deploy NAME deploy the composition with name NAME') - console.error(' --encode output the conductor action code for the composition') - console.error('Flags:') - console.error(' --lower [VERSION] lower to primitive combinators or specific composer version') - console.error(' --apihost HOST API HOST') - console.error(' -u, --auth KEY authorization KEY') - console.error(' -i, --insecure bypass certificate checking') - console.error(' -v, --version output the composer version') - return -} - -const filename = argv._[0] -const source = fs.readFileSync(filename, { encoding: 'utf8' }) -let composition = filename.slice(filename.lastIndexOf('.')) === '.js' ? vm.runInNewContext(source, { composer, require, console, process }) : composer.deserialize(JSON.parse(source)) -const lower = typeof argv.lower === 'string' ? argv.lower : false -if (argv.deploy) { - const options = { ignore_certs: argv.insecure } - if (argv.apihost) options.apihost = argv.apihost - if (argv.auth) options.api_key = argv.auth - composer.openwhisk(options).compositions.deploy(composer.composition(argv.deploy, composition), lower) - .then(obj => { - const names = obj.actions.map(action => action.name) - console.log(`ok: created action${names.length > 1 ? 's' : ''} ${names}`) - }, console.error) -} else if (argv.encode) { - console.log(composer.encode(composer.composition('anonymous', composition), lower).actions.slice(-1)[0].action.exec.code) -} else { - console.log(JSON.stringify(composer.lower(composition, lower), null, 4)) -} diff --git a/bin/compose.js b/bin/compose.js new file mode 100755 index 0000000..4b95b4b --- /dev/null +++ b/bin/compose.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node + + /* + * 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. + */ + +'use strict' + +const composer = require('../composer') +const conductor = require('../conductor') +const fs = require('fs') +const json = require('../package.json') +const minimist = require('minimist') +const Module = require('module') +const path = require('path') + +const argv = minimist(process.argv.slice(2), { + string: ['debug', 'o'], + boolean: ['version', 'ast', 'js', 'file'], + alias: { version: 'v' } +}) + +if (argv.version) { + console.log(json.version) + process.exit(0) +} + +// resolve module even if not in default path +const _resolveFilename = Module._resolveFilename +Module._resolveFilename = function (request, parent) { + if (request.startsWith(json.name)) { + try { + return _resolveFilename(request, parent) + } catch (error) { + return require.resolve(request.replace(json.name, '..')) + } + } else { + return _resolveFilename(request, parent) + } +} + +if (argv._.length !== 1 || path.extname(argv._[0]) !== '.js') { + console.error('Usage:') + console.error(' compose composition.js [flags]') + console.error('Flags:') + console.error(' --ast only output the ast for the composition') + console.error(' --file write output to a file next to the input file') + console.error(' --js output the conductor action code for the composition') + console.error(' -o FILE write output to FILE') + console.error(' -v, --version output the composer version') + console.error(' --debug LIST comma-separated list of debug flags (when using --js flag)') + process.exit(1) +} + +let composition +let file +try { + composition = composer.parse(require(path.resolve(argv._[0]))) // load and validate composition + composition = composition.compile() +} catch (error) { + error.statusCode = 422 + console.error(error) + process.exit(422 - 256) // Unprocessable Entity +} +if (argv.js) { + composition = conductor.generate(composition, argv.debug).action.exec.code +} else { + if (argv.ast) composition = composition.ast + composition = JSON.stringify(composition, null, 4) +} +if (argv.o) { + file = argv.o +} else if (argv.file) { + const { dir, name } = path.parse(argv._[0]) + file = path.format({ dir, name, ext: argv.js ? '.conductor.js' : '.json' }) +} +if (file) { + fs.writeFileSync(file, composition.concat('\n'), { encoding: 'utf8' }) +} else { + console.log(composition) +} diff --git a/bin/deploy.js b/bin/deploy.js new file mode 100755 index 0000000..54524ca --- /dev/null +++ b/bin/deploy.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + + /* + * 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. + */ + +'use strict' + +const composer = require('../composer') +const client = require('../client') +const fqn = require('../fqn') +const fs = require('fs') +const json = require('../package.json') +const minimist = require('minimist') +const path = require('path') + +const argv = minimist(process.argv.slice(2), { + string: ['apihost', 'apiversion', 'auth', 'source', 'annotation', 'annotation-file', 'debug', 'kind'], + boolean: ['insecure', 'version', 'overwrite', 'basic', 'bearer'], + alias: { auth: 'u', insecure: 'i', version: 'v', annotation: 'a', 'annotation-file': 'A', overwrite: 'w', timeout: 't', memory: 'm', logsize: 'l' } +}) + +if (argv.version) { + console.log(json.version) + process.exit(0) +} + +if (argv._.length !== 2 || path.extname(argv._[1]) !== '.json') { + console.error('Usage:') + console.error(' deploy composition composition.json [flags]') + console.error('Flags:') + console.error(' -a, --annotation KEY=VALUE add KEY annotation with VALUE') + console.error(' -A, --annotation-file KEY=FILE add KEY annotation with FILE content') + console.error(' --apihost HOST API HOST') + console.error(' --apiversion VERSION API VERSION') + console.error(' --basic force basic authentication. Note: this option can only be chosen for CF-based namespaces') + console.error(' --bearer force bearer token authentication') + console.error(' -i, --insecure bypass certificate checking') + console.error(' --kind KIND the KIND of the conductor action runtime') + console.error(' -l, --logsize LIMIT the maximum log size LIMIT in MB for the conductor action (default 10)') + console.error(' -m, --memory LIMIT the maximum memory LIMIT in MB for the conductor action (default 256)') + console.error(' -t, --timeout LIMIT the timeout LIMIT in milliseconds for the conductor action (default 60000)') + console.error(' -u, --auth KEY authorization KEY') + console.error(' -v, --version output the composer version') + console.error(' -w, --overwrite overwrite actions if already defined') + console.error(' --debug LIST comma-separated list of debug flags') + process.exit(1) +} +let composition +try { + composition = JSON.parse(fs.readFileSync(argv._[1], 'utf8')) + if (typeof composition !== 'object') throw new Error('Composition must be a dictionary') + if (typeof composition.ast !== 'object') throw new Error('Composition must have a field "ast" of type dictionary') + if (typeof composition.composition !== 'object') throw new Error('Composition must have a field "composition" of type dictionary') + if (typeof composition.version !== 'string') throw new Error('Composition must have a field "version" of type string') + if (composition.actions !== undefined && !Array.isArray(composition.actions)) throw new Error('Optional field "actions" must be an array') + composition.composition = composer.parse(composition.composition) // validate composition + if (typeof argv.annotation === 'string') argv.annotation = [argv.annotation] + composition.annotations = [] + for (let annotation of [...(argv.annotation || [])]) { + const index = annotation.indexOf('=') + if (index < 0) throw Error('Annotation syntax must be "KEY=VALUE"') + composition.annotations.push({ key: annotation.substring(0, index), value: annotation.substring(index + 1) }) + } + if (typeof argv['annotation-file'] === 'string') argv['annotation-file'] = [argv['annotation-file']] + for (let annotation of argv['annotation-file'] || []) { + const index = annotation.indexOf('=') + if (index < 0) throw Error('Annotation syntax must be "KEY=FILE"') + composition.annotations.push({ key: annotation.substring(0, index), value: fs.readFileSync(annotation.substring(index + 1), 'utf8') }) + } +} catch (error) { + error.statusCode = 422 + console.error(error) + process.exit(422 - 256) // Unprocessable Entity +} +const options = { ignore_certs: argv.insecure } +if (argv.apihost) options.apihost = argv.apihost +if (argv.auth) options.api_key = argv.auth +if (argv.apiversion) options.apiversion = argv.apiversion +try { + composition.name = fqn(argv._[0]) +} catch (error) { + error.statusCode = 400 + console.error(error) + process.exit(400 - 256) // Bad Request +} +if (argv.basic && argv.bearer) { + throw Error('Must select either basic authentication of bearer token authentication') +} +if (typeof argv.timeout !== 'undefined' && typeof argv.timeout !== 'number') { + throw Error('Timeout must be a number') +} +if (typeof argv.memory !== 'undefined' && typeof argv.memory !== 'number') { + throw Error('Maximum memory must be a number') +} +if (typeof argv.logsize !== 'undefined' && typeof argv.logsize !== 'number') { + throw Error('Maximum log size must be a number') +} +client(options, argv.basic, argv.bearer).compositions.deploy(composition, argv.overwrite, argv.debug, argv.kind, argv.timeout, argv.memory, argv.logsize) + .then(actions => { + const names = actions.map(action => action.name) + console.log(`ok: created action${actions.length > 1 ? 's' : ''} ${names}`) + }) + .catch(error => { + error.statusCode = error.statusCode || 500 + console.error(error) + process.exit(error.statusCode - 256) + }) diff --git a/client.js b/client.js new file mode 100644 index 0000000..27757c2 --- /dev/null +++ b/client.js @@ -0,0 +1,166 @@ +/* + * 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. + */ + +/* eslint no-eval: 0 */ + +'use strict' + +const conductor = require('./conductor') +const fs = require('fs') +const openwhisk = require('openwhisk') +const ibmcloudUtils = require('./ibmcloud-utils') +const os = require('os') +const path = require('path') + +const NS_TYPE_CF = 'CF' +const NS_TYPE_IAM = 'IAM' + +// return enhanced openwhisk client capable of deploying compositions +module.exports = function (options, basic, bearer) { + // try to extract apihost and key first from whisk property file file and then from process.env + let apihost + let apiversion + let apikey + let ignorecerts + let namespace = ibmcloudUtils.getNamespaceId() + let token + let authHandler + + try { + const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') + const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') + + for (let line of lines) { + let parts = line.trim().split('=') + if (parts.length === 2) { + if (parts[0] === 'APIHOST') { + apihost = parts[1] + } else if (parts[0] === 'APIVERSION') { + apiversion = parts[1] + } else if (parts[0] === 'AUTH') { + apikey = parts[1] + } else if (parts[0] === 'APIGW_ACCESS_TOKEN') { + token = parts[1] + } + } + } + } catch (error) {} + + if (process.env.__OW_API_HOST) apihost = process.env.__OW_API_HOST + if (process.env.__OW_API_KEY) apikey = process.env.__OW_API_KEY + if (process.env.__OW_NAMESPACE) namespace = process.env.__OW_NAMESPACE + if (process.env.__OW_IGNORE_CERTS) ignorecerts = process.env.__OW_IGNORE_CERTS + if (process.env.__OW_APIGW_TOKEN) token = process.env.__OW_APIGW_TOKEN + + // check whether there is a CLI argument that overrides the API key retrieved from the config or env + if (options && options.api_key) apikey = options.api_key + + // retrieve the namespace type + // we need to apply different authentication strategies for CF and IAM + const namespaceType = ibmcloudUtils.getNamespaceType() + + // + // check for IAM-based namespaces, first + if (namespaceType === NS_TYPE_IAM) { + const tokenTimestamp = ibmcloudUtils.getIamTokenTimestamp() + + if (ibmcloudUtils.iamTokenExpired(tokenTimestamp)) { + console.log( + 'Error: Your IAM token seems to be expired. Plase perform an `ibmcloud login` ' + + 'to make sure your token is up to date.' + ) + throw new Error('IAM token expired') + } + + // for authentication, we'll use the user IAM access token + const iamToken = ibmcloudUtils.getIamAuthHeader() + + // define an appropriate authentication handler for IAM + authHandler = { + getAuthHeader: () => { + // use bearer token for IAM authentication + return Promise.resolve(iamToken) + } + } + + // ignore the API key value, in case of an IAM-based namespace + apikey = undefined + + // + // in case of CF-based namespaces, the user can opt-in for using bearer token authentication instead of using the default basic authentication + } else if (namespaceType === NS_TYPE_CF) { + if (bearer && !basic) { + // switch from basic auth to bearer token + authHandler = { + getAuthHeader: () => { + return Promise.resolve(`Bearer ${token}`) + } + } + } else if (apikey) { + // use basic auth + authHandler = { + getAuthHeader: () => { + const apiKeyBase64 = Buffer.from(apikey).toString('base64') + return Promise.resolve(`Basic ${apiKeyBase64}`) + } + } + } + } + + const namespaceDisplayName = namespace || '_' + + console.log(`deploying to ${namespaceType}-based namespace '${namespaceDisplayName}' ...`) + + if (!authHandler) { + throw new Error( + `Failed to determine the authentication strategy for the ${namespaceType}-based namespace '${namespaceDisplayName}'` + ) + } + + const wsk = openwhisk( + Object.assign({ apihost, apiversion, auth_handler: authHandler, namespace, ignore_certs: ignorecerts }, options) + ) + wsk.compositions = new Compositions(wsk) + return wsk +} + +// management class for compositions +class Compositions { + constructor (wsk) { + this.actions = wsk.actions + } + + deploy (composition, overwrite, debug, kind, timeout, memory, logs, httpOptions) { + function addHttpOptions (action) { + // the openwhisk npm allows passthrough request-style options + return Object.assign({}, action, httpOptions) + } + + const actions = (composition.actions || []) + .concat(conductor.generate(composition, debug, kind, timeout, memory, logs)) + .map(addHttpOptions) + return actions + .reduce( + (promise, action) => + promise + .then(() => overwrite && this.actions.delete(action).catch(() => {})) + .then(() => this.actions.create(action)), + Promise.resolve() + ) + .then(() => actions) + } +} diff --git a/composer.js b/composer.js index 7b446a7..76b1965 100644 --- a/composer.js +++ b/composer.js @@ -1,11 +1,12 @@ /* - * Copyright 2017-2018 IBM Corporation + * 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 * - * Licensed 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 + * 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, @@ -16,654 +17,362 @@ 'use strict' -// compiler code shared between composer and conductor (to permit client-side and server-side lowering) - -function compiler() { - const util = require('util') - const semver = require('semver') - - // standard combinators - const combinators = { - empty: { since: '0.4.0' }, - seq: { components: true, since: '0.4.0' }, - sequence: { components: true, since: '0.4.0' }, - if: { args: [{ _: 'test' }, { _: 'consequent' }, { _: 'alternate', optional: true }], since: '0.4.0' }, - if_nosave: { args: [{ _: 'test' }, { _: 'consequent' }, { _: 'alternate', optional: true }], since: '0.4.0' }, - while: { args: [{ _: 'test' }, { _: 'body' }], since: '0.4.0' }, - while_nosave: { args: [{ _: 'test' }, { _: 'body' }], since: '0.4.0' }, - dowhile: { args: [{ _: 'body' }, { _: 'test' }], since: '0.4.0' }, - dowhile_nosave: { args: [{ _: 'body' }, { _: 'test' }], since: '0.4.0' }, - try: { args: [{ _: 'body' }, { _: 'handler' }], since: '0.4.0' }, - finally: { args: [{ _: 'body' }, { _: 'finalizer' }], since: '0.4.0' }, - retain: { components: true, since: '0.4.0' }, - retain_catch: { components: true, since: '0.4.0' }, - let: { args: [{ _: 'declarations', type: 'object' }], components: true, since: '0.4.0' }, - mask: { components: true, since: '0.4.0' }, - action: { args: [{ _: 'name', type: 'string' }, { _: 'action', type: 'object', optional: true }], since: '0.4.0' }, - composition: { args: [{ _: 'name', type: 'string' }, { _: 'composition' }], since: '0.4.0' }, - repeat: { args: [{ _: 'count', type: 'number' }], components: true, since: '0.4.0' }, - retry: { args: [{ _: 'count', type: 'number' }], components: true, since: '0.4.0' }, - value: { args: [{ _: 'value', type: 'value' }], since: '0.4.0' }, - literal: { args: [{ _: 'value', type: 'value' }], since: '0.4.0' }, - function: { args: [{ _: 'function', type: 'object' }], since: '0.4.0' } - } +const fqn = require('./fqn') +const fs = require('fs') +const util = require('util') - // composer error class - class ComposerError extends Error { - constructor(message, argument) { - super(message + (argument !== undefined ? '\nArgument: ' + util.inspect(argument) : '')) - } - } +const version = require('./package.json').version - // composition class - class Composition { - // weaker instanceof to tolerate multiple instances of this class - static [Symbol.hasInstance](instance) { - return instance.constructor && instance.constructor.name === Composition.name - } - - // construct a composition object with the specified fields - constructor(composition) { - return Object.assign(this, composition) - } - - // apply f to all fields of type composition - visit(f) { - const combinator = combinators[this.type] - if (combinator.components) { - this.components = this.components.map(f) - } - for (let arg of combinator.args || []) { - if (arg.type === undefined) { - this[arg._] = f(this[arg._], arg._) - } - } - } - } +const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) - // compiler class - class Compiler { - // detect task type and create corresponding composition object - task(task) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (task === null) return this.empty() - if (task instanceof Composition) return task - if (typeof task === 'function') return this.function(task) - if (typeof task === 'string') return this.action(task) - throw new ComposerError('Invalid argument', task) - } - - // function combinator: stringify function code - function(fun) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (typeof fun === 'function') { - fun = `${fun}` - if (fun.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', fun) - } - if (typeof fun === 'string') { - fun = { kind: 'nodejs:default', code: fun } - } - if (typeof fun !== 'object' || fun === null) throw new ComposerError('Invalid argument', fun) - return new Composition({ type: 'function', function: { exec: fun } }) - } - - // lowering - - _empty() { - return this.sequence() - } - - _seq(composition) { - return this.sequence(...composition.components) - } - - _value(composition) { - return this._literal(composition) - } - - _literal(composition) { - return this.let({ value: composition.value }, () => value) - } - - _retain(composition) { - return this.let( - { params: null }, - args => { params = args }, - this.mask(...composition.components), - result => ({ params, result })) - } - - _retain_catch(composition) { - return this.seq( - this.retain( - this.finally( - this.seq(...composition.components), - result => ({ result }))), - ({ params, result }) => ({ params, result: result.result })) - } - - _if(composition) { - return this.let( - { params: null }, - args => { params = args }, - this.if_nosave( - this.mask(composition.test), - this.seq(() => params, this.mask(composition.consequent)), - this.seq(() => params, this.mask(composition.alternate)))) - } - - _while(composition) { - return this.let( - { params: null }, - args => { params = args }, - this.while_nosave( - this.mask(composition.test), - this.seq(() => params, this.mask(composition.body), args => { params = args })), - () => params) - } - - _dowhile(composition) { - return this.let( - { params: null }, - args => { params = args }, - this.dowhile_nosave( - this.seq(() => params, this.mask(composition.body), args => { params = args }), - this.mask(composition.test)), - () => params) - } - - _repeat(composition) { - return this.let( - { count: composition.count }, - this.while( - () => count-- > 0, - this.mask(this.seq(...composition.components)))) - } - - _retry(composition) { - return this.let( - { count: composition.count }, - params => ({ params }), - this.dowhile( - this.finally(({ params }) => params, this.mask(this.retain_catch(...composition.components))), - ({ result }) => result.error !== undefined && count-- > 0), - ({ result }) => result) - } - - // define combinator methods for the standard combinators - static init() { - for (let type in combinators) { - const combinator = combinators[type] - // do not overwrite hand-written combinators - Compiler.prototype[type] = Compiler.prototype[type] || function () { - const composition = new Composition({ type }) - const skip = combinator.args && combinator.args.length || 0 - if (!combinator.components && (arguments.length > skip)) { - throw new ComposerError('Too many arguments') - } - for (let i = 0; i < skip; ++i) { - const arg = combinator.args[i] - const argument = arg.optional ? arguments[i] || null : arguments[i] - switch (arg.type) { - case undefined: - composition[arg._] = this.task(argument) - continue - case 'value': - if (typeof argument === 'function') throw new ComposerError('Invalid argument', argument) - composition[arg._] = argument === undefined ? {} : argument - continue - case 'object': - if (argument === null || Array.isArray(argument)) throw new ComposerError('Invalid argument', argument) - default: - if (typeof argument !== arg.type) throw new ComposerError('Invalid argument', argument) - composition[arg._] = argument - } - } - if (combinator.components) { - composition.components = Array.prototype.slice.call(arguments, skip).map(obj => this.task(obj)) - } - return composition - } - } - } - - // return combinator list - get combinators() { - return combinators - } - - // recursively deserialize composition - deserialize(composition) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - composition = new Composition(composition) // copy - composition.visit(composition => this.deserialize(composition)) - return composition - } - - // label combinators with the json path - label(composition) { - if (arguments.length > 1) throw new ComposerError('Too many arguments') - if (!(composition instanceof Composition)) throw new ComposerError('Invalid argument', composition) - - const label = path => (composition, name, array) => { - composition = new Composition(composition) // copy - composition.path = path + (name !== undefined ? (array === undefined ? `.${name}` : `[${name}]`) : '') - // label nested combinators - composition.visit(label(composition.path)) - return composition - } - - return label('')(composition) - } - - // recursively label and lower combinators to the desired set of combinators (including primitive combinators) - lower(composition, combinators = []) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (!(composition instanceof Composition)) throw new ComposerError('Invalid argument', composition) - if (!Array.isArray(combinators) && typeof combinators !== 'boolean' && typeof combinators !== 'string') throw new ComposerError('Invalid argument', combinators) - - if (combinators === false) return composition // no lowering - if (combinators === true || combinators === '') combinators = [] // maximal lowering - if (typeof combinators === 'string') { // lower to combinators of specific composer version - combinators = Object.keys(this.combinators).filter(key => semver.gte(combinators, this.combinators[key].since)) - } - - const lower = composition => { - composition = new Composition(composition) // copy - // repeatedly lower root combinator - while (combinators.indexOf(composition.type) < 0 && this[`_${composition.type}`]) { - const path = composition.path - composition = this[`_${composition.type}`](composition) - if (path !== undefined) composition.path = path - } - // lower nested combinators - composition.visit(lower) - return composition - } - - return lower(composition) - } - } +// error class +class ComposerError extends Error { + constructor (message, argument) { + super(message + (argument !== undefined ? '\nArgument value: ' + util.inspect(argument) : '')) + } +} - Compiler.init() +const composer = { util: { declare, version } } + +const lowerer = { + literal (value) { + return composer.let({ value }, () => value) + }, + + retain (...components) { + let params = null + return composer.let( + { params }, + composer.finally( + args => { params = args }, + composer.seq(composer.mask(...components), + result => ({ params, result })))) + }, + + retain_catch (...components) { + return composer.seq( + composer.retain( + composer.finally( + composer.seq(...components), + result => ({ result }))), + ({ params, result }) => ({ params, result: result.result })) + }, + + if (test, consequent, alternate) { + let params = null + return composer.let( + { params }, + composer.finally( + args => { params = args }, + composer.if_nosave( + composer.mask(test), + composer.finally(() => params, composer.mask(consequent)), + composer.finally(() => params, composer.mask(alternate))))) + }, + + while (test, body) { + let params = null + return composer.let( + { params }, + composer.finally( + args => { params = args }, + composer.seq(composer.while_nosave( + composer.mask(test), + composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args }))), + () => params))) + }, + + dowhile (body, test) { + let params = null + return composer.let( + { params }, + composer.finally( + args => { params = args }, + composer.seq(composer.dowhile_nosave( + composer.finally(() => params, composer.seq(composer.mask(body), args => { params = args })), + composer.mask(test)), + () => params))) + }, + + repeat (count, ...components) { + return composer.let( + { count }, + composer.while( + () => count-- > 0, + composer.mask(...components))) + }, + + retry (count, ...components) { + return composer.let( + { count }, + params => ({ params }), + composer.dowhile( + composer.finally(({ params }) => params, composer.mask(composer.retain_catch(...components))), + ({ result }) => result.error !== undefined && count-- > 0), + ({ result }) => result) + }, + + merge (...components) { + return composer.seq(composer.retain(...components), ({ params, result }) => Object.assign(params, result)) + } +} + +// apply f to all fields of type composition +function visit (composition, f) { + composition = Object.assign({}, composition) // copy + const combinator = composition['.combinator']() + if (combinator.components) { + composition.components = composition.components.map(f) + } + for (let arg of combinator.args || []) { + if (arg.type === undefined && composition[arg.name] !== undefined) { + composition[arg.name] = f(composition[arg.name], arg.name) + } + } + return new Composition(composition) +} - return { ComposerError, Composition, Compiler } +// recursively label combinators with the json path +function label (composition) { + const label = path => (composition, name, array) => { + const p = path + (name !== undefined ? (array === undefined ? `.${name}` : `[${name}]`) : '') + composition = visit(composition, label(p)) // copy + composition.path = p + return composition + } + return label('')(composition) } -// composer module - -function composer() { - const fs = require('fs') - const os = require('os') - const path = require('path') - const { minify } = require('uglify-es') - - // read composer version number - const { version } = require('./package.json') - - // initialize compiler - const { ComposerError, Composition, Compiler } = compiler() - - // capture compiler and conductor code (omitting composer code) - const conductorCode = minify(`const main=(${conductor})(${compiler}())`, { output: { max_line_len: 127 }, mangle: { reserved: [Composition.name] } }).code - - /** - * Parses a (possibly fully qualified) resource name and validates it. If it's not a fully qualified name, - * then attempts to qualify it. - * - * Examples string to namespace, [package/]action name - * foo => /_/foo - * pkg/foo => /_/pkg/foo - * /ns/foo => /ns/foo - * /ns/pkg/foo => /ns/pkg/foo - */ - function parseActionName(name) { - if (typeof name !== 'string' || name.trim().length == 0) throw new ComposerError('Name is not specified') - name = name.trim() - let delimiter = '/' - let parts = name.split(delimiter) - let n = parts.length - let leadingSlash = name[0] == delimiter - // no more than /ns/p/a - if (n < 1 || n > 4 || (leadingSlash && n == 2) || (!leadingSlash && n == 4)) throw new ComposerError('Name is not valid') - // skip leading slash, all parts must be non empty (could tighten this check to match EntityName regex) - parts.forEach(function (part, i) { if (i > 0 && part.trim().length == 0) throw new ComposerError('Name is not valid') }) - let newName = parts.join(delimiter) - if (leadingSlash) return newName - else if (n < 3) return `${delimiter}_${delimiter}${newName}` - else return `${delimiter}${newName}` +// derive combinator methods from combinator table +// check argument count and map argument positions to argument names +// delegate to Composition constructor for the rest of the validation +function declare (combinators, prefix) { + if (arguments.length > 2) throw new ComposerError('Too many arguments in "declare"') + if (!isObject(combinators)) throw new ComposerError('Invalid argument "combinators" in "declare"', combinators) + if (prefix !== undefined && typeof prefix !== 'string') throw new ComposerError('Invalid argument "prefix" in "declare"', prefix) + const composer = {} + for (let key in combinators) { + const type = prefix ? prefix + '.' + key : key + const combinator = combinators[key] + if (!isObject(combinator) || (combinator.args !== undefined && !Array.isArray(combinator.args))) { + throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator) + } + for (let arg of combinator.args || []) { + if (typeof arg.name !== 'string') throw new ComposerError(`Invalid "${type}" combinator specification in "declare"`, combinator) + } + composer[key] = function () { + const composition = { type, '.combinator': () => combinator } + const skip = (combinator.args && combinator.args.length) || 0 + if (!combinator.components && (arguments.length > skip)) { + throw new ComposerError(`Too many arguments in "${type}" combinator`) + } + for (let i = 0; i < skip; ++i) { + composition[combinator.args[i].name] = arguments[i] + } + if (combinator.components) { + composition.components = Array.prototype.slice.call(arguments, skip) + } + return new Composition(composition) } + } + return composer +} - // management class for compositions - class Compositions { - constructor(wsk, composer) { - this.actions = wsk.actions - this.composer = composer - } - - deploy(composition, combinators) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (!(composition instanceof Composition)) throw new ComposerError('Invalid argument', composition) - if (composition.type !== 'composition') throw new ComposerError('Cannot deploy anonymous composition') - const obj = this.composer.encode(composition, combinators) - return obj.actions.reduce((promise, action) => promise.then(() => this.actions.delete(action).catch(() => { })) - .then(() => this.actions.update(action)), Promise.resolve()) - .then(() => obj) - } +// composition class +class Composition { + // weaker instanceof to tolerate multiple instances of this class + static [Symbol.hasInstance] (instance) { + return instance.constructor && instance.constructor.name === Composition.name + } + + // construct a composition object with the specified fields + constructor (composition) { + const combinator = composition['.combinator']() + Object.assign(this, composition) + for (let arg of combinator.args || []) { + if (composition[arg.name] === undefined && arg.optional && arg.type !== undefined) continue + switch (arg.type) { + case undefined: + try { + this[arg.name] = composer.task(arg.optional ? composition[arg.name] || null : composition[arg.name]) + } catch (error) { + throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name]) + } + break + case 'name': + try { + this[arg.name] = fqn(composition[arg.name]) + } catch (error) { + throw new ComposerError(`${error.message} in "${composition.type} combinator"`, composition[arg.name]) + } + break + case 'value': + if (typeof composition[arg.name] === 'function' || composition[arg.name] === undefined) { + throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name]) + } + break + case 'object': + if (!isObject(composition[arg.name])) { + throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name]) + } + break + default: + if ('' + typeof composition[arg.name] !== arg.type) { + throw new ComposerError(`Invalid argument "${arg.name}" in "${composition.type} combinator"`, composition[arg.name]) + } + } + } + if (combinator.components) this.components = (composition.components || []).map(obj => composer.task(obj)) + return this + } + + // compile composition + compile () { + if (arguments.length > 0) throw new ComposerError('Too many arguments in "compile"') + + const actions = [] + + const flatten = composition => { + composition = visit(composition, flatten) + if (composition.type === 'action' && composition.action) { + actions.push({ name: composition.name, action: composition.action }) + delete composition.action + } + return composition } - // enhanced client-side compiler - class Composer extends Compiler { - // enhanced action combinator: mangle name, capture code - action(name, options = {}) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - name = parseActionName(name) // throws ComposerError if name is not valid - let exec - if (Array.isArray(options.sequence)) { // native sequence - exec = { kind: 'sequence', components: options.sequence.map(parseActionName) } - } - if (typeof options.filename === 'string') { // read action code from file - exec = fs.readFileSync(options.filename, { encoding: 'utf8' }) - } - if (typeof options.action === 'function') { // capture function - exec = `const main = ${options.action}` - if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function', options.action) - } - if (typeof options.action === 'string' || typeof options.action === 'object' && options.action !== null && !Array.isArray(options.action)) { - exec = options.action - } - if (typeof exec === 'string') { - exec = { kind: 'nodejs:default', code: exec } - } - const composition = { type: 'action', name } - if (exec) composition.action = { exec } - return new Composition(composition) - } - - // enhanced composition combinator: mangle name - composition(name, composition) { - if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (typeof name !== 'string') throw new ComposerError('Invalid argument', name) - name = parseActionName(name) - return new Composition({ type: 'composition', name, composition: this.task(composition) }) - } - - // return enhanced openwhisk client capable of deploying compositions - openwhisk(options) { - // try to extract apihost and key first from whisk property file file and then from process.env - let apihost - let api_key - - try { - const wskpropsPath = process.env.WSK_CONFIG_FILE || path.join(os.homedir(), '.wskprops') - const lines = fs.readFileSync(wskpropsPath, { encoding: 'utf8' }).split('\n') - - for (let line of lines) { - let parts = line.trim().split('=') - if (parts.length === 2) { - if (parts[0] === 'APIHOST') { - apihost = parts[1] - } else if (parts[0] === 'AUTH') { - api_key = parts[1] - } - } - } - } catch (error) { } - - if (process.env.__OW_API_HOST) apihost = process.env.__OW_API_HOST - if (process.env.__OW_API_KEY) api_key = process.env.__OW_API_KEY - - const wsk = require('openwhisk')(Object.assign({ apihost, api_key }, options)) - wsk.compositions = new Compositions(wsk, this) - return wsk - } - - // recursively encode composition into { composition, actions } by encoding nested compositions into actions and extracting nested action definitions - encode(composition, combinators = []) { // lower non-primitive combinators by default - if (arguments.length > 2) throw new ComposerError('Too many arguments') - if (!(composition instanceof Composition)) throw new ComposerError('Invalid argument', composition) - - composition = this.lower(composition, combinators) - - const actions = [] - - const encode = composition => { - composition = new Composition(composition) // copy - composition.visit(encode) - if (composition.type === 'composition') { - const code = `// generated by composer v${version}\n\nconst composition = ${JSON.stringify(encode(composition.composition), null, 4)}\n\n// do not edit below this point\n\n${conductorCode}` // invoke conductor on composition - composition.action = { exec: { kind: 'nodejs:default', code }, annotations: [{ key: 'conductor', value: composition.composition }, { key: 'composer', value: version }] } - delete composition.composition - composition.type = 'action' - } - if (composition.type === 'action' && composition.action) { - actions.push({ name: composition.name, action: composition.action }) - delete composition.action - } - return composition - } - - composition = encode(composition) - return { composition, actions } - } - - get version() { - return version - } + const obj = { composition: label(flatten(this)).lower(), ast: this, version } + if (actions.length > 0) obj.actions = actions + return obj + } + + // recursively lower combinators to the desired set of combinators (including primitive combinators) + lower (combinators = []) { + if (arguments.length > 1) throw new ComposerError('Too many arguments in "lower"') + if (!Array.isArray(combinators)) throw new ComposerError('Invalid argument "combinators" in "lower"', combinators) + + const lower = composition => { + // repeatedly lower root combinator + while (composition['.combinator']().def) { + const path = composition.path + const combinator = composition['.combinator']() + if (Array.isArray(combinators) && combinators.indexOf(composition.type) >= 0) break + // map argument names to positions + const args = [] + const skip = (combinator.args && combinator.args.length) || 0 + for (let i = 0; i < skip; i++) args.push(composition[combinator.args[i].name]) + if (combinator.components) args.push(...composition.components) + composition = combinator.def(...args) + if (path !== undefined) composition.path = path // preserve path + } + // lower nested combinators + return visit(composition, lower) } - return new Composer() + return lower(this) + } } -module.exports = composer() - -// conductor action - -function conductor({ Compiler }) { - const compiler = new Compiler() +// primitive combinators +const combinators = { + sequence: { components: true }, + if_nosave: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }] }, + while_nosave: { args: [{ name: 'test' }, { name: 'body' }] }, + dowhile_nosave: { args: [{ name: 'body' }, { name: 'test' }] }, + try: { args: [{ name: 'body' }, { name: 'handler' }] }, + finally: { args: [{ name: 'body' }, { name: 'finalizer' }] }, + let: { args: [{ name: 'declarations', type: 'object' }], components: true }, + mask: { components: true }, + action: { args: [{ name: 'name', type: 'name' }, { name: 'action', type: 'object', optional: true }] }, + function: { args: [{ name: 'function', type: 'object' }] }, + async: { components: true }, + parallel: { components: true }, + map: { components: true }, + dynamic: {} +} - this.require = require +Object.assign(composer, declare(combinators)) + +// derived combinators +const extra = { + empty: { def: composer.sequence }, + seq: { components: true, def: composer.sequence }, + if: { args: [{ name: 'test' }, { name: 'consequent' }, { name: 'alternate', optional: true }], def: lowerer.if }, + while: { args: [{ name: 'test' }, { name: 'body' }], def: lowerer.while }, + dowhile: { args: [{ name: 'body' }, { name: 'test' }], def: lowerer.dowhile }, + repeat: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.repeat }, + retry: { args: [{ name: 'count', type: 'number' }], components: true, def: lowerer.retry }, + retain: { components: true, def: lowerer.retain }, + retain_catch: { components: true, def: lowerer.retain_catch }, + value: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal }, + literal: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal }, + merge: { components: true, def: lowerer.merge }, + par: { components: true, def: composer.parallel } +} - function chain(front, back) { - front.slice(-1)[0].next = 1 - front.push(...back) - return front +Object.assign(composer, declare(extra)) + +// add or override definitions of some combinators +Object.assign(composer, { + // detect task type and create corresponding composition object + task (task) { + if (arguments.length > 1) throw new ComposerError('Too many arguments in "task" combinator') + if (task === undefined) throw new ComposerError('Invalid argument in "task" combinator', task) + if (task === null) return composer.empty() + if (task instanceof Composition) return task + if (typeof task === 'function') return composer.function(task) + if (typeof task === 'string') return composer.action(task) + throw new ComposerError('Invalid argument "task" in "task" combinator', task) + }, + + // function combinator: stringify function code + function (fun) { + if (arguments.length > 1) throw new ComposerError('Too many arguments in "function" combinator') + if (typeof fun === 'function') { + fun = `${fun}` + if (fun.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "function" combinator', fun) } - - function sequence(components) { - if (components.length === 0) return [{ type: 'empty' }] - return components.map(compile).reduce(chain) + if (typeof fun === 'string') { + fun = { kind: 'nodejs:default', code: fun } } - - function compile(json) { - const path = json.path - switch (json.type) { - case 'sequence': - return chain([{ type: 'pass', path }], sequence(json.components)) - case 'action': - return [{ type: 'action', name: json.name, path }] - case 'function': - return [{ type: 'function', exec: json.function.exec, path }] - case 'finally': - var body = compile(json.body) - const finalizer = compile(json.finalizer) - var fsm = [[{ type: 'try', path }], body, [{ type: 'exit' }], finalizer].reduce(chain) - fsm[0].catch = fsm.length - finalizer.length - return fsm - case 'let': - var body = sequence(json.components) - return [[{ type: 'let', let: json.declarations, path }], body, [{ type: 'exit' }]].reduce(chain) - case 'mask': - var body = sequence(json.components) - return [[{ type: 'let', let: null, path }], body, [{ type: 'exit' }]].reduce(chain) - case 'try': - var body = compile(json.body) - const handler = chain(compile(json.handler), [{ type: 'pass' }]) - var fsm = [[{ type: 'try', path }], body, [{ type: 'exit' }]].reduce(chain) - fsm[0].catch = fsm.length - fsm.slice(-1)[0].next = handler.length - fsm.push(...handler) - return fsm - case 'if_nosave': - var consequent = compile(json.consequent) - var alternate = chain(compile(json.alternate), [{ type: 'pass' }]) - var fsm = [[{ type: 'pass', path }], compile(json.test), [{ type: 'choice', then: 1, else: consequent.length + 1 }]].reduce(chain) - consequent.slice(-1)[0].next = alternate.length - fsm.push(...consequent) - fsm.push(...alternate) - return fsm - case 'while_nosave': - var consequent = compile(json.body) - var alternate = [{ type: 'pass' }] - var fsm = [[{ type: 'pass', path }], compile(json.test), [{ type: 'choice', then: 1, else: consequent.length + 1 }]].reduce(chain) - consequent.slice(-1)[0].next = 1 - fsm.length - consequent.length - fsm.push(...consequent) - fsm.push(...alternate) - return fsm - case 'dowhile_nosave': - var test = compile(json.test) - var fsm = [[{ type: 'pass', path }], compile(json.body), test, [{ type: 'choice', then: 1, else: 2 }]].reduce(chain) - fsm.slice(-1)[0].then = 1 - fsm.length - fsm.slice(-1)[0].else = 1 - var alternate = [{ type: 'pass' }] - fsm.push(...alternate) - return fsm - } + if (!isObject(fun)) throw new ComposerError('Invalid argument "function" in "function" combinator', fun) + return new Composition({ type: 'function', function: { exec: fun }, '.combinator': () => combinators.function }) + }, + + // action combinator + action (name, options = {}) { + if (arguments.length > 2) throw new ComposerError('Too many arguments in "action" combinator') + if (!isObject(options)) throw new ComposerError('Invalid argument "options" in "action" combinator', options) + let exec + if (Array.isArray(options.sequence)) { // native sequence + exec = { kind: 'sequence', components: options.sequence.map(fqn) } + } else if (typeof options.filename === 'string') { // read action code from file + exec = fs.readFileSync(options.filename, { encoding: 'utf8' }) + } else if (typeof options.action === 'function') { // capture function + exec = `const main = ${options.action}` + if (exec.indexOf('[native code]') !== -1) throw new ComposerError('Cannot capture native function in "action" combinator', options.action) + } else if (typeof options.action === 'string' || isObject(options.action)) { + exec = options.action } - - const fsm = compile(compiler.lower(compiler.label(compiler.deserialize(composition)))) - - const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) - - // encode error object - const encodeError = error => ({ - code: typeof error.code === 'number' && error.code || 500, - error: (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) || 'An internal error occurred' - }) - - // error status codes - const badRequest = error => Promise.reject({ code: 400, error }) - const internalError = error => Promise.reject(encodeError(error)) - - return params => Promise.resolve().then(() => invoke(params)).catch(internalError) - - // do invocation - function invoke(params) { - // initial state and stack - let state = 0 - let stack = [] - - // restore state and stack when resuming - if (params.$resume !== undefined) { - if (!isObject(params.$resume)) return badRequest('The type of optional $resume parameter must be object') - state = params.$resume.state - stack = params.$resume.stack - if (state !== undefined && typeof state !== 'number') return badRequest('The type of optional $resume.state parameter must be number') - if (!Array.isArray(stack)) return badRequest('The type of $resume.stack must be an array') - delete params.$resume - inspect() // handle error objects when resuming - } - - // wrap params if not a dictionary, branch to error handler if error - function inspect() { - if (!isObject(params)) params = { value: params } - if (params.error !== undefined) { - params = { error: params.error } // discard all fields but the error field - state = undefined // abort unless there is a handler in the stack - while (stack.length > 0) { - if (typeof (state = stack.shift().catch) === 'number') break - } - } - } - - // run function f on current stack - function run(f) { - // handle let/mask pairs - const view = [] - let n = 0 - for (let frame of stack) { - if (frame.let === null) { - n++ - } else if (frame.let !== undefined) { - if (n === 0) { - view.push(frame) - } else { - n-- - } - } - } - - // update value of topmost matching symbol on stack if any - function set(symbol, value) { - const element = view.find(element => element.let !== undefined && element.let[symbol] !== undefined) - if (element !== undefined) element.let[symbol] = JSON.parse(JSON.stringify(value)) - } - - // collapse stack for invocation - const env = view.reduceRight((acc, cur) => typeof cur.let === 'object' ? Object.assign(acc, cur.let) : acc, {}) - let main = '(function(){try{' - for (const name in env) main += `var ${name}=arguments[1]['${name}'];` - main += `return eval((${f}))(arguments[0])}finally{` - for (const name in env) main += `arguments[1]['${name}']=${name};` - main += '}})' - try { - return (1, eval)(main)(params, env) - } finally { - for (const name in env) set(name, env[name]) - } - } - - while (true) { - // final state, return composition result - if (state === undefined) { - console.log(`Entering final state`) - console.log(JSON.stringify(params)) - if (params.error) return params; else return { params } - } - - // process one state - const json = fsm[state] // json definition for current state - if (json.path !== undefined) console.log(`Entering composition${json.path}`) - const current = state - state = json.next === undefined ? undefined : current + json.next // default next state - switch (json.type) { - case 'choice': - state = current + (params.value ? json.then : json.else) - break - case 'try': - stack.unshift({ catch: current + json.catch }) - break - case 'let': - stack.unshift({ let: JSON.parse(JSON.stringify(json.let)) }) - break - case 'exit': - if (stack.length === 0) return internalError(`State ${current} attempted to pop from an empty stack`) - stack.shift() - break - case 'action': - return { action: json.name, params, state: { $resume: { state, stack } } } // invoke continuation - break - case 'function': - let result - try { - result = run(json.exec.code) - } catch (error) { - console.error(error) - result = { error: `An exception was caught at state ${current} (see log for details)` } - } - if (typeof result === 'function') result = { error: `State ${current} evaluated to a function` } - // if a function has only side effects and no return value, return params - params = JSON.parse(JSON.stringify(result === undefined ? params : result)) - inspect() - break - case 'empty': - inspect() - break - case 'pass': - break - default: - return internalError(`State ${current} has an unknown type`) - } - } + if (typeof exec === 'string') { + exec = { kind: 'nodejs:default', code: exec } } -} + const composition = { type: 'action', name, '.combinator': () => combinators.action } + if (exec) { + composition.action = { exec } + if (isObject(options.limits)) composition.action.limits = options.limits + } + return new Composition(composition) + }, + + // recursively deserialize composition + parse (composition) { + if (arguments.length > 1) throw new ComposerError('Too many arguments in "parse" combinator') + if (!isObject(composition)) throw new ComposerError('Invalid argument "composition" in "parse" combinator', composition) + const combinator = typeof composition['.combinator'] === 'function' ? composition['.combinator']() : combinators[composition.type] + if (!isObject(combinator)) throw new ComposerError('Invalid composition type in "parse" combinator', composition) + return visit(Object.assign({ '.combinator': () => combinator }, composition), composition => composer.parse(composition)) + } +}) + +module.exports = composer diff --git a/conductor.js b/conductor.js new file mode 100644 index 0000000..80495d9 --- /dev/null +++ b/conductor.js @@ -0,0 +1,443 @@ +/* + * 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. + */ + +/* eslint no-eval: 0 */ + +'use strict' + +const { minify } = require('terser') + +// read conductor version number +const version = require('./package.json').version + +// synthesize conductor action code from composition +function generate ({ name, composition, ast, version: composer, annotations = [] }, debug, kind = 'nodejs:default', timeout = 60000, memory = 256, logs = 10) { + let code = `// generated by composer v${composer} and conductor v${version}\n\nconst composition = ${JSON.stringify(composition, null, 4)}\n\n// do not edit below this point\n\n` + + minify(`const main=(${main})(composition)`, { output: { max_line_len: 127 } }).code + if (debug) code = `process.env.DEBUG='${debug}'\n\n` + code + annotations = annotations.concat([ + { key: 'conductor', value: ast }, + { key: 'composerVersion', value: composer }, + { key: 'conductorVersion', value: version }, + { key: 'provide-api-key', value: true }]) + return { name, action: { exec: { kind, code }, annotations, limits: { timeout, memory, logs } } } +} + +module.exports = { generate } + +// runtime code +function main (composition) { + const openwhisk = require(/* webpackIgnore: true */ 'openwhisk') + let wsk + let db + const expiration = 86400 // expire redis key after a day + + function live (id) { return `composer/fork/${id}` } + function done (id) { return `composer/join/${id}` } + + function createRedisClient (p) { + const client = require(/* webpackIgnore: true */ 'redis').createClient(p.s.redis.uri, p.s.redis.ca ? { tls: { ca: Buffer.from(p.s.redis.ca, 'base64').toString('binary') } } : {}) + const noop = () => { } + let handler = noop + client.on('error', error => handler(error)) + require(/* webpackIgnore: true */ 'redis-commands').list.forEach(f => { + client[`${f}Async`] = function () { + let failed = false + return new Promise((resolve, reject) => { + handler = error => { + handler = noop + failed = true + reject(error) + } + client[f](...arguments, (error, result) => { + handler = noop + return error ? reject(error) : resolve(result) + }) + }).catch(error => { + if (failed) client.end(true) + return Promise.reject(error) + }) + } + }) + return client + } + + const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj) + + const needleOptions = (/needle<([^>]*)>/.exec(process.env.DEBUG || '') || [])[1] + + function invoke (req) { + try { + if (needleOptions) req = Object.assign({}, req, JSON.parse(needleOptions)) + } catch (err) { + console.err(`Ignoring invalid needle options: ${needleOptions}`) + } + return wsk.actions.invoke(req) + } + + function fork ({ p, node, index }, array, it) { + const saved = p.params // save params + p.s.state = index + node.return // return state + p.params = { value: [] } // return value + if (array.length === 0) return + if (typeof p.s.redis !== 'object' || typeof p.s.redis.uri !== 'string' || (typeof p.s.redis.ca !== 'string' && typeof p.s.redis.ca !== 'undefined')) { + p.params = { error: 'Parallel combinator requires a properly configured redis instance' } + console.error(p.params.error) + return + } + const stack = [{ marker: true }].concat(p.s.stack) + const barrierId = require(/* webpackIgnore: true */ 'uuid').v4() + console.log(`barrierId: ${barrierId}, spawning: ${array.length}`) + if (!wsk) wsk = openwhisk(p.s.openwhisk) + if (!db) db = createRedisClient(p) + return db.lpushAsync(live(barrierId), 42) // push marker + .then(() => db.expireAsync(live(barrierId), expiration)) + .then(() => Promise.all(array.map((item, position) => { + const params = it(saved, item) // obtain combinator-specific params for branch invocation + params.$composer.stack = stack + params.$composer.redis = p.s.redis + params.$composer.openwhisk = p.s.openwhisk + params.$composer.join = { barrierId, position, count: array.length } + return invoke({ name: process.env.__OW_ACTION_NAME, params }) // invoke branch + .then(({ activationId }) => { console.log(`barrierId: ${barrierId}, spawned position: ${position} with activationId: ${activationId}`) }) + }))).then(() => collect(p, barrierId), error => { + console.error(error.body || error) + p.params = { error: `Parallel combinator failed to invoke a composition at AST node root${node.parent} (see log for details)` } + return db.delAsync(live(barrierId), done(barrierId)) // delete keys + .then(() => { + inspect(p) + return step(p) + }) + }) + } + + // compile ast to fsm + const compiler = { + sequence (parent, node) { + return [{ parent, type: 'pass' }, ...compile(parent, ...node.components)] + }, + + action (parent, node) { + return [{ parent, type: 'action', name: node.name }] + }, + + async (parent, node) { + const body = [...compile(parent, ...node.components)] + return [{ parent, type: 'async', return: body.length + 2 }, ...body, { parent, type: 'stop' }, { parent, type: 'pass' }] + }, + + function (parent, node) { + return [{ parent, type: 'function', exec: node.function.exec }] + }, + + finally (parent, node) { + const finalizer = compile(parent, node.finalizer) + const fsm = [{ parent, type: 'try' }, ...compile(parent, node.body), { parent, type: 'exit' }, ...finalizer] + fsm[0].catch = fsm.length - finalizer.length + return fsm + }, + + let (parent, node) { + return [{ parent, type: 'let', let: node.declarations }, ...compile(parent, ...node.components), { parent, type: 'exit' }] + }, + + mask (parent, node) { + return [{ parent, type: 'let', let: null }, ...compile(parent, ...node.components), { parent, type: 'exit' }] + }, + + try (parent, node) { + const handler = [...compile(parent, node.handler), { parent, type: 'pass' }] + const fsm = [{ parent, type: 'try' }, ...compile(parent, node.body), { parent, type: 'exit' }, ...handler] + fsm[0].catch = fsm.length - handler.length + fsm[fsm.length - handler.length - 1].next = handler.length + return fsm + }, + + if_nosave (parent, node) { + const consequent = compile(parent, node.consequent) + const alternate = [...compile(parent, node.alternate), { parent, type: 'pass' }] + const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.test), { parent, type: 'choice', then: 1, else: consequent.length + 1 }, ...consequent, ...alternate] + fsm[fsm.length - alternate.length - 1].next = alternate.length + return fsm + }, + + while_nosave (parent, node) { + const body = compile(parent, node.body) + const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.test), { parent, type: 'choice', then: 1, else: body.length + 1 }, ...body, { parent, type: 'pass' }] + fsm[fsm.length - 2].next = 2 - fsm.length + return fsm + }, + + dowhile_nosave (parent, node) { + const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.body), ...compile(parent, node.test), { parent, type: 'choice', else: 1 }, { parent, type: 'pass' }] + fsm[fsm.length - 2].then = 2 - fsm.length + return fsm + }, + + parallel (parent, node) { + const tasks = node.components.map(task => [...compile(parent, task), { parent, type: 'stop' }]) + const fsm = [{ parent, type: 'parallel' }, ...tasks.reduce((acc, cur) => { acc.push(...cur); return acc }, []), { parent, type: 'pass' }] + fsm[0].return = fsm.length - 1 + fsm[0].tasks = tasks.reduce((acc, cur) => { acc.push(acc[acc.length - 1] + cur.length); return acc }, [1]).slice(0, -1) + return fsm + }, + + map (parent, node) { + const tasks = compile(parent, ...node.components) + return [{ parent, type: 'map', return: tasks.length + 2 }, ...tasks, { parent, type: 'stop' }, { parent, type: 'pass' }] + }, + + dynamic (parent, node) { + return [{ parent, type: 'dynamic' }] + } + } + + function compile (parent, node) { + if (arguments.length === 1) return [{ parent, type: 'empty' }] + if (arguments.length === 2) { + const fsm = compiler[node.type](node.path || parent, node) + if (node.path !== undefined) fsm[0].path = node.path + return fsm + } + return Array.prototype.slice.call(arguments, 1).reduce((fsm, node) => { fsm.push(...compile(parent, node)); return fsm }, []) + } + + const fsm = compile('', composition) + + const conductor = { + choice ({ p, node, index }) { + p.s.state = index + (p.params.value ? node.then : node.else) + }, + + try ({ p, node, index }) { + p.s.stack.unshift({ catch: index + node.catch }) + }, + + let ({ p, node, index }) { + p.s.stack.unshift({ let: JSON.parse(JSON.stringify(node.let)) }) + }, + + exit ({ p, node, index }) { + if (p.s.stack.length === 0) return internalError(`pop from an empty stack`) + p.s.stack.shift() + }, + + action ({ p, node, index }) { + return { method: 'action', action: node.name, params: p.params, state: { $composer: p.s } } + }, + + function ({ p, node, index }) { + return Promise.resolve().then(() => run(node.exec.code, p)) + .catch(error => { + console.error(error) + return { error: `Function combinator threw an exception at AST node root${node.parent} (see log for details)` } + }) + .then(result => { + if (typeof result === 'function') result = { error: `Function combinator evaluated to a function type at AST node root${node.parent}` } + // if a function has only side effects and no return value, return params + p.params = JSON.parse(JSON.stringify(result === undefined ? p.params : result)) + inspect(p) + return step(p) + }) + }, + + empty ({ p, node, index }) { + inspect(p) + }, + + pass ({ p, node, index }) { + }, + + async ({ p, node, index, inspect, step }) { + p.params.$composer = { state: p.s.state, stack: [{ marker: true }].concat(p.s.stack), redis: p.s.redis, openwhisk: p.s.openwhisk } + p.s.state = index + node.return + if (!wsk) wsk = openwhisk(p.s.openwhisk) + return invoke({ name: process.env.__OW_ACTION_NAME, params: p.params }) + .then(response => ({ method: 'async', activationId: response.activationId, sessionId: p.s.session }), error => { + console.error(error) // invoke failed + return { error: `Async combinator failed to invoke composition at AST node root${node.parent} (see log for details)` } + }) + .then(result => { + p.params = result + inspect(p) + return step(p) + }) + }, + + stop ({ p, node, index, inspect, step }) { + p.s.state = -1 + }, + + parallel ({ p, node, index }) { + return fork({ p, node, index }, node.tasks, (input, branch) => { + const params = Object.assign({}, input) // clone + params.$composer = { state: index + branch } + return params + }) + }, + + map ({ p, node, index }) { + return fork({ p, node, index }, p.params.value || [], (input, branch) => { + const params = isObject(branch) ? branch : { value: branch } // wrap + params.$composer = { state: index + 1 } + return params + }) + }, + + dynamic ({ p, node, index }) { + if (p.params.type !== 'action' || typeof p.params.name !== 'string' || typeof p.params.params !== 'object') { + p.params = { error: `Incorrect use of the dynamic combinator at AST node root${node.parent}` } + inspect(p) + } else { + return { method: 'action', action: p.params.name, params: p.params.params, state: { $composer: p.s } } + } + } + } + + function finish (p) { + return p.params.error ? p.params : { params: p.params } + } + + function collect (p, barrierId) { + if (!db) db = createRedisClient(p) + const timeout = Math.max(Math.floor((process.env.__OW_DEADLINE - new Date()) / 1000) - 5, 1) + console.log(`barrierId: ${barrierId}, waiting with timeout: ${timeout}s`) + return db.brpopAsync(done(barrierId), timeout) // pop marker + .then(marker => { + console.log(`barrierId: ${barrierId}, done waiting`) + if (marker !== null) { + return db.lrangeAsync(done(barrierId), 0, -1) + .then(result => result.map(JSON.parse).map(({ position, params }) => { p.params.value[position] = params })) + .then(() => db.delAsync(live(barrierId), done(barrierId))) // delete keys + .then(() => { + inspect(p) + return step(p) + }) + } else { // timeout + p.s.collect = barrierId + console.log(`barrierId: ${barrierId}, handling timeout`) + return { method: 'action', action: '/whisk.system/utils/echo', params: p.params, state: { $composer: p.s } } + } + }) + } + + const internalError = error => Promise.reject(error) // terminate composition execution and record error + + // wrap params if not a dictionary, branch to error handler if error + function inspect (p) { + if (!isObject(p.params)) p.params = { value: p.params } + if (p.params.error !== undefined) { + p.params = { error: p.params.error } // discard all fields but the error field + p.s.state = -1 // abort unless there is a handler in the stack + while (p.s.stack.length > 0 && !p.s.stack[0].marker) { + if ((p.s.state = p.s.stack.shift().catch || -1) >= 0) break + } + } + } + + // run function f on current stack + function run (f, p) { + // handle let/mask pairs + const view = [] + let n = 0 + for (let frame of p.s.stack) { + if (frame.let === null) { + n++ + } else if (frame.let !== undefined) { + if (n === 0) { + view.push(frame) + } else { + n-- + } + } + } + + // update value of topmost matching symbol on stack if any + function set (symbol, value) { + const element = view.find(element => element.let !== undefined && element.let[symbol] !== undefined) + if (element !== undefined) element.let[symbol] = JSON.parse(JSON.stringify(value)) + } + + // collapse stack for invocation + const env = view.reduceRight((acc, cur) => cur.let ? Object.assign(acc, cur.let) : acc, {}) + let main = '(function(){try{const require=arguments[2];' + for (const name in env) main += `var ${name}=arguments[1]['${name}'];` + main += `return eval((function(){return(${f})})())(arguments[0])}finally{` + for (const name in env) main += `arguments[1]['${name}']=${name};` + main += '}})' + try { + return (1, eval)(main)(p.params, env, require) + } finally { + for (const name in env) set(name, env[name]) + } + } + + function step (p) { + // final state, return composition result + if (p.s.state < 0 || p.s.state >= fsm.length) { + console.log(`Entering final state`) + console.log(JSON.stringify(p.params)) + if (p.s.join) { + if (!db) db = createRedisClient(p) + return db.lpushxAsync(live(p.s.join.barrierId), JSON.stringify({ position: p.s.join.position, params: p.params })).then(count => { // push only if marker is present + return (count > p.s.join.count ? db.renameAsync(live(p.s.join.barrierId), done(p.s.join.barrierId)) : Promise.resolve()) + }).then(() => { + p.params = { method: 'join', sessionId: p.s.session, barrierId: p.s.join.barrierId, position: p.s.join.position } + }) + } + return + } + + // process one state + const node = fsm[p.s.state] // json definition for index state + if (node.path !== undefined) console.log(`Entering composition${node.path}`) + const index = p.s.state // current state + p.s.state = p.s.state + (node.next || 1) // default next state + if (typeof conductor[node.type] !== 'function') return internalError(`unexpected "${node.type}" combinator`) + return conductor[node.type]({ p, index, node, inspect, step }) || step(p) + } + + // do invocation + return (params) => { + // extract parameters + const $composer = params.$composer || {} + delete params.$composer + $composer.session = $composer.session || process.env.__OW_ACTIVATION_ID + + // current state + const p = { s: Object.assign({ state: 0, stack: [], resuming: true }, $composer), params } + + // step and catch all errors + return Promise.resolve().then(() => { + if (typeof p.s.state !== 'number') return internalError('state parameter is not a number') + if (!Array.isArray(p.s.stack)) return internalError('stack parameter is not an array') + + if (p.s.collect) { // waiting on parallel branches + const barrierId = p.s.collect + delete p.s.collect + return collect(p, barrierId) + } + + if ($composer.resuming) inspect(p) // handle error objects when resuming + + return step(p) + }).catch(error => { + const message = (typeof error.error === 'string' && error.error) || error.message || (typeof error === 'string' && error) + p.params = { error: message ? `Internal error: ${message}` : 'Internal error' } + }).then(params => params || finish(p)) // params is defined iff execution will be resumed + } +} diff --git a/docs/COMBINATORS.md b/docs/COMBINATORS.md index c72530b..76017f5 100644 --- a/docs/COMBINATORS.md +++ b/docs/COMBINATORS.md @@ -1,27 +1,53 @@ + + # Combinators The `composer` module offers a number of combinators to define compositions: | Combinator | Description | Example | -| --:| --- | --- | -| [`action`](#action) | action | `composer.action('echo')` | -| [`function`](#function) | function | `composer.function(({ x, y }) => ({ product: x * y }))` | -| [`literal` or `value`](#literal) | constant value | `composer.literal({ message: 'Hello, World!' })` | -| [`composition`](#composition) | named composition | `composer.composition('myCompositionName', myComposition)` | +| --:| --- | --- | +| [`action`](#action) | named action | `composer.action('echo')` | +| [`async`](#async) | asynchronous invocation | `composer.async('compress', 'upload')` | +| [`dowhile` and `dowhile_nosave`](#dowhile) | loop at least once | `composer.dowhile('fetchData', 'needMoreData')` | +| [`dynamic`](#dynamic) | dynamic invocation | `composer.dynamic()` | [`empty`](#empty) | empty sequence | `composer.empty()` -| [`sequence` or `seq`](#sequence) | sequence | `composer.sequence('hello', 'bye')` | +| [`finally`](#finally) | finalization | `composer.finally('tryThis', 'doThatAlways')` | +| [`function`](#function) | JavaScript function | `composer.function(({ x, y }) => ({ product: x * y }))` | +| [`if` and `if_nosave`](#if) | conditional | `composer.if('authenticate', 'success', 'failure')` | | [`let`](#let) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)` | +| [`literal` or `value`](#literal) | constant value | `composer.literal({ message: 'Hello, World!' })` | +| [`map`](#map) | parallel map | `composer.map('validate', 'compute')` | | [`mask`](#mask) | variable hiding | `composer.let({ n }, composer.while(_ => n-- > 0, composer.mask(composition)))` | -| [`if` and `if_nosave`](#if) | conditional | `composer.if('authenticate', 'success', 'failure')` | -| [`while` and `while_nosave`](#while) | loop | `composer.while('notEnough', 'doMore')` | -| [`dowhile` and `dowhile_nosave`](#dowhile) | loop at least once | `composer.dowhile('fetchData', 'needMoreData')` | +| [`merge`](#merge) | data augmentation | `composer.merge('hash')` | +| [`parallel` or `par`](#parallel) | parallel composition | `composer.parallel('compress', 'hash')` | | [`repeat`](#repeat) | counted loop | `composer.repeat(3, 'hello')` | -| [`try`](#try) | error handling | `composer.try('divideByN', 'NaN')` | -| [`finally`](#finally) | finalization | `composer.finally('tryThis', 'doThatAlways')` | -| [`retry`](#retry) | error recovery | `composer.retry(3, 'connect')` | | [`retain` and `retain_catch`](#retain) | persistence | `composer.retain('validateInput')` | +| [`retry`](#retry) | error recovery | `composer.retry(3, 'connect')` | +| [`sequence` or `seq`](#sequence) | sequence | `composer.sequence('hello', 'bye')` | +| [`task`](#task) | single task | `composer.task('echo')` +| [`try`](#try) | error handling | `composer.try('divideByN', 'NaN')` | +| [`while` and `while_nosave`](#while) | loop | `composer.while('notEnough', 'doMore')` | -The `action`, `function`, and `literal` combinators construct compositions respectively from actions, functions, and constant values. The other combinators combine existing compositions to produce new compositions. +The `action`, `function`, and `literal` combinators construct compositions +respectively from OpenWhisk actions, JavaScript functions, and constant values. +The other combinators combine existing compositions to produce new compositions. ## Shorthands @@ -30,23 +56,32 @@ Where a composition is expected, the following shorthands are permitted: - `fun` of type `function` stands for `composer.function(fun)`, - `null` stands for the empty sequence `composer.empty()`. -## Primitive combinators - -Some of these combinators are _derived_ combinators: they are equivalent to combinations of other combinators. The `composer` module offers a `composer.lower` method (see [COMPOSER.md](#COMPOSER.md)) that can eliminate derived combinators from a composition, producing an equivalent composition made only of _primitive_ combinators. The primitive combinators are: `action`, `function`, `composition`, `sequence`, `let`, `mask`, `if_nosave`, `while_nosave`, `dowhile_nosave`, `try`, and `finally`. - ## Action -`composer.action(name, [options])` is a composition with a single action named _name_. It invokes the action named _name_ on the input parameter object for the composition and returns the output parameter object of this action invocation. +`composer.action(name, [options])` is a composition with a single action named +_name_. It invokes the action named _name_ on the input parameter object for the +composition and returns the output parameter object of this action invocation. -The action _name_ may specify the namespace and/or package containing the action following the usual OpenWhisk grammar. If no namespace is specified, the default namespace is assumed. If no package is specified, the default package is assumed. +The action _name_ may specify the namespace and/or package containing the action +following the usual OpenWhisk grammar. If no namespace is specified, the default +namespace is assumed. If no package is specified, the default package is +assumed. Examples: ```javascript -composer.action('hello') +composer.action('hello') // default package composer.action('myPackage/myAction') composer.action('/whisk.system/utils/echo') ``` -The optional `options` dictionary makes it possible to provide a definition for the action being composed. +To be clear, if no package is specified, the default package is assumed even if +the composition itself is not deployed to the default package. To invoke an +action from the same package as the composition the [`dynamic`](#dynamic) +combinator may be used as illustrated [below](#example). + +### Action definition + +The optional `options` dictionary makes it possible to provide a definition for +the action being composed. ```javascript // specify the code for the action as a function composer.action('hello', { action: function () { return { message: 'hello' } } }) @@ -60,7 +95,6 @@ composer.action('hello', { action: hello }) // specify the code for the action as a string composer.action('hello', { action: "const message = 'hello'; function main() { return { message } }" }) - // specify the code and runtime for the action composer.action('hello', { action: { @@ -75,16 +109,34 @@ composer.action('hello', { filename: 'hello.js' }) // specify a sequence of actions composer.action('helloAndBye', { sequence: ['hello', 'bye'] }) ``` -The action may de defined by providing the code for the action as a string, as a Javascript function, or as a file name. Alternatively, a sequence action may be defined by providing the list of sequenced actions. The code (specified as a string) may be annotated with the kind of the action runtime. +The action may be defined by providing the code for the action as a string, as a +JavaScript function, or as a file name. Alternatively, a sequence action may be +defined by providing the list of sequenced actions. The code (specified as a +string) may be annotated with the kind of the action runtime. + +### Limits + +If a definition is provided for the action, the `options` dictionary may also +specify `limits`, for instance: +```javascript +composer.action('hello', { filename: 'hello.js', limits: { logs: 1, memory: 128, timeout: 10000 } }) +``` +The `limits` object optionally specifies any combination of: +- the maximum log size LIMIT in MB for the action, +- the maximum memory LIMIT in MB for the action, +- the timeout LIMIT in milliseconds for the action. -### Environment capture +### Environment capture in actions -Javascript functions used to define actions cannot capture any part of their declaration environment. The following code is not correct as the declaration of `name` would not be available at invocation time: +JavaScript functions used to define actions cannot capture any part of their +declaration environment. The following code is not correct as the declaration of +`name` would not be available at invocation time: ```javascript let name = 'Dave' composer.action('hello', { action: function main() { return { message: 'Hello ' + name } } }) ``` -In contrast, the following code is correct as it resolves `name`'s value at composition time. +In contrast, the following code is correct as it resolves `name`'s value at +composition time. ```javascript let name = 'Dave' composer.action('hello', { action: `function main() { return { message: 'Hello ' + '${name}' } }` }) @@ -92,11 +144,21 @@ composer.action('hello', { action: `function main() { return { message: 'Hello ' ## Function -`composer.function(fun)` is a composition with a single Javascript function _fun_. It applies the specified function to the input parameter object for the composition. - - If the function returns a value of type `function`, the composition returns an error object. - - If the function throws an exception, the composition returns an error object. The exception is logged as part of the conductor action invocation. - - If the function returns a value of type other than function, the value is first converted to a JSON value using `JSON.stringify` followed by `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a `{ value }` dictionary. The composition returns the final JSON dictionary. - - If the function does not return a value and does not throw an exception, the composition returns the input parameter object for the composition converted to a JSON dictionary using `JSON.stringify` followed by `JSON.parse`. +`composer.function(fun)` is a composition with a single JavaScript function +_fun_. It applies the specified function to the input parameter object for the +composition. + - If the function returns a value of type `function`, the composition returns + an error object. + - If the function throws an exception, the composition returns an error object. + The exception is logged as part of the conductor action invocation. + - If the function returns a value of type other than function, the value is + first converted to a JSON value using `JSON.stringify` followed by + `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON + value is then wrapped into a `{ value }` dictionary. The composition returns + the final JSON dictionary. + - If the function does not return a value and does not throw an exception, the + composition returns the input parameter object for the composition converted + to a JSON dictionary using `JSON.stringify` followed by `JSON.parse`. Examples: ```javascript @@ -107,162 +169,396 @@ function product({ x, y }) { return { product: x * y } } composer.function(product) ``` -### Environment capture +### Environment capture in functions -Functions intended for compositions cannot capture any part of their declaration environment. They may however access and mutate variables in an environment consisting of the variables declared by the [composer.let](#composerletname-value-composition_1-composition_2-) combinator discussed below. +Functions intended for compositions cannot capture any part of their declaration +environment. They may however access and mutate variables in an environment +consisting of the variables declared by the [let](#let) combinator discussed +below. -The following is not legal: +The following code is not correct: ```javascript let name = 'Dave' composer.function(params => ({ message: 'Hello ' + name })) ``` -The following is legal: +The following code is correct: ```javascript composer.let({ name: 'Dave' }, composer.function(params => ({ message: 'Hello ' + name }))) ``` ## Literal -`composer.literal(value)` and its synonymous `composer.value(value)` output a constant JSON dictionary. This dictionary is obtained by first converting the _value_ argument to JSON using `JSON.stringify` followed by `JSON.parse`. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a `{ value }` dictionary. +`composer.literal(value)` and its synonymous `composer.value(value)` output a +constant JSON dictionary. This dictionary is obtained by first converting the +_value_ argument to JSON using `JSON.stringify` followed by `JSON.parse`. If the +resulting JSON value is not a JSON dictionary, the JSON value is then wrapped +into a `{ value }` dictionary. -The _value_ argument may be computed at composition time. For instance, the following composition captures the date at the time the composition is encoded to JSON: +The _value_ argument may be computed at composition time. For instance, the +following composition captures the date at the time the composition is encoded +to JSON: ```javascript composer.sequence( composer.literal(Date()), composer.action('log', { action: params => ({ message: 'Composition time: ' + params.value }) })) ``` -JSON values cannot represent functions. Applying `composer.literal` to a value of type `'function'` will result in an error. Functions embedded in a `value` of type `'object'`, e.g., `{ f: p => p, n: 42 }` will be silently omitted from the JSON dictionary. In other words, `composer.literal({ f: p => p, n: 42 })` will output `{ n: 42 }`. - -In general, a function can be embedded in a composition either by using the `composer.function` combinator, or by embedding the source code for the function as a string and later using `eval` to evaluate the function code. - -## Composition +JSON values cannot represent functions. Applying `composer.literal` to a value +of type `'function'` will result in an error. Functions embedded in a `value` of +type `'object'`, e.g., `{ f: p => p, n: 42 }` will be silently omitted from the +JSON dictionary. In other words, `composer.literal({ f: p => p, n: 42 })` will +output `{ n: 42 }`. -`composition(name, composition)` returns a composition consisting of the invocation of the composition named `name` and of the declaration of the composition named `name` defined to be `composition`. +In general, a function can be embedded in a composition either by using the +`composer.function` combinator, or by embedding the source code for the function +as a string and later using `eval` to evaluate the function code. -```javascript -composer.if('isEven', 'half', composer.composition('tripleAndIncrement', composer.sequence('triple', 'increment'))) -``` -In this example, the `composer.sequence('triple', 'increment')` composition is given the name `tripleAndIncrement` and the enclosing composition references the `tripleAndIncrement` composition by name. In particular, deploying this composition actually deploys two compositions: -- a composition named `tripleAndIncrement` defined as `composer.sequence('triple', 'increment')`, and -- a composition defined as `composer.if('isEven', 'half', 'tripleAndIncrement')` whose name will be specified as deployment time. +## Sequence -Importantly, the behavior of the second composition would be altered if we redefine the `tripleAndIncrement` composition to do something else, since it refers to the composition by name. +`composer.sequence(composition_1, composition_2, ...)` or it synonymous +`composer.seq(composition_1, composition_2, ...)` chain a series of compositions +(possibly empty). -## Empty +The input parameter object for the composition is the input parameter object of +the first composition in the sequence. The output parameter object of one +composition in the sequence is the input parameter object for the next +composition in the sequence. The output parameter object of the last composition +in the sequence is the output parameter object for the composition. -`composer.empty()` is a shorthand for the empty sequence `composer.sequence()`. It is typically used to make it clear that a composition, e.g., a branch of an `if` combinator, is intentionally doing nothing. +If one of the components fails (i.e., returns an error object), the remainder of +the sequence is not executed. The output parameter object for the composition is +the error object produced by the failed component. -## Sequence +An empty sequence behaves as a sequence with a single function `params => +params`. The output parameter object for the empty sequence is its input +parameter object unless it is an error object, in which case, as usual, the +error object only contains the `error` field of the input parameter object. -`composer.sequence(composition_1, composition_2, ...)` chains a series of compositions (possibly empty). +## Empty -The input parameter object for the composition is the input parameter object of the first composition in the sequence. The output parameter object of one composition in the sequence is the input parameter object for the next composition in the sequence. The output parameter object of the last composition in the sequence is the output parameter object for the composition. +`composer.empty()` is a shorthand for the empty sequence `composer.sequence()`. +It is typically used to make it clear that a composition, e.g., a branch of an +`if` combinator, is intentionally doing nothing. -If one of the components fails (i.e., returns an error object), the remainder of the sequence is not executed. The output parameter object for the composition is the error object produced by the failed component. +## Task -An empty sequence behaves as a sequence with a single function `params => params`. The output parameter object for the empty sequence is its input parameter object unless it is an error object, in which case, as usual, the error object only contains the `error` field of the input parameter object. +`composer.task(composition)` is equivalent to `composer.sequence(composition)`. ## Let -`composer.let({ name_1: value_1, name_2: value_2, ... }, composition_1_, _composition_2_, ...)` declares one or more variables with the given names and initial values, and runs a sequence of compositions in the scope of these declarations. - -The initial values must be valid JSON values. In particular, `composer.let({ foo: undefined })` is incorrect as `undefined` is not representable by a JSON value. On the other hand, `composer.let({ foo: null })` is correct. For the same reason, initial values cannot be functions, e.g., `composer.let({ foo: params => params })` is incorrect. - -Variables declared with `composer.let` may be accessed and mutated by functions __running__ as part of the following sequence (irrespective of their place of definition). In other words, name resolution is [dynamic](https://en.wikipedia.org/wiki/Name_resolution_(programming_languages)#Static_versus_dynamic). If a variable declaration is nested inside a declaration of a variable with the same name, the innermost declaration masks the earlier declarations. - -For example, the following composition invokes composition `composition` repeatedly `n` times. +`composer.let({ name_1: value_1, name_2: value_2, ... }, composition_1, +composition_2, ...)` declares one or more variables with the given names and +initial values, and runs a sequence of compositions in the scope of these +declarations. + +The initial values must be valid JSON values. In particular, `composer.let({foo: +undefined }, composition)` is incorrect as `undefined` is not representable by a +JSON value. Use `composer.let({ foo: null }, composition)` instead. For the same +reason, initial values cannot be functions, e.g., `composer.let({ foo: params => +params }, composition)` is incorrect. + +Variables declared with `composer.let` may be accessed and mutated by functions +__running__ as part of the following sequence (irrespective of their place of +definition). In other words, name resolution is +[dynamic](https://en.wikipedia.org/wiki/Name_resolution_(programming_languages)#Static_versus_dynamic). +If a variable declaration is nested inside a declaration of a variable with the +same name, the innermost declaration masks the earlier declarations. + +For example, the following composition invokes composition `composition` +repeatedly `n` times. ```javascript composer.let({ i: n }, composer.while(() => i-- > 0, composition)) ``` -Variables declared with `composer.let` are not visible to invoked actions. However, they may be passed as parameters to actions as for instance in: +Variables declared with `composer.let` are not visible to invoked actions. +However, they may be passed as parameters to actions as for instance in: ```javascript composer.let({ n: 42 }, () => ({ n }), 'increment', params => { n = params.n }) ``` -In this example, the variable `n` is exposed to the invoked action as a field of the input parameter object. Moreover, the value of the field `n` of the output parameter object is assigned back to variable `n`. +In this example, the variable `n` is exposed to the invoked action as a field of +the input parameter object. Moreover, the value of the field `n` of the output +parameter object is assigned back to variable `n`. ## Mask -`composer.mask(composition)` is meant to be used in combination with the `let` combinator. It makes it possible to hide the innermost enclosing `let` combinator from _composition_. It is typically used to define composition templates that need to introduce variables. +`composer.mask(composition_1, composition_2, ...)` is meant to be used in +combination with the `let` combinator. It runs a sequence of compositions +excluding from their scope the variables declared by the innermost enclosing +`let`. It is typically used to define composition templates that need to +introduce variables. -For instance, the following function is a possible implementation of a repeat loop: +For instance, the following function is a possible implementation of a repeat +loop: ```javascript function loop(n, composition) { - return .let({ n }, composer.while(() => n-- > 0, composer.mask(composition))) + return composer.let({ n }, composer.while(() => n-- > 0, composer.mask(composition))) } ``` -This function takes two parameters: the number of iterations _n_ and the _composition_ to repeat _n_ times. Here, the `mask` combinator makes sure that this declaration of _n_ is not visible to _composition_. Thanks to `mask`, the following example correctly returns `{ value: 12 }`. +This function takes two parameters: the number of iterations _n_ and the +_composition_ to repeat _n_ times. Here, the `mask` combinator makes sure that +this declaration of _n_ is not visible to _composition_. Thanks to `mask`, the +following example correctly returns `{ value: 12 }`. ```javascript composer.let({ n: 0 }, loop(3, loop(4, () => ++n))) ``` -While composer variables are dynamically scoped, the `mask` combinator alleviates the biggest concern with dynamic scoping: incidental name collision. +While composer variables are dynamically scoped, judicious use of the `mask` +combinator can prevent incidental name collision. ## If -`composer.if(condition, consequent, [alternate])` runs either the _consequent_ composition if the _condition_ evaluates to true or the _alternate_ composition if not. - -A _condition_ composition evaluates to true if and only if it produces a JSON dictionary with a field `value` with value `true`. Other fields are ignored. Because JSON values other than dictionaries are implicitly lifted to dictionaries with a `value` field, _condition_ may be a Javascript function returning a Boolean value. An expression such as `params.n > 0` is not a valid condition (or in general a valid composition). One should write instead `params => params.n > 0`. The input parameter object for the composition is the input parameter object for the _condition_ composition. - -The _alternate_ composition may be omitted. If _condition_ fails, neither branch is executed. - -The output parameter object of the _condition_ composition is discarded, one the choice of a branch has been made and the _consequent_ composition or _alternate_ composition is invoked on the input parameter object for the composition. For example, the following composition divides parameter `n` by two if `n` is even: +`composer.if(condition, consequent, [alternate])` runs either the _consequent_ +composition if the _condition_ evaluates to true or the _alternate_ composition +if not. + +A _condition_ composition evaluates to true if and only if it produces a JSON +dictionary with a field `value` with value `true`. Other fields are ignored. +Because JSON values other than dictionaries are implicitly lifted to +dictionaries with a `value` field, _condition_ may be a JavaScript function +returning a Boolean value. An expression such as `params.n > 0` is not a valid +condition (or in general a valid composition). One should write instead `params +=> params.n > 0`. The input parameter object for the composition is the input +parameter object for the _condition_ composition. + +The _alternate_ composition may be omitted. If _condition_ fails, neither branch +is executed. + +The output parameter object of the _condition_ composition is discarded, one the +choice of a branch has been made and the _consequent_ composition or _alternate_ +composition is invoked on the input parameter object for the composition. For +example, the following composition divides parameter `n` by two if `n` is even: ```javascript composer.if(params => params.n % 2 === 0, params => { params.n /= 2 }) ``` -The `if_nosave` combinator is similar but it does not preserve the input parameter object, i.e., the _consequent_ composition or _alternate_ composition is invoked on the output parameter object of _condition_. The following example also divides parameter `n` by two if `n` is even: +The `if_nosave` combinator is similar but it does not preserve the input +parameter object, i.e., the _consequent_ composition or _alternate_ composition +is invoked on the output parameter object of _condition_. The following example +also divides parameter `n` by two if `n` is even: ```javascript composer.if_nosave(params => { params.value = params.n % 2 === 0 }, params => { params.n /= 2 }) ``` -In the first example, the condition function simply returns a Boolean value. The consequent function uses the saved input parameter object to compute `n`'s value. In the second example, the condition function adds a `value` field to the input parameter object. The consequent function applies to the resulting object. In particular, in the second example, the output parameter object for the condition includes the `value` field. - -While, the `if` combinator is typically more convenient, preserving the input parameter object is not free as it counts toward the parameter size limit for OpenWhisk actions. In essence, the limit on the size of parameter objects processed during the evaluation of the condition is reduced by the size of the saved parameter object. The `if_nosave` combinator omits the parameter save, hence preserving the parameter size limit. +In the first example, the condition function simply returns a Boolean value. The +consequent function uses the saved input parameter object to compute `n`'s +value. In the second example, the condition function adds a `value` field to the +input parameter object. The consequent function applies to the resulting object. +In particular, in the second example, the output parameter object for the +condition includes the `value` field. + +While, the `if` combinator is typically more convenient, preserving the input +parameter object is not free as it counts toward the parameter size limit for +OpenWhisk actions. In essence, the limit on the size of parameter objects +processed during the evaluation of the condition is reduced by the size of the +saved parameter object. The `if_nosave` combinator omits the parameter save, +hence preserving the parameter size limit. ## While -`composer.while(condition, body)` runs _body_ repeatedly while _condition_ evaluates to true. The _condition_ composition is evaluated before any execution of the _body_ composition. See [composer.if](#composerifcondition-consequent-alternate) for a discussion of conditions. - -A failure of _condition_ or _body_ interrupts the execution. The composition returns the error object from the failed component. - -The output parameter object of the _condition_ composition is discarded and the input parameter object for the _body_ composition is either the input parameter object for the whole composition the first time around or the output parameter object of the previous iteration of _body_. However, if `while_nosave` combinator is used, the input parameter object for _body_ is the output parameter object of _condition_. Moreover, the output parameter object for the whole composition is the output parameter object of the last _condition_ evaluation. - -For instance, the following composition invoked on dictionary `{ n: 28 }` returns `{ n: 7 }`: +`composer.while(condition, body)` runs _body_ repeatedly while _condition_ +evaluates to true. The _condition_ composition is evaluated before any execution +of the _body_ composition. See +[composer.if](#composerifcondition-consequent-alternate) for a discussion of +conditions. + +A failure of _condition_ or _body_ interrupts the execution. The composition +returns the error object from the failed component. + +The output parameter object of the _condition_ composition is discarded and the +input parameter object for the _body_ composition is either the input parameter +object for the whole composition the first time around or the output parameter +object of the previous iteration of _body_. However, if `while_nosave` +combinator is used, the input parameter object for _body_ is the output +parameter object of _condition_. Moreover, the output parameter object for the +whole composition is the output parameter object of the last _condition_ +evaluation. + +For instance, the following composition invoked on dictionary `{ n: 28 }` +returns `{ n: 7 }`: ```javascript composer.while(params => params.n % 2 === 0, params => { params.n /= 2 }) ``` -For instance, the following composition invoked on dictionary `{ n: 28 }` returns `{ n: 7, value: false }`: +For instance, the following composition invoked on dictionary `{ n: 28 }` +returns `{ n: 7, value: false }`: ```javascript composer.while_nosave(params => { params.value = params.n % 2 === 0 }, params => { params.n /= 2 }) ``` ## Dowhile -`composer.dowhile(condition, body)` is similar to `composer.while(body, condition)` except that _body_ is invoked before _condition_ is evaluated, hence _body_ is always invoked at least once. +`composer.dowhile(condition, body)` is similar to `composer.while(body, +condition)` except that _body_ is invoked before _condition_ is evaluated, hence +_body_ is always invoked at least once. -Like `while_nosave`, `dowhile_nosave` does not implicitly preserve the parameter object while evaluating _condition_. +Like `while_nosave`, `dowhile_nosave` does not implicitly preserve the parameter +object while evaluating _condition_. ## Repeat -`composer.repeat(count, body)` invokes _body_ _count_ times. +`composer.repeat(count, composition_1, composition_2, ...)` invokes a sequence +of compositions _count_ times. ## Try `composer.try(body, handler)` runs _body_ with error handler _handler_. -If _body_ returns an error object, _handler_ is invoked with this error object as its input parameter object. Otherwise, _handler_ is not run. +If _body_ returns an error object, _handler_ is invoked with this error object +as its input parameter object. Otherwise, _handler_ is not run. ## Finally `composer.finally(body, finalizer)` runs _body_ and then _finalizer_. -The _finalizer_ is invoked in sequence after _body_ even if _body_ returns an error object. +The _finalizer_ is invoked in sequence after _body_ even if _body_ returns an +error object. The output parameter object of _body_ (error object or not) is the +input parameter object of _finalizer_. ## Retry -`composer.retry(count, body)` runs _body_ and retries _body_ up to _count_ times if it fails. The output parameter object for the composition is either the output parameter object of the successful _body_ invocation or the error object produced by the last _body_ invocation. +`composer.retry(count, composition_1, composition_2, ...)` runs a sequence of +compositions retrying the sequence up to _count_ times if it fails. The output +parameter object for the composition is either the output parameter object of +the successful sequence invocation or the error object produced by the last +sequence invocation. ## Retain -`composer.retain(body)` runs _body_ on the input parameter object producing an object with two fields `params` and `result` such that `params` is the input parameter object of the composition and `result` is the output parameter object of _body_. +`composer.retain(composition_1, composition_2, ...)` runs a sequence of +compositions on the input parameter object producing an object with two fields +`params` and `result` such that `params` is the input parameter object of the +composition and `result` is the output parameter object of the sequence. + +If the sequence fails, the output of the `retain` combinator is only the error +object (i.e., the input parameter object is not preserved). In contrast, the +`retain_catch` combinator always outputs `{ params, result }`, even if `result` +is an error object. + +## Merge + +`composer.merge(composition_1, composition_2, ...)` runs a sequence of +compositions on the input parameter object and merge the output parameter object +of the sequence into the input parameter object. In other words, +`composer.merge(composition_1, composition_2, ...)` is a shorthand for: +``` +composer.seq(composer.retain(composition_1, composition_2, ...), ({ params, result }) => Object.assign(params, result)) +``` + +## Async + +The `async` combinator may require an SSL configuration as discussed +[here](../README.md#openwhisk-ssl-configuration). + +`composer.async(composition_1, composition_2, ...)` runs a sequence of +compositions asynchronously. It invokes the sequence but does not wait for it to +execute. It immediately returns a dictionary that includes a field named +`activationId` with the activation id for the sequence invocation. + +The spawned sequence operates on a copy of the execution context for the parent +composition. Variables declared in the parent are defined for the child and are +initialized with the parent values at the time of the `async`. But mutations or +later declarations in the parent are not visible in the child and vice versa. + +## Parallel + +Parallel combinators require access to a Redis instance as discussed +[here](../README.md#parallel-compositions-with-redis). + +Parallel combinators may require an SSL configuration as discussed +[here](../README.md#openwhisk-ssl-configuration). + +`composer.parallel(composition_1, composition_2, ...)` and its synonymous +`composer.par(composition_1, composition_2, ...)` invoke a series of +compositions (possibly empty) in parallel. + +This combinator runs _composition_1_, _composition_2_, ... in parallel and waits +for all of these compositions to complete. + +The input parameter object for the composition is the input parameter object for +every branch in the composition. The output parameter object for the composition +has a single field named `value` of type array. The elements of the array are +the output parameter objects for the branches in order. + +Error results from the branches are included in the array of results like normal +results. In particular, an error result from a branch does not interrupt the +parallel execution of the other branches. Moreover, since errors results are +nested inside an output parameter object with a single `value` field, an error +from a branch does not trigger the execution of the current error handler. The +caller should walk the array and decide if and how to handle errors. -If _body_ fails, the output of the `retain` combinator is only the error object (i.e., the input parameter object is not preserved). In constrast, the `retain_catch` combinator always outputs `{ params, result }`, even if `result` is an error result. +The `composer.let` variables in scope at the `parallel` combinator are in scope +in the branches. But each branch has its own copy of the execution context. +Variable mutations in one branch are not reflected in other branches or in the +parent composition. + +## Map + +Parallel combinators require access to a Redis instance as discussed +[here](../README.md#parallel-compositions-with-redis). + +Parallel combinators may require an SSL configuration as discussed +[here](../README.md#openwhisk-ssl-configuration). + +`composer.map(composition_1, composition_2, ...)` makes multiple parallel +invocations of a sequence of compositions. + +The input parameter object for the `map` combinator should include an array of +named _value_. The `map` combinator spawns one sequence for each element of this +array. The input parameter object for the nth instance of the sequence is the +nth array element if it is a dictionary or an object with a single field named +`value` with the nth array element as the field value. Fields on the input +parameter object other than the `value` field are discarded. These sequences run +in parallel. The `map` combinator waits for all the sequences to complete. The +output parameter object for the composition has a single field named `value` of +type array. The elements of the array are the output parameter objects for the +branches in order. + +Error results from the branches are included in the array of results like normal +results. In particular, an error result from a branch does not interrupt the +parallel execution of the other branches. Moreover, since errors results are +nested inside an output parameter object with a single `value` field, an error +from a branch does not trigger the execution of the current error handler. The +caller should walk the array and decide if and how to handle errors. + +The `composer.let` variables in scope at the `map` combinator are in scope in +the branches. But each branch has its own copy of the execution context. +Variable mutations in one branch are not reflected in other branches or in the +parent composition. + +## Dynamic + +`composer.dynamic()` invokes an action specified by means of the input parameter +object. + +The input parameter object for the `dynamic` combinator must be a dictionary +including the following three fields: +- a field `type` with string value `"action"`, +- a field `name` of type string, +- a field `params` of type dictionary. +Other fields of the input parameter object are ignored. + +The `dynamic` combinator invokes the action named _name_ with the input +parameter object _params_. The output parameter object for the composition is +the output parameter object of the action invocation. + +### Example + +The `dynamic` combinator may be used for example to invoke an action that +belongs to the same package as the composition, without having to specify the +package name beforehand. + +```javascript +const composer = require('@ibm-functions/composer') + +function invoke (actionShortName) { + return composer.let( + { actionShortName }, + params => ({ type: 'action', params, name: process.env.__OW_ACTION_NAME.split('/').slice(0, -1).concat(actionShortName).join('/') }), + composer.dynamic()) +} + +module.exports = composer.seq( + composer.action('echo'), // echo action from the default package + invoke('echo') // echo action from the same package as the composition +) +``` +In this example, `let` captures the target action short name at compile time +without expanding it to a fully qualified name. Then, at run time, the package +name is obtained from the environment variable `__OW_ACTION_NAME` and combined +with the action short name. Finally, `dynamic` is used to invoke the action. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..34d9379 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,188 @@ + + +# Commands + +The `compose` command compiles composition code to a portable JSON format. The +`deploy` command deploys JSON-encoded compositions. These commands are intended +as a minimal complement to the OpenWhisk CLI. The OpenWhisk CLI already has the +capability to configure, invoke, and delete compositions since these are just +OpenWhisk actions but lacks the capability to create composition actions. The +`compose` and `deploy` commands bridge this gap. They make it possible to deploy +compositions as part of the development cycle or in shell scripts. They do not +replace the OpenWhisk CLI however as they do not duplicate existing OpenWhisk +CLI capabilities. + +## Compose + +``` +compose +``` +``` +Usage: + compose composition.js [flags] +Flags: + --ast only output the ast for the composition + --file write output to a file next to the input file + --js output the conductor action code for the composition + -o FILE write output to FILE + -v, --version output the composer version + --debug LIST comma-separated list of debug flags (when using --js flag) +``` +The `compose` command takes a JavaScript module that exports a composition +object (for example [demo.js](../samples/demo.js)) and compiles this object to a +portable JSON format on the standard output or in file. +``` +compose demo.js -o demo.json +``` +If the `--ast` option is specified, the `compose` command only outputs a JSON +representation of the Abstract Syntax Tree for the composition. + +If the `--js` option is specified, the `compose` command outputs the conductor +action code for the composition instead of the generated JSON. + +If the `-o` option is used, the `compose` command outputs to the specified file. + +If the `--file` option is specified, the `compose` command outputs to a file +next to the input file with a `.json` or `.conductor.js` extension (if the +`--js` option is specified). + +# Deploy + +``` +deploy +``` +``` +Usage: + deploy composition composition.json [flags] +Flags: + -a, --annotation KEY=VALUE add KEY annotation with VALUE + -A, --annotation-file KEY=FILE add KEY annotation with FILE content + --apihost HOST API HOST + --apiversion VERSION API VERSION + --basic force basic authentication + --bearer force bearer token authentication + -i, --insecure bypass certificate checking + --kind KIND the KIND of the conductor action runtime + -l, --logsize LIMIT the maximum log size LIMIT in MB for the conductor action (default 10) + -m, --memory LIMIT the maximum memory LIMIT in MB for the conductor action (default 256) + -t, --timeout LIMIT the timeout LIMIT in milliseconds for the conductor action (default 60000) + -u, --auth KEY authorization KEY + -v, --version output the composer version + -w, --overwrite overwrite actions if already defined + --debug LIST comma-separated list of debug flags +``` +The `deploy` command deploys a JSON-encoded composition with the given name. +``` +deploy demo demo.json -w +``` +``` +ok: created /_/authenticate,/_/success,/_/failure,/_/demo +``` + +The `deploy` command synthesizes and deploys a conductor action that implements +the composition with the given name. It also deploys the composed actions for +which definitions are provided as part of the composition. + +The `deploy` command outputs the list of deployed actions or an error result. If +an error occurs during deployment, the state of the various actions is unknown. + +The `-w` option authorizes the `deploy` command to overwrite existing +definitions. More precisely, it deletes the deployed actions before recreating +them. As a result, default parameters, limits, and annotations on preexisting +actions are lost. + +The `--logsize` option specifies the maximum log size for the conductor action. +The `--memory` option specifies the maximum memory for the conductor action. +The `--timeout` option specifies the timeout for the conductor action. + +The `--kind` option specifies the kind for the conductor action runtime. By +default, the `nodejs:default` OpenWhisk runtime is used. The chosen runtime must +be based on Node.js. Other Node.js runtimes may or may not be compatible with +Composer. + +### Annotations + +The `deploy` command implicitly annotates the deployed composition action with +the required `conductor` annotations. Other annotations may be specified by +means of the flags: +``` + -a, --annotation KEY=VALUE add KEY annotation with VALUE + -A, --annotation-file KEY=FILE add KEY annotation with FILE content +``` + +### OpenWhisk instance + +Like the OpenWhisk CLI, the `deploy` command supports the following flags for +specifying the OpenWhisk instance to use: +``` + --apihost HOST API HOST + -i, --insecure bypass certificate checking + -u, --auth KEY authorization KEY +``` +In addition the `deploy` command supports the flags: +``` + --basic force basic authentication + --bearer force bearer token authentication +``` +If the `--apihost` flag is absent, the environment variable `__OW_API_HOST` is +used in its place. If neither is available, the `deploy` command extracts the +`APIHOST` key from the whisk property file. + +The `apiversion` may be specified using the `--apiversion` flag, or, if absent, +the `APIVERSION` property of the whisk property file. If both are absent, the +default is assumed. + +If the `--insecure` flag is set or the environment variable `__OW_IGNORE_CERTS` +is set to `true`, the `deploy` command ignores SSL certificates validation +failures. + +The default target namespace is the value of environment variable +`__OW_NAMESPACE` if defined. If not, it is the value of the `NAMESPACE` property +in the whisk property file if present. Otherwise, the default `_` value is used. + +If the `--basic` flag is set, the `deploy` command uses basic authentication. If +the `--bearer` flag is set, the `deploy` command uses bearer token +authentication. If neither flag is set, the `deploy` command uses basic +authentication only if the default target namespace is `_`. Setting both flags +is an error. + +For basic authentication, the authentication key is obtained from the `--auth` +flag. If the `--auth` flag is absent, the environment variable `__OW_API_KEY` is +used in its place. If neither is available, the `deploy` command extracts the +`AUTH` key from the whisk property file. + +For bearer token authentication, the token is either the value of the +environment variable `__OW_APIGW_TOKEN` if defined or the value of property +`APIGW_ACCESS_TOKEN` in the whisk property file. + +The default path for the whisk property file is `$HOME/.wskprops`. It can be +altered by setting the `WSK_CONFIG_FILE` environment variable. + +### Debug flag + +The `--debug` flag takes a comma-separated list of debugging options. + +The `needle` option activates `needle` verbose logging. + +The `needle
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: