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. +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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 [![Travis](https://travis-ci.org/ibm-functions/composer.svg?branch=master)](https://travis-ci.org/ibm-functions/composer) @@ -5,75 +24,56 @@ [![Join Slack](https://img.shields.io/badge/join-slack-9B69A0.svg)](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` option enables overriding `needle` default parameters. +The specified `defaults` must be be a json dictionary, as for example in: +``` +deploy demo demo.json --debug 'needle<{"connection":"keep-alive","open_timeout":60000}>' +``` diff --git a/docs/COMPOSE.md b/docs/COMPOSE.md deleted file mode 100644 index 4142ab8..0000000 --- a/docs/COMPOSE.md +++ /dev/null @@ -1,140 +0,0 @@ -# Compose Command - -The `compose` command makes it possible to deploy compositions from the command line. - -The `compose` command is 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` command bridges this gap. It makes it possible to deploy compositions as part of the development cycle or in shell scripts. It is not a replacement for the OpenWhisk CLI however as it does not duplicate existing OpenWhisk CLI capabilities. Moreover, for a much richer developer experience, we recommend using [Shell](https://github.com/ibm-functions/shell). - -## Usage - -``` -compose -``` -``` -Usage: - compose composition.js[on] command [flags] -Commands: - --json output the json representation for the composition (default command) - --deploy NAME deploy the composition with name NAME - --encode output the conductor action code for the composition -Flags: - --lower [VERSION] lower to primitive combinators or specific composer version - --apihost HOST API HOST - -u, --auth KEY authorization KEY - -i, --insecure bypass certificate checking -``` -The `compose` command requires either a Javascript file that evaluates to a composition (for example [demo.js](../samples/demo.js)) or a JSON file that encodes a composition (for example [demo.json](../samples/demo.json)). The JSON format is documented in [FORMAT.md](FORMAT.md). - -The `compose` command has three modes of operation: -- By default or when the `--json` option is specified, the command returns the composition encoded as a JSON dictionary. -- When the `--deploy` option is specified, the command deploys the composition given the desired name for the composition. -- When the `--encode` option is specified, the command returns the Javascript code for the [conductor action](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md) for the composition. - -## JSON format - -By default, the `compose` command evaluates the composition code and outputs the resulting JSON dictionary: -``` -compose demo.js -``` -```json -{ - "type": "if", - "test": { - "type": "action", - "name": "/_/authenticate", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function ({ password }) { return { value: password === 'abc123' } }" - } - } - }, - "consequent": { - "type": "action", - "name": "/_/success", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'success' } }" - } - } - }, - "alternate": { - "type": "action", - "name": "/_/failure", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'failure' } }" - } - } - } -} -``` -The evaluation context includes the `composer` object implicitly defined as: -```javascript -composer = require('@ibm-functions/composer') -``` -In other words, there is no need to require the `composer` module explicitly in the composition code. - -## Deployment - -The `--deploy` option makes it possible to deploy a composition (Javascript or JSON) given the desired name for the composition: -``` -compose demo.js --deploy demo -``` -``` -ok: created actions /_/authenticate,/_/success,/_/failure,/_/demo -``` -Or: -``` -compose demo.js > demo.json -compose demo.json --deploy demo -``` -``` -ok: created actions /_/authenticate,/_/success,/_/failure,/_/demo -``` -The `compose` 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 `compose` 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 `compose` command deletes the deployed actions before recreating them if necessary. As a result, default parameters, limits, and annotations on preexisting actions are lost. - -### Configuration - -Like the OpenWhisk CLI, the `compose` command supports the following flags for specifying the OpenWhisk deployment to use: -``` - --apihost HOST API HOST - -u, --auth KEY authorization KEY - -i, --insecure bypass certificate checking -``` -If the `--apihost` flag is absent, the environment variable `__OW_API_HOST` is used in its place. If neither is available, the `compose` command extracts the `APIHOST` key from the whisk property file for the current user. - -If the `--auth` flag is absent, the environment variable `__OW_API_KEY` is used in its place. If neither is available, the `compose` command extracts the `AUTH` key from the whisk property file for the current user. - -The default path for the whisk property file is `$HOME/.wskprops`. It can be altered by setting the `WSK_CONFIG_FILE` environment variable. - -## Code generation - -The `compose` command returns the code of the conductor action for the composition (Javascript or JSON) when invoked with the `--encode` option. -For instance, the conductor action code for the [demo.js](../samples/demo.js) composition is [demo-conductor.js](../samples/demo-conductor.js): -``` -compose demo.js --encode > demo-conductor.js -``` -This code may be deployed using the OpenWhisk CLI: -``` -wsk action create demo demo-conductor.js -a conductor true -``` -``` -ok: created action demo -``` -The conductor action code does not include definitions for nested actions or compositions. - -## Lowering - -If the `--lower VERSION` option is specified, the `compose` command uses the set of combinators of the specified revision of the `composer` module. More recently introduced combinators (if any) are translated into combinators of the older set. - -If the `--lower` option is specified without a version number, the `compose` command uses only primitive combinators. - -These options may be combined with any of the `compose` commands. diff --git a/docs/COMPOSER.md b/docs/COMPOSER.md deleted file mode 100644 index 71efd2e..0000000 --- a/docs/COMPOSER.md +++ /dev/null @@ -1,107 +0,0 @@ -# Composer Module - -The [`composer`](../composer.js) Node.js module makes it possible define, deploy, and invoke compositions. - -## Installation - -To install the `composer` module, use the Node Package Manager: -``` -npm install @ibm-functions/composer -``` -To take advantage of the `compose` command, it may be useful to install the module globally as well (`-g` option). - -## Example - -The [samples/node-demo.js](../samples/node-demo.js) file illustrates how to define, deploy, and invoke a composition using `node`: -```javascript -// require the composer module -const composer = require('@ibm-functions/composer') - -// define the composition -const composition = 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' } } })) - -// instantiate OpenWhisk client -const wsk = composer.openwhisk({ ignore_certs: true }) - -wsk.compositions.deploy(composer.composition('demo', composition)) // name and deploy composition - .then(() => wsk.actions.invoke({ name: 'demo', params: { password: 'abc123' }, blocking: true })) // invoke composition - .then(({ response }) => console.log(JSON.stringify(response.result, null, 4)), console.error) -``` -``` -node samples/node-demo.js -``` -```json -{ - "message": "success" -} -``` -Alternatively, the `compose` command can deploy compositions and the OpenWhisk CLI can invoke compositions. See [COMPOSE.md](COMPOSE.md) for details. - -# Composer methods - -The `composer` object offers a number of combinator methods to define composition objects, e.g., `composer.if`. Combinators are documented in [COMBINATORS.md](COMBINATORS.md). It also offers a series of helper methods described below: - -| Combinator | Description | Example | -| --:| --- | --- | -| [`deserialize`](#deserialize) | deserialization | `composer.deserialize(JSON.stringify(composition))` | -| [`lower`](#lower) | lowering | `composer.lower(composer.if('authenticate', 'success', 'failure'), '0.4.0')` | -| [`encode`](#encode) | code generation | `composer.encode(composition, '0.4.0')` | - -Finally, the `composer` object object offers an extension to the [OpenWhisk Client for Javascript](https://github.com/apache/incubator-openwhisk-client-js) that supports [deploying](#deployment) compositions. - -## Deserialize - -`composer.deserialize(composition)` recursively deserializes a serialized composition object. In other words, it recreates a `Composition` object from the input JSON dictionary. - -## Lower - -`composer.lower(composition, [combinators])` outputs a composition object equivalent to the input `composition` object but using a reduced set of combinators. The optional `combinators` parameter may specify the desired set, either directly as an array of combinator names, e.g., `['retain', 'retry']` or indirectly as a revision of the composer module, e.g., `'0.4.0'`. If the `combinators` parameter is undefined, the set of combinators is the set of _primitive_ combinators (see [COMBINATORS.md](COMBINATORS.md])). If an array of combinators is specified the primitive combinators are implicitly added to the array. If a `composer` module revision is specified, the target combinator set is the set of combinators available as of the specified revision of the `composer` module. The `combinators` parameter may also have type Boolean. If `combinators === true` only primitive combinators are used. If `combinators === false`, there is no change to the composition. - -For instance, `composer.lower(composition, ['retry'])` will preserve any instance of the `retry` combinator but replace other non-primitive combinators sur as `retain`. - -## Encode - -`composer.encode(composition, [combinators])` first lowers the composition. It then converts compositions nested into `composition` into conductor actions. It finally extracts the action definitions from `composition` (both embedded action definitions and synthesized conductor actions) returning a dictionary with two fields `{ composition, actions }` where `composition` no longer contains any action or composition definitions and `actions` is the corresponding array of extracted action definitions. - -The optional `combinators` parameter controls the lowering. See [lower](#lower) for details. - -# Deployment - -The `composer` object offers an extension to the [OpenWhisk Client for Javascript](https://github.com/apache/incubator-openwhisk-client-js) that supports deploying compositions. - -## Openwhisk client - -A client instance is obtained by invoking `composer.openwhisk([options])`, for instance with: -```javascript -const wsk = composer.openwhisk({ ignore_certs: true }) - -``` -The specific OpenWhisk deployment to use may be specified via the optional `options` argument, environment variables, or the OpenWhisk property file. Options have priority over environment variables, which have priority over the OpenWhisk property file. Options and environment variables are documented [here](https://github.com/apache/incubator-openwhisk-client-js#constructor-options). The default path for the whisk property file is `$HOME/.wskprops`. It can be altered by setting the `WSK_CONFIG_FILE` environment variable. - -The `composer` module adds to the OpenWhisk client instance a new top-level category named `compositions` with a method named `deploy`. - -## Deploying compositions - -`wsk.compositions.deploy(composition, [combinators])` lowers and deploys the composition `composition`. More precisely, it successively deploys all the actions and compositions defined in `composition` including `composition` itself. The composition `composition` must have a name, hence the `deploy` method is typically used as illustrated above: -``` -wsk.compositions.deploy(composer.composition('demo', composition)) -``` - -The optional `combinators` parameter controls the lowering. See [lower](#lower) for details. - -The compositions are encoded into conductor actions prior to deployment. In other words, the `deploy` method deploys one or several actions. - -The `deploy` method returns a successful promise if all the actions were deployed successfully, or a rejected promise otherwise. In the later, the state of the various actions is unknown. - -The `deploy` method deletes the deployed actions before recreating them if necessary. As a result, default parameters, limits, and annotations on preexisting actions are lost. - -## Invoking, updating, and deleting compositions - -Since compositions are deployed as conductor actions, other management tasks for compositions can be achieved by invoking methods of `wsk.actions`, for instance: -```javascript -wsk.actions.delete('demo') -``` -Updating or deleting a conductor action only affect the action itself. It does not affect any other action deployed as part of the composition. diff --git a/docs/COMPOSITIONS.md b/docs/COMPOSITIONS.md index 4c8e416..5d32590 100644 --- a/docs/COMPOSITIONS.md +++ b/docs/COMPOSITIONS.md @@ -1,80 +1,145 @@ + + # Compositions - -Composer makes it possible to assemble actions into rich workflows called _compositions_. An example composition is described in [../README.md](../README.md). + +Composer makes it possible to assemble actions into rich workflows called +_compositions_. An example composition is described in +[../README.md](../README.md). ## Control flow -Compositions can express the control flow of typical a sequential imperative programming language: sequences, conditionals, loops, error handling. This control flow is specified using _combinator_ methods such as: +Compositions can express the control flow of typical imperative programming +language: sequences, conditionals, loops, structured error handling. This +control flow is specified using _combinator_ methods such as: - `composer.sequence(firstAction, secondAction)` - `composer.if(conditionAction, consequentAction, alternateAction)` - `composer.try(bodyAction, handlerAction)` +Parallel constructs are also available. + Combinators are described in [COMBINATORS.md](COMBINATORS.md). ## Composition objects -Combinators return composition objects, i.e., instances of the `Composition` class. +Combinators return composition objects, i.e., instances of the `Composition` +class. ## Parameter objects and error objects -A composition, like any action, accepts a JSON dictionary (the _input parameter object_) and produces a JSON dictionary (the _output parameter object_). An output parameter object with an `error` field is an _error object_. A composition _fails_ if it produces an error object. +A composition, like any action, accepts a JSON dictionary (the _input parameter +object_) and produces a JSON dictionary (the _output parameter object_). An +output parameter object with an `error` field is an _error object_. A +composition _fails_ if it produces an error object. + +By convention, an error object returned by a composition is stripped from all +fields except from the `error` field. This behavior is consistent with the +OpenWhisk action semantics, e.g., the action with code `function main() { return +{ error: 'KO', message: 'OK' } }` outputs `{ error: 'KO' }`. -By convention, an error object returned by a composition is stripped from all fields except from the `error` field. This behavior is consistent with the OpenWhisk action semantics, e.g., the action with code `function main() { return { error: 'KO', message: 'OK' } }` outputs `{ error: 'KO' }`. +Error objects play a specific role as they interrupt the normal flow of +execution, akin to exceptions in traditional programming languages. For +instance, if a component of a sequence returns an error object, the remainder of +the sequence is not executed. Moreover, if the sequence is enclosed in an error +handling composition like a `composer.try(sequence, handler)` combinator, the +execution continues with the error handler. -Error objects play a specific role as they interrupt the normal flow of execution, akin to exceptions in traditional programming languages. For instance, if a component of a sequence returns an error object, the remainder of the sequence is not executed. Moreover, if the sequence is enclosed in an error handling composition like a `composer.try(sequence, handler)` combinator, the execution continues with the error handler. +### Reserved parameter name + +The field name `$composer` is reserved for composer internal use. Compositions +and composed actions should not expect or return parameter objects with a +top-level field named `$composer`. ## Data flow -The invocation of a composition triggers a series of computations (possibly empty, e.g., for the empty sequence) obtained by chaining the components of the composition along the path of execution. The input parameter object for the composition is the input parameter object of the first component in the chain. The output parameter object of a component in the chain is typically the input parameter object for the next component if any or the output parameter object for the composition if this is the final component in the chain. +The invocation of a composition triggers a series of computations (possibly +empty, e.g., for the empty sequence) obtained by chaining the components of the +composition along the path of execution. The input parameter object for the +composition is the input parameter object of the first component in the chain. +The output parameter object of a component in the chain is typically the input +parameter object for the next component if any or the output parameter object +for the composition if this is the final component in the chain. -For example, the composition `composer.sequence('triple', 'increment')` invokes the `increment` action on the output of the `triple` action. +For example, the composition `composer.sequence('triple', 'increment')` invokes +the `increment` action on the output of the `triple` action. -Some combinators however are designed to alter the default flow of data. For instance, the `composer.retain(myAction)` composition returns a combination of the input parameter object and the output parameter object of `myAction`. +Some combinators however are designed to alter the default flow of data. For +instance, the `composer.merge('myAction')` composition merges the input and +output parameter objects of `myAction`. ## Components -Components of a compositions can be actions, Javascript functions, or compositions. +Components of a compositions can be actions, JavaScript functions, or +compositions. -Javascript functions can be viewed as simple, anonymous actions that do not need to be deployed and managed separately from the composition they belong to. Functions are typically used to alter a parameter object between two actions that expect different schemas, as in: +JavaScript functions can be viewed as simple, anonymous actions that do not need +to be deployed and managed separately from the composition they belong to. +Functions are typically used to alter a parameter object between two actions +that expect different schemas, as in: ```javascript -composer.if('getUserNameAndPassword', params => ({ key = btoa(params.user + ':' + params.password) }), 'authenticate') +composer.sequence('getUserNameAndPassword', params => ({ key = btoa(params.user + ':' + params.password) }), 'authenticate') ``` - -Compositions may be nested inside compositions in two ways. First, combinators can be nested, e.g., +Combinators can be nested, e.g., ```javascript composer.if('isEven', 'half', composer.sequence('triple', 'increment')) ``` -Second, compositions can reference other compositions by name. For instance, assuming we deploy the sequential composition of the `triple` and `increment` actions as the composition `tripleAndIncrement`, the following code behaves identically to the previous example: +Compositions can reference other compositions by name. For instance, assuming we +deploy the sequential composition of the `triple` and `increment` actions as the +composition `tripleAndIncrement`, the following code behaves identically to the +previous example: ```javascript composer.if('isEven', 'half', 'tripleAndIncrement') ``` -Observe however, that the behavior of the second composition would be altered if we redefine the `tripleAndIncrement` composition to do something else, whereas the first example would not be affected. +The behavior of this last composition would be altered if we redefine the +`tripleAndIncrement` composition to do something else, whereas the first example +would not be affected. -## Nested declarations +## Embedded action definitions -A composition can embed the definitions of none, some, or all the composed actions as illustrated in [demo.js](../samples/demo.js): +A composition can embed the definitions of none, some, or all the composed +actions as illustrated in [demo.js](../samples/demo.js): ```javascript 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' } } })) + composer.action('failure', { action: function () { return { message: 'failure' } } })) ) ``` Deploying such a composition deploys the embedded actions. -A composition can also include the definition of another composition: -```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 other words, 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. - -## Serialization and deserialization - - Compositions objects can be serialized to JSON dictionaries by invoking `JSON.stringify` on them. Serialized compositions can be deserialized to composition objects using the `composer.deserialize(serializedComposition)` method. The JSON format is documented in [FORMAT.md](FORMAT.md). - In short, the JSON dictionary for a composition contains a representation of the syntax tree for this composition as well as the definition of all the actions and compositions embedded inside the composition. - ## Conductor actions -Compositions are implemented by means of OpenWhisk [conductor actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md). The conductor actions are implicitly synthesized when compositions are deployed using the `compose` command or the `composer.deploy` method. Alternatively, the `composer.encode` method can encode compositions without deploying them. See [COMPOSER.md](COMPOSER.md) for details. \ No newline at end of file +Compositions are implemented by means of OpenWhisk [conductor +actions](https://github.com/apache/openwhisk/blob/master/docs/conductors.md). +Compositions have all the attributes and capabilities of an action, e.g., +default parameters, limits, blocking invocation, web export. Execution +[traces](https://github.com/apache/openwhisk/blob/master/docs/conductors.md#activations) +and +[limits](https://github.com/apache/openwhisk/blob/master/docs/conductors.md#limits) +of compositions follow from conductor actions. + +The conductor action code for a composition may be obtained by means of the +`generate` method of the `conductor` module or using the `compose` command with +the `--js` flag. The conductor action code may be deployed using, e.g., the +OpenWhisk CLI. +``` +compose demo.js --js > demo-conductor.js +wsk action create demo demo-conductor.js -a conductor true +``` +The `conductor` annotation must be set on conductor actions. diff --git a/docs/FORMAT.md b/docs/FORMAT.md deleted file mode 100644 index 6822db1..0000000 --- a/docs/FORMAT.md +++ /dev/null @@ -1,60 +0,0 @@ -# JSON Format - -Compositions are encoded as JSON dictionaries prior to deployment. For instance the composition in [demo.js](../samples/demo.js) is encoded as: -```json -{ - "type": "if", - "test": { - "type": "action", - "name": "/_/authenticate", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function ({ password }) { return { value: password === 'abc123' } }" - } - } - }, - "consequent": { - "type": "action", - "name": "/_/success", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'success' } }" - } - } - }, - "alternate": { - "type": "action", - "name": "/_/failure", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'failure' } }" - } - } - } -} -``` -This json dictionary has one mandatory field named `type` with the name of the combinator and possible other fields that depend on the specific combinator. The values of some of these fields may be themselves composition dictionaries. In this example, the `test`, `consequent`, and `alternate` fields are compositions of `type` action. - -The field names and types typically match the combinator method signatures: - -| Type | Fields | -| --:| --- | -| `action` | name:string, action:optional object | -| `function` | function:string | -| `literal` or `value` | value:any | -| `composition` | name:string, composition:composition | -| `empty` | -| `sequence` or `seq` | components:array of compositions | -| `let` | declarations:object, components:array of compositions | -| `mask`| components:array of compositions | -| `if` and `if_nosave` | test:composition, consequent:composition, alternate:composition | -| `while` and `while_nosave` | test:composition, body:composition | -| `dowhile` and `dowhile_nosave` | body:composition, test:composition | -| `repeat` | count:number, components:array of compositions | -| `try` | body:composition, handler:composition | -| `finally` | body:composition, finalizer:composition | -| `retry` | count:number, components:array of compositions | -| `retain` and `retain_catch` | components:array of compositions | diff --git a/docs/README.md b/docs/README.md index ec4d3b2..e29a579 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,13 +1,33 @@ + + # Composer Package The Composer package consists of: -* the [composer](../composer.js) Node.js module for authoring, deploying, and invoking compositions, -* the [compose](../bin/compose) command for managing compositions from the command line. +* the [composer](../composer.js) module for authoring compositions, +* the [conductor](../conductor.js) module for generating conductor actions from + compositions, +* the [client](../client.js) module for deploying compositions to openwhisk, +* the [compose](../bin/compose.js) and [deploy](../bin/deploy.js) commands for + managing compositions from the command line. The documentation for the Composer package is organized as follows: - [COMPOSITIONS.md](COMPOSITIONS.md) gives a brief introduction to compositions. -- [COMPOSER.md](COMPOSER.md) documents the `composer` module. -- [COMPOSE.md](COMPOSE.md) documents the `compose` command. -- [COMBINATORS.md](COMBINATORS.md) documents the methods of the `composer` object. -- [FORMAT.md](FORMAT.md) documents the JSON format for encoding compositions. -- The [tutorials](tutorials) folder includes various tutorials. +- [COMBINATORS.md](COMBINATORS.md) explains the composition constructs. +- [COMMANDS.md](COMMANDS.md) describes the `compose` and `deploy` commands. diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md deleted file mode 100644 index 1070fc3..0000000 --- a/docs/tutorials/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Tutorials - -This folder contains a few tutorials to get started: -* [Introduction to Serverless - Composition](introduction/README.md): Setting up your - programming environment and getting started with Shell and Composer. -* [Building a Translation Slack Bot with Serverless - Composition](translateBot/README.md): A more advanced tutorial - using Composition to build a serverless Slack chatbot that does language - translation. diff --git a/docs/tutorials/introduction/README.md b/docs/tutorials/introduction/README.md deleted file mode 100644 index d4aa87c..0000000 --- a/docs/tutorials/introduction/README.md +++ /dev/null @@ -1,468 +0,0 @@ -# Introduction to Serverless Composition - -Composer is an [IBM Cloud Functions](https://ibm.biz/openwhisk) -programming model for composing individual functions into larger -applications. Compositions, informally named _apps_, run in the cloud -using automatically managed compute and memory resources. Composer is -an extension of the function-as-a-service computing model, and enables -stateful computation, control flow, and rich patterns of data flow. - -Programming for the serverless cloud is a uniquely new experience. For -this reason, we have developed a unified environment that offers the -benefits and familiarity of a command line interface, with -visualization and a graphical interface to assist in certain -tasks. This environment is offered through a new tool called -[IBM Cloud Shell](https://github.com/ibm-functions/shell), or just _Shell_. - -## Shell quick start - -Shell is a cross-platform desktop application powered by [Electron](https://electronjs.org/). - -### Before you run your first app - -You must have a valid IBM Cloud (i.e., Bluemix) -[account](https://ibm.biz/openwhisk), or deploy [Apache -OpenWhisk](https://github.com/apache/incubator-openwhisk) -locally. This is needed because Composer builds on and extends Apache -OpenWhisk, which powers IBM Cloud Functions. - -* Existing `wsk` CLI users: You can go directly to [Installing Shell](#installing-shell). - -* _New users using composer with IBM Cloud Functions:_ you need an IBM Cloud -[account](https://ibm.biz/openwhisk), and the [IBM Cloud CLI](https://console.bluemix.net/docs/cli/reference/bluemix_cli/download_cli.html#download_install) (`bx`). You will also need to install the Cloud Function Plugin for bx: - - ``` - $ bx plugin install Cloud-Functions -r bluemix - ``` - - After installing `bx` and the Cloud Function plugin, use `bx login` to generate a access token for Cloud Function. - - ``` - $ bx login -a api.ng.bluemix.net -o yourBluemixOrg -s yourBluemixSpace - ``` - - Run a test to generate credentials and verify your setup. Here, we perform a blocking (synchronous) invocation of echo, passing it "hello" as an argument. If you see the return message, you are good to go. - ``` - $ bx wsk action invoke /whisk.system/utils/echo -p message hello --result - { - "message": "hello" - } - ``` - -* _New users using composer with Apache OpenWhisk:_ you need a valid `$HOME/.wskprops` file and a locally deployed OpenWhisk instance. - - -### Installing Shell - -Shell is currently distributed through the [Node -package manager](https://www.npmjs.com/package/@ibm-functions/shell). - -``` -$ npm install -g @ibm-functions/shell -``` - -We roll out frequent updates and bug fixes. You can check for new -releases via `fsh version -u`. - -``` -$ fsh version -u -You are currently on version x.y.z -Checking for updates... you are up to date! -``` - -We recommend updating the shell via the same `npm install` command -shown earlier. Consult the [troubleshooting -guide](https://github.com/ibm-functions/shell/blob/master/npm.md) if -your installation fails. - - -### Starting Shell - -``` -$ fsh shell -``` - -You will see a window popping up. Welcome to Shell! - -_Tip:_ If you are using Mac, you can keep Shell in the dock by right-clicking on the blue Cloud Function Shell icon and choose `Options > Keep in Dock`. Next time you can click on the icon in the dock to launch Shell. - - -## Your first app - -Compositions are described using a [Node.js library](../../README.md) -which offers an SDK for describing control structures. We call these -_combinators_. The simplest combinator constructs a sequence. For example, here is -a snippet for your first app: - -```javascript -composer.sequence(args => ({msg: `hello ${args.name}!`})) -``` - -The code describes a sequence app with just one function that is inlined for convenience. - -There are two ways to deploy a composition code snippet. One is using Shell to write the code and deploy it. The other is using any editor to write this code, save it as a local file, and deploy the file using a Shell command. We describe both here. - - -### Write an app in Shell - -In Shell, enter - -```bash -# enter in Shell -> compose myApp -``` - -where `myApp` is the name of the app in the cloud. This command opens a built-in editor in a sidecar for writing code. Copy the `composer.sequence` code above and paste it into the editor. Hit "Deploy" at the bottom stripe to deploy it. After the app is successfully deployed, Shell will show a flow graph that represents the textual composition code at the bottom of the editor as a verification. - -_Tip:_ Enter `edit myApp` to edit `myApp` after it is deployed. - -|| -|:--:| -|Your first app, composed in Shell.| - - -### Write an app in an external editor - -You may also use your favorite editor to compose apps. When finished, save your code to a file on your machine with the extension `.js`. To view your local composition javascript file as a graph, enter - -```bash -# enter in Shell -> app preview path/to/file.js -``` - -Shell watches the file you are editing and automatically -updates the graph as you compose. You can use this active preview mode -to incrementally build your application, sanity checking your control -flow as you go. - -|| -|:--:| -|Writing an app in my own editor, and previewing the code as a graph in Shell.| - - -To deploy a local composition file to the cloud, enter this command: -```bash -# enter in Shell -> app create myApp path/to/file.js -``` - -Again, `myApp` is the name of the app in the cloud. - -_Tip_: If you have an action already named `myApp`, the shell will report a name conflict. Use a different name for your app, or use `app update` if you want to update an existing app. Apps are stored as OpenWhisk actions, and hence the naming restrictions for OpenWhisk apply. - - -## Running your first app - -Run your first app using this command: - -```bash -# enter in Shell -> app invoke myApp -p name composer -``` - -You will see the result in the sidecar. Click on different buttons in the sidecar bottom stripe to explore different views. - -_Tip #1:_ Enter `app invoke --help` to view the usage of the `app invoke` command. You can access the usage information of other commands in Shell in the same way using `--help`. - -_Tip #2:_ Enter `session list` to view a list of previous app executions. - - -## Composing OpenWhisk actions - -Combinators accept either inline Node.js functions or actions by name. -For the latter, you may use a fully qualified name of an action (i.e., -`/namespace[/package]/action`) or its short name. Here is an example -using the `date` action from the `/whisk.system/utils` package. - -```javascript -composer.sequence('/whisk.system/utils/date') -``` - -A composition which refers to actions by name will not run correctly -if there are missing referenced entities. The `app preview` will -highlight any missing entities. As an example, preview the built-in -[`@demos/if.js`](https://github.com/ibm-functions/shell/blob/master/app/demos/if.js) -composition, which is [described in the next -section](#if-combinator). The control flow graph should be -self-explanatory. An action is gray when it is not yet deployed, and -blue otherwise. - -```bash -# enter in Shell -> app preview @demos/if.js -``` - -|| -|:--:| -|Control flow graph for `if` combinator. An action that is not yet deployed is gray, and blue otherwise.| - -_Tip:_ Shell supports `wsk` CLI commands for deploying OpenWhisk actions. We will explain how to do so next. You can also read more about using `wsk` commands in Shell [here](https://github.com/ibm-functions/shell/blob/master/fsh.md). - - -### Composing inline functions vs. OpenWhisk actions - -The main difference between using an inline function verses a OpenWhisk action in a composition is that an inline function does not generate an activation like an OpenWhisk action. [Activations](https://github.com/apache/incubator-openwhisk/blob/master/docs/reference.md) record runtime data like the execution time and output. They are useful for debugging. - -When making a real app, we encourage you to create the main components as OpenWhisk actions, as OpenWhisk actions can be reused by different apps and are easier to debug. Inline functions can be used as a convenient way to connect different components together (such as renaming input and output, generating an error message) and are better kept short and simple. - - -## Compositions by example - -You now have the basic tools to build a serverless composition, invoke -it, and inspect its execution and result. Currently, Composer offers [13 different combinators](../../README.md#combinators) to support conditions, iterations, error handling, variable declarations and other common programming constructs for building various types of apps. - -This section will introduce you to some combinators for creating richer control and data flow, while other combinators are covered in the [reference manual](../../README.md). All javascript code described below is [bundled in Shell](https://github.com/ibm-functions/shell/blob/master/app/demos) and can be accessed within Shell using the prefix `@demos/`. - - -### `if` combinator - -An `if` combinator allows you to describe a conditional flow with a -`then` and optional `else` branch. This is convenient for -short-circuiting a sequence for example, or taking data-dependent -paths in the control flow. - -Here is a short example. Say you have a function `welcome` which generates an HTML page. - -```javascript -// @demos/welcome.js -let welcome = args => ({ html: `welcome ${args.name}!` }) -``` - -In order to use this function as part of an authenticated API, we can -modify the function itself to introduce authentication middleware. Or, -we can compose it with an authentication function. - -```javascript -// @demos/authenticate.js -let authenticate = args => ({ value: args.token === "secret" }) -``` - -For illustration purposes, `authenticate` is a simple token based -checker. If the token equals the secret value, return `true`, and -`false` otherwise. In a real scenario, this function may delegate to a -third party service or identity provider. - -Let's add a third function, this one to deal with the -non-authenticated case and return a different HTML page, perhaps -informing the client to try again with the proper secret. - -```javascript -// @demos/login.js -let login = args => ({ html: `please say the magic word.` }) -``` - -The `if` combinator composes these three functions as you might -expect. - -```javascript -// @demos/if.js -composer.if( - /* cond */ 'authenticate', - /* then */ 'welcome', - /* else */ 'login') -``` - -Now, enter the following in Shell to deploy and run the app. - -```bash -# enter in Shell -# create required actions -> action create authenticate @demos/authenticate.js -> action create welcome @demos/welcome.js -> action create login @demos/login.js - -# create app -> app create if @demos/if.js - -# invoke app, with no secret parameter -> app invoke if -{ - html: "please say the magic word." -} - -# now invoke with secret parameter -> app invoke if -p token secret -p name if-combinator -{ - html: "welcome if-combinator!" -} -``` - -_Tip:_ You can see the output data of an action node in the `Session Flow` graph by clicking on the node. This will bring you to the corresponding activation. _Note:_ An inline function node is not clickable as it does not generate an activation. - -Each of the activations will have a different session id, which are reported by listing the available sessions. - -```bash -# enter in Shell -> session list -``` - -Clicking on a session id from the list will open that session in the sidecar. - -_Tip:_ Clicking on a session id invokes the command `session get sessionId` to view the session info in the sidecar. - - -### `try` combinator - -Another common composition pattern is for error handling and -recovery. Composer offers a `try` combinator that is analogous to -`try-catch`. - -A example to illustrate using `try` is a schema or data validation -action. Let `validate` be an action which checks if a string is base64 -encoded, and which throws an exception if the input is not valid. A -`try` combinator allows an error handler to rewrite the result, as -one example, to suite the particular usage scenario in the app. - -```javascript -// @demos/try.js -composer.try( - /* try */ 'validate', - /* catch */ args => ({ ok: false })) -``` - -The `validate` action is available as [`@demos/validate.js`](https://github.com/ibm-functions/shell/blob/master/app/demos/validate.js) and the -composition as [`@demos/try.js`](https://github.com/ibm-functions/shell/blob/master/app/demos/try.js) for your convenience. - -```bash -# enter in Shell -# create validate action -> action create validate @demos/validate.js - -# create app -> app create try @demos/try.js - -# invoke app with valid parameter -> app invoke try -p str aGVsbG8gdHJ5IQ== -{ - ok: true -} - -# and now for the failing case -> app invoke try -p str bogus -{ - ok: false -} -``` - -It is worth looking at the session flow of the second app invoke where -the catch handler is invoked. - -```bash -# enter in Shell -> session get --last try -``` - -|| -|:--:| -|Session execution for `try` where the handler is invoked.| - - -Notice that the `validate` action failed, as expected. This is -visually recognized by the red-colored action, and the hover text which -shows the action result containing the error. The app result is -successful however, as the handler rewrites the exception into a -different result. - -## Nesting and forwarding - -An important property of the combinators is that they nest. This -encourages modularity and composition reuse. The example that follows -illustrates both composition nesting, and data forwarding. The example -builds on the `try` app described in the previous section. Here, after -the validate task, we extend the composition with a base64 decoder to -render the input `str` in plain text. - -Recall that the result of the `validate` task is `{ok: true}`, -not the `str` argument that it processed. So we need a way to forward -`str` around this action. In other words, we _retain_ the input -arguments to `validate`, and pass them to the next action in the -sequence. Composer offers a combinator for just this purpose. Below -is the composition showing the inner sequence with the data forwarding -combinator `retain`. - -```javascript -// @demos/retain.js -composer.try( - composer.sequence( - composer.retain('validate'), - args => ({ text: new Buffer(args.params.str, 'base64').toString() })), - args => ({ ok: false })) -``` - -The `retain` combinator produces an output with two fields: `params` -and `result`. The former is the input parameter of the -composition. The latter is the output of `validate`. The control and -dataflow for this composition is shown below, and is available in the -shell as -[`@demos/retain.js`](https://github.com/ibm-functions/shell/blob/master/app/demos/retain.js). - -```bash -# enter in Shell -> app preview @demos/retain.js -``` - -|| -|:--:| -|Control flow graph showing the `retain` combinator and the implied dataflow around `validate`.| - -The app will now produce the decoded text as its final output. - -```bash -# enter in Shell -# create app -> app create retain @demos/retain.js - -# invoke app with valid parameter -> app invoke retain -p str aGVsbG8gdHJ5IQ== -{ - text: "hello try!" -} - -# and now for the failing case -> app invoke retain -p str bogus -{ - ok: false -} -``` - -## Variables and scoping - -The composer allows you to introduce variables within a composition, -and to limit their scope. This is useful when you have to introduce -service keys and credentials for example. A scoped variable is defined -using `let`. The example below illustrates how you might introduce a -"secret" for a specific task without its value escaping to other -compositions or functions. - -```javascript -// @demos/let.js -composer.sequence( - composer.let({secret: 42}, - composer.task(_ => ({ ok: secret === 42 }))), - composer.task(_ => ({ ok: (typeof secret === 'undefined') }))) -``` - -The composition will execute successfully only if `secret` is not -leaked to the final task in the composition, while the value is -available inside the task nested within the `let`. - -```bash -# enter in Shell -> app create let @demos/let.js -> app invoke let -{ - ok: true -} -``` - -## Other combinators - -The examples shown here illustrate the more common combinators you -may use to create serverless compositions. There are more combinators -available in the Composer library. Refer to the [Composer reference -manual](../../README.md) for more details. - -## Next step - -Try the second tutorial, [building a translation chatbot](../translateBot/README.md). - diff --git a/docs/tutorials/introduction/editor.png b/docs/tutorials/introduction/editor.png deleted file mode 100644 index 47f446c..0000000 Binary files a/docs/tutorials/introduction/editor.png and /dev/null differ diff --git a/docs/tutorials/introduction/hello-composition.png b/docs/tutorials/introduction/hello-composition.png deleted file mode 100644 index 41dde1e..0000000 Binary files a/docs/tutorials/introduction/hello-composition.png and /dev/null differ diff --git a/docs/tutorials/introduction/hello-session.png b/docs/tutorials/introduction/hello-session.png deleted file mode 100644 index 5ca5b1b..0000000 Binary files a/docs/tutorials/introduction/hello-session.png and /dev/null differ diff --git a/docs/tutorials/introduction/if-preview.png b/docs/tutorials/introduction/if-preview.png deleted file mode 100644 index d1fd1c4..0000000 Binary files a/docs/tutorials/introduction/if-preview.png and /dev/null differ diff --git a/docs/tutorials/introduction/preview.png b/docs/tutorials/introduction/preview.png deleted file mode 100644 index b2ddea9..0000000 Binary files a/docs/tutorials/introduction/preview.png and /dev/null differ diff --git a/docs/tutorials/introduction/retain.png b/docs/tutorials/introduction/retain.png deleted file mode 100644 index 7266d34..0000000 Binary files a/docs/tutorials/introduction/retain.png and /dev/null differ diff --git a/docs/tutorials/introduction/try-session.png b/docs/tutorials/introduction/try-session.png deleted file mode 100644 index 855bf6a..0000000 Binary files a/docs/tutorials/introduction/try-session.png and /dev/null differ diff --git a/docs/tutorials/translateBot/README.md b/docs/tutorials/translateBot/README.md deleted file mode 100644 index cd4e41c..0000000 --- a/docs/tutorials/translateBot/README.md +++ /dev/null @@ -1,260 +0,0 @@ -# Building a Translation Slack Bot with Serverless Composition - -Let's do something fun with Composition: We will build a serverless chatbot that translates the user's message using the IBM Watson Language Translation services. This tutorial assumes you have finished the [introduction](../introduction/README.md), which describes the basics about Composition and the instructions to setup the development environment ([Shell](https://github.com/ibm-functions/shell)). - -Here's what our chatbot does. -* First, it identifies the language of the user's message. -* If the language is not English, it translates the message to English. -* If the language is already English, just to make things more fun, it translates the message to "Shakespeare English". -* If the message is not translatable (such as if the message is a number), it returns a error message. - -Let's get started. - -## Composing the app - -Here is our composition code, using the [Composer NodeJs library](../../README.md). -```javascript -composer.try( - composer.sequence( - 'myWatsonTranslator/languageId', - composer.if( - p => p.language !== 'en', - composer.sequence( - p => ({translateFrom: p.language, translateTo: 'en', payload: p.payload}), - 'myWatsonTranslator/translator' - ), - composer.sequence( - p => ({text: p.payload}), - 'en2shakespeare' - ) - ) - ), - err => ({payload: 'Sorry we cannot translate your text'}) -) -``` - -In Shell, enter - -```bash -# in Shell -> compose myTranslateApp -``` - -Copy and paste the javascript code above to the editor, and hit "Deploy". You will see a graph that represents your the textual composition code when the app is successfully deployed. - -|| -|:--:| -|Your translate app. _Tip:_ You can click on the "full width" icon at the bottom right of the sidecar to expand the sidecar to full width.| - -This app first calls an OpenWhisk action `myWatsonTranslator/languageId` to identify the language type. Then it uses the `if` combinator to say if the identified language is not English, call `myWatsonTranslator/translator` to translate the input message (in `payload`) to English. Otherwise, call `en2shakespeare` to translate English to Shakespeare English. This sequence is wrapped in a `try` combinator to catch any error in the process. Short inline functions are used to quickly check a property value, rename data based on the requirement of an action, and generate an error message. - -All action nodes in the graph are gray at this moment because we don't have those actions deployed yet. - - -## Using IBM Watson Language Translation service -We use a built-in OpenWhisk package for using IBM Watson Language Translation service. To do so you need to have a valid [IBM Cloud account](https://www.ibm.com/cloud/) and a Language Translation service instance under your namespace. The service offers a lite plan that is free of charge. Here we will quickly walk through how to get the service credential and set up the Watson Translation OpenWhisk package. - -If you already have the Watson Translation OpenWhisk package setup, change `myWatsonTranslator` in the composition code to the name of your translation package and redeploy the app. If you see `languageId` and `translator` in the graph become blue, you can move directly to the [next section](#creating-en2shakespeare). - -_Tip:_ You can edit an exiting app using the command `edit appName`. - -Follow the steps here to get your Language Translation service credential: -1. Go to your [IBM Cloud dashboard](https://console.bluemix.net/dashboard/apps) -2. If you see a `Language Translator` service offering in your list, click on it and go to Step 5. Otherwise, click on the `Create resource` button at the upper right. -3. Search for "Language Translation" and click on it. -4. Select the free lite plan, then click "Create" at the bottom to create the service instance. -5. You will be at the Language Translation service homepage. Click on the "Service credentials" tab at the left pane. -6. Choose a service credential that you'd like to use form the list. If you don't see any credentials, click "New credential" then "Add" to add a new credential. -7. There is a "View credentials" option that you can click to expand. You'll see a `username` and a `password`. We will use them to set up the Watson Translation OpenWhisk package. - -Now go back to Shell and enter the following command to set up the translation package with your credential: - -```bash -# in Shell -> wsk package bind /whisk.system/watson-translator myWatsonTranslator -p username MYUSERNAME -p password MYPASSWORD -``` - -Replace `MYUSERNAME` and `MYPASSWORD` with your username and password. This command creates a package under your namespace called `myWatsonTranslator` from the built-in `whisk.system/watson-translator` package, and binds your service credentials to it. You can now try to invoke an action in this package to test if things are set up correctly: - -```bash -# in Shell -> action invoke myWatsonTranslator/languageId -p payload "bonjour" -{ - "language": "fr", - "payload": "bonjour", - "confidence": 0.799347 -} -``` - -_Tip:_ You can read more about using OpenWhisk packages [here](https://github.com/apache/incubator-openwhisk/blob/master/docs/packages.md). - -## Creating en2shakespeare - -`en2shakespeare` is a typical example of turning a web API service into an OpenWhisk action (cloud function). It uses the `request` npm module to send an http request. Here's the code: - -```javascript -var request = require("request"); -function main(params) { - var options = { - url: "http://api.funtranslations.com/translate/shakespeare.json", - qs: { - text: params.text, - api_key: params.apiKey - }, - json: true - }; - return new Promise(function(resolve, reject) { - request(options, function(err, resp) { - if (err) { - reject({error: err}) - } - resolve({ - payload: resp.body.contents.translated - }); - }); - }); -}; -``` - -In Shell, enter - -``` bash -# in Shell -> new en2shakespeare -``` - -Copy and paste the above javascript code into the editor and hit `Deploy` to deploy the action. - -Now, enter `app get myTranslateApp` in Shell and you should see all the nodes blue. We can now run the app. - -_Note:_ `en2shakespeare` uses an API from [Fun Translations](http://funtranslations.com/). The API allows some free calls without providing an API key. You can subscribe this API with Fun Translations and change `params.apiKey` in the code to be your key. - -_Tip #1:_ OpenWhisk Node.js runtime provides [several built-in npm modules](https://github.com/apache/incubator-openwhisk/blob/master/docs/reference.md#nodejs-version-6-environment), including `request` that we use here. You can also create a [zip action](https://github.com/apache/incubator-openwhisk/blob/master/docs/actions.md#packaging-an-action-as-a-nodejs-module) that uses any custom modules you'd like. - -_Tip #2:_ You can edit an existing action using `edit actionName` - -## Running the app - -```bash -# enter in Shell -> app invoke myTranslateApp -p payload "Mieux vaut prévenir que guérir" -{ - "payload": "Prevention is better than cure" -} -> app invoke myTranslateApp -p payload "hello world" -{ - "payload": "Valorous morrow to thee, sir ordinary" -} -> app invoke myTranslateApp -p payload "3.14159" -{ - "payload": "Sorry we cannot translate your text" -} -``` - -Checkout the `Session Flow` and `Trace` tab of a session to look at the execution path and trace of your app. You can use `session list` to see a list of recent sessions, and click on a session to view its detail in the sidecar. - -## Connecting the app to Slack - -Now we have the main functionality of the bot programmed, we need to connect it to Slack. To create a new Slack bot, you need to go to your [Slack API app page](https://api.slack.com/apps), login and choose `Create New App`. You will be prompted to give your app a name and select a development workspace. Here I call my app `Composition Bot` and select my personal workspace. - -|| -|:--:| -|Create a new Slack app for our bot| - -At your Slack app's main page, follow the steps to setup the chatbot: - -1. Go to `Features > Bot Users` and `Add a Bot User`. I have both display name and default username be `composition_bot`. Turn on `Always Show My Bot as Online`. Press `Add Bot User` to add the bot. -2. Go to `Settings > Install App` and click `Install App to Workspace`. Authorize Slack to add the bot user. -3. GO to `Features > Event subscriptions`, turn it on, go to `Subscribe to Bot Events` and add the `message.im` bot user event. This event is fired when our bot receives a direct message. Scroll to the top of the page. You'll see an empty `Request URL` text box. - -Let's go back to Shell now to create a new app called `slackTranslationBot` - -```bash -# in Shell -> compose slackTranslationBot -``` - -Paste the following code to the editor, and hit Deploy. - -```javascript -composer.sequence(p => p) -``` - -`slackTranslationBot` currently only has an echo function that will return whatever the input is. Now, let's create a URL endpoint for triggering `slackTranslationBot`. - -```bash -# in Shell -> webbify slackTranslationBot -https://openwhisk.ng.bluemix.net/xxxxx.... -``` - -The `webbify` command creates a URL for invoking a cloud function or composition. Copy of that URL, go back to the web browser and paste it to the `Request URL` text box in our Slack app's `Event subscriptions` page. Hit `Save Changes` at the bottom of the page. Now, try sending a direct message to our `composition_bot`. - -|| -|:--:| -|Send a test message "hello bot" to the slack bot| - -In Shell, enter `session get --last` to view the most recent session that was from `slackTranslationBot` as it just got triggered. View the data returned by Slack. According to Slack's [documentations](https://api.slack.com/events/message), the message is stored in `event.text`. Also, if a message is generated by the bot itself, there'll be a `event.subtype` that has the value `"bot_message"`. We do not want our bot to reply to itself, so we will add a condition in the composition code to handle that later. - -The last piece we need is to create an action that can send a message back to our bot. Go to `Features > Incoming Webhooks`in the Slack app page. Turn the feature on, and add a new webhook that enables our app to post to `@composition_bot`. We can post a message by making a HTTP POST request with data `{"text": "our message"}` to the webhook. Let's create an action to do that. - -```bash -# in Shell -> new sendSlackMsg -``` - -Copy the below JavaScript code and paste it in the editor. Remember to replace the value of `url` to be your webhook URL. Hit `Deploy` when you're done. - -```javascript -var request = require('request'); -function main(arg){ - return new Promise((resolve, reject) => { - request.post({ - headers: {'content-type' : 'application/json'}, - url: 'http://xxxxx', // replace this with your webhook url - body: JSON.stringify({text: arg.text}) - }, function(error, response, body){ - if(error){ - reject({success: false, input: arg.text, error: error}); - } - resolve({success: true, input: arg.text, result: response}); - }); - }); -} -``` - -Now let's edit the `slackTranslationBot` app. - -```bash -# in Shell -> edit slackTranslationBot -``` - -Copy and paste this code to the editor, and redeploy `slackTranslationBot`. - -```javascript -composer.if( - p => p.event.subtype !== 'bot_message', // ignore the messages sent by the bot itself - composer.sequence( - p => ({payload: p.event.text}), // rename data for the next action - 'myTranslateApp', // do translation - p => ({text: p.payload}), // rename data for the next action - 'sendSlackMsg' // send a message back - ) -) -``` - -Here we reuse `myTranslateApp`, and use some inline functions to rename the data for Slack. You can imagine reusing `myTranslateApp` again if we want to build the same bot for other messaging platforms like Facebook. - -## Testing the Slack Bot - -Let's test it by sending another test message to our slack bot. - -|| -|:--:| -|Now we have a translation Slack bot| - -Congratulations! You now have a Slack bot that can translate the user's message. The bot is completely serverless, meaning that you never pay for an idle bot (only pay per use), and the bot scales automatically. - -You can use `session list --name slackTranslationBot` in Shell to look at your bot's execution history. - diff --git a/docs/tutorials/translateBot/finishedBot.png b/docs/tutorials/translateBot/finishedBot.png deleted file mode 100644 index 9314b85..0000000 Binary files a/docs/tutorials/translateBot/finishedBot.png and /dev/null differ diff --git a/docs/tutorials/translateBot/hello_bot.png b/docs/tutorials/translateBot/hello_bot.png deleted file mode 100644 index b6d3858..0000000 Binary files a/docs/tutorials/translateBot/hello_bot.png and /dev/null differ diff --git a/docs/tutorials/translateBot/mySlackApp.png b/docs/tutorials/translateBot/mySlackApp.png deleted file mode 100644 index 57928fe..0000000 Binary files a/docs/tutorials/translateBot/mySlackApp.png and /dev/null differ diff --git a/docs/tutorials/translateBot/myTranslateApp-edit.png b/docs/tutorials/translateBot/myTranslateApp-edit.png deleted file mode 100644 index abf9753..0000000 Binary files a/docs/tutorials/translateBot/myTranslateApp-edit.png and /dev/null differ diff --git a/fqn.js b/fqn.js new file mode 100644 index 0000000..44836f7 --- /dev/null +++ b/fqn.js @@ -0,0 +1,46 @@ +/* + * 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' + +/** + * Parses a (possibly fully qualified) entity name and validates it. + * If it's not a fully qualified name, then attempts to qualify it. + * + * Examples: + * foo => /_/foo + * pkg/foo => /_/pkg/foo + * /ns/foo => /ns/foo + * /ns/pkg/foo => /ns/pkg/foo + */ +module.exports = function (name) { + if (typeof name !== 'string') throw new Error('Name must be a string') + if (name.trim().length === 0) throw new Error('Name is not valid') + name = name.trim() + const delimiter = '/' + const parts = name.split(delimiter) + const n = parts.length + const leadingSlash = name[0] === delimiter + // no more than /ns/p/a + if (n < 1 || n > 4 || (leadingSlash && n === 2) || (!leadingSlash && n === 4)) throw new Error('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 Error('Name is not valid') }) + const newName = parts.join(delimiter) + if (leadingSlash) return newName + else if (n < 3) return `${delimiter}_${delimiter}${newName}` + else return `${delimiter}${newName}` +} diff --git a/ibmcloud-utils.js b/ibmcloud-utils.js new file mode 100644 index 0000000..5c70e4a --- /dev/null +++ b/ibmcloud-utils.js @@ -0,0 +1,90 @@ +/* + * 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 fs = require('fs') +const os = require('os') +const path = require('path') + +const getCloudFunctionsConfig = () => { + try { + // read the ibm cloud functions cli config file + const ibmCloudFunctionsPropsPath = + process.env.IC_FN_CONFIG_FILE || path.join(os.homedir(), '.bluemix/plugins/cloud-functions/config.json') + const ibmCloudFunctionsConfig = JSON.parse(fs.readFileSync(ibmCloudFunctionsPropsPath, { encoding: 'utf8' })) + + return ibmCloudFunctionsConfig + } catch (error) { + console.error('Could not open ibmcloud functions plugin config') + throw error + } +} + +const getNamespaceType = () => { + return getCloudFunctionsConfig().WskCliNamespaceMode +} + +const getNamespaceId = () => { + return getCloudFunctionsConfig().WskCliNamespaceId +} + +/** + * return a Apache OpenWhisk Client SDK for JavaScript compliant authentication header token, + * which can be used within a custom authentication handler + * see https://github.com/apache/openwhisk-client-js#using-3rd-party-authentication-handler + * for further details + */ +const getIamAuthHeader = () => { + // for authentication, we'll use the user IAM access token + let iamToken + try { + // read the IAM Access token from the ibm cloud config file + const ibmCloudPropsPath = process.env.IC_CONFIG_FILE || path.join(os.homedir(), '.bluemix/config.json') + const ibmCloudConfig = JSON.parse(fs.readFileSync(ibmCloudPropsPath, { encoding: 'utf8' })) + + iamToken = ibmCloudConfig.IAMToken + } catch (error) { + console.error('Could not open ibmcloud config') + throw error + } + + // return an object that provides a getAuthHeader function to comply with the authentication handler interface + // required by OpenWhisk Client SDK for JavaScript + return iamToken +} + +const getIamTokenTimestamp = () => { + const timestamp = getCloudFunctionsConfig().IamTimeTokenRefreshed + return new Date(timestamp) +} + +const iamTokenExpired = (timeRefreshed, timeReference) => { + if (typeof (timeReference) === 'undefined') { + timeReference = new Date() + } + + // time difference in hours exceeds 1 hour + return (timeReference - timeRefreshed) / 1000 / 3600 > 1 +} + +module.exports = { + iamTokenExpired, + getIamAuthHeader, + getIamTokenTimestamp, + getNamespaceId, + getNamespaceType +} diff --git a/package.json b/package.json index f96bd70..d9e7110 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,47 @@ { + "license": "Apache-2.0", "name": "@ibm-functions/composer", - "version": "0.4.0", - "description": "Composer is an IBM Cloud Functions programming model for composing individual functions into larger applications.", + "version": "1.0.0", + "description": "Composer is a new programming model for composing cloud functions built on Apache OpenWhisk.", "homepage": "https://github.com/ibm-functions/composer", "main": "composer.js", "scripts": { - "test": "mocha" + "test": "standard && mocha" }, "bin": { - "compose": "./bin/compose" + "compose": "./bin/compose.js", + "deploy": "./bin/deploy.js" }, "files": [ "bin/", + "client.js", "composer.js", + "conductor.js", "docs/*.md", - "samples/", - "test/" + "fqn.js", + "ibmcloud-utils.js", + "samples/" ], "repository": { "type": "git", "url": "https://github.com/ibm-functions/composer.git" }, "keywords": [ - "ibm", "functions", "serverless", "composer", - "bluemix", "openwhisk" ], "dependencies": { "minimist": "^1.2.0", - "openwhisk": "^3.11.0", - "semver": "^5.3.0", - "uglify-es": "^3.3.9" + "openwhisk": "^3.21.3", + "terser": "^3.8.2" }, "devDependencies": { - "mocha": "^3.5.0" + "mocha": "^5.2.0", + "mock-fs": "^4.13.0", + "pre-commit": "^1.2.2", + "standard": "^12.0.1" }, "author": { "name": "Olivier Tardieu", @@ -56,5 +61,9 @@ "email": "nickm@us.ibm.com" } ], - "license": "Apache-2.0" + "standard": { + "ignore": [ + "/bin/" + ] + } } diff --git a/samples/demo-conductor.js b/samples/demo-conductor.js deleted file mode 100644 index 6b6df45..0000000 --- a/samples/demo-conductor.js +++ /dev/null @@ -1,105 +0,0 @@ -// generated by composer v0.4.0 - -const composition = { - "type": "if", - "test": { - "type": "action", - "name": "/_/authenticate" - }, - "consequent": { - "type": "action", - "name": "/_/success" - }, - "alternate": { - "type": "action", - "name": "/_/failure" - } -} - -// do not edit below this point - -const main=function({Compiler:e}){const t=new e;function n(e,t){return e.slice(-1)[0].next=1,e.push(...t),e}function s(e){ -return 0===e.length?[{type:"empty"}]:e.map(r).reduce(n)}function r(e){const t=e.path;switch(e.type){case"sequence":return n([{ -type:"pass",path:t}],s(e.components));case"action":return[{type:"action",name:e.name,path:t}];case"function":return[{ -type:"function",exec:e.function.exec,path:t}];case"finally":var o=r(e.body);const u=r(e.finalizer);return(c=[[{type:"try", -path:t}],o,[{type:"exit"}],u].reduce(n))[0].catch=c.length-u.length,c;case"let":o=s(e.components);return[[{type:"let", -let:e.declarations,path:t}],o,[{type:"exit"}]].reduce(n);case"mask":return[[{type:"let",let:null,path:t}],o=s(e.components),[{ -type:"exit"}]].reduce(n);case"try":o=r(e.body);const p=n(r(e.handler),[{type:"pass"}]);return(c=[[{type:"try",path:t}],o,[{ -type:"exit"}]].reduce(n))[0].catch=c.length,c.slice(-1)[0].next=p.length,c.push(...p),c;case"if_nosave": -var a=r(e.consequent),i=n(r(e.alternate),[{type:"pass"}]),c=[[{type:"pass",path:t}],r(e.test),[{type:"choice",then:1, -else:a.length+1}]].reduce(n);return a.slice(-1)[0].next=i.length,c.push(...a),c.push(...i),c;case"while_nosave":a=r(e.body), -i=[{type:"pass"}],c=[[{type:"pass",path:t}],r(e.test),[{type:"choice",then:1,else:a.length+1}]].reduce(n) -;return a.slice(-1)[0].next=1-c.length-a.length,c.push(...a),c.push(...i),c;case"dowhile_nosave":var l=r(e.test);(c=[[{ -type:"pass",path:t}],r(e.body),l,[{type:"choice",then:1,else:2}]].reduce(n)).slice(-1)[0].then=1-c.length,c.slice(-1)[0].else=1 -;i=[{type:"pass"}];return c.push(...i),c}}this.require=require -;const o=r(t.lower(t.label(t.deserialize(composition)))),a=e=>"object"==typeof e&&null!==e&&!Array.isArray(e),i=e=>Promise.reject({ -code:400,error:e}),c=e=>Promise.reject((e=>({code:"number"==typeof e.code&&e.code||500, -error:"string"==typeof e.error&&e.error||e.message||"string"==typeof e&&e||"An internal error occurred"}))(e)) -;return e=>Promise.resolve().then(()=>(function(e){let t=0,n=[];if(void 0!==e.$resume){ -if(!a(e.$resume))return i("The type of optional $resume parameter must be object");if(t=e.$resume.state,n=e.$resume.stack, -void 0!==t&&"number"!=typeof t)return i("The type of optional $resume.state parameter must be number") -;if(!Array.isArray(n))return i("The type of $resume.stack must be an array");delete e.$resume,s()}function s(){if(a(e)||(e={ -value:e}),void 0!==e.error)for(e={error:e.error},t=void 0;n.length>0&&"number"!=typeof(t=n.shift().catch););}function r(t){ -const s=[];let r=0;for(let e of n)null===e.let?r++:void 0!==e.let&&(0===r?s.push(e):r--);function o(e,t){ -const n=s.find(t=>void 0!==t.let&&void 0!==t.let[e]);void 0!==n&&(n.let[e]=JSON.parse(JSON.stringify(t)))} -const a=s.reduceRight((e,t)=>"object"==typeof t.let?Object.assign(e,t.let):e,{});let i="(function(){try{" -;for(const e in a)i+=`var ${e}=arguments[1]['${e}'];`;i+=`return eval((${t}))(arguments[0])}finally{` -;for(const e in a)i+=`arguments[1]['${e}']=${e};`;i+="}})";try{return(0,eval)(i)(e,a)}finally{for(const e in a)o(e,a[e])}} -for(;;){if(void 0===t)return console.log("Entering final state"),console.log(JSON.stringify(e)),e.error?e:{params:e} -;const a=o[t];void 0!==a.path&&console.log(`Entering composition${a.path}`);const i=t;switch(t=void 0===a.next?void 0:i+a.next, -a.type){case"choice":t=i+(e.value?a.then:a.else);break;case"try":n.unshift({catch:i+a.catch});break;case"let":n.unshift({ -let:JSON.parse(JSON.stringify(a.let))});break;case"exit": -if(0===n.length)return c(`State ${i} attempted to pop from an empty stack`);n.shift();break;case"action":return{action:a.name, -params:e,state:{$resume:{state:t,stack:n}}};case"function":let o;try{o=r(a.exec.code)}catch(e){console.error(e),o={ -error:`An exception was caught at state ${i} (see log for details)`}}"function"==typeof o&&(o={ -error:`State ${i} evaluated to a function`}),e=JSON.parse(JSON.stringify(void 0===o?e:o)),s();break;case"empty":s();break -;case"pass":break;default:return c(`State ${i} has an unknown type`)}}})(e)).catch(c)}(function(){ -const e=require("util"),t=require("semver"),n={empty:{since:"0.4.0"},seq:{components:!0,since:"0.4.0"},sequence:{components:!0, -since:"0.4.0"},if:{args:[{_:"test"},{_:"consequent"},{_:"alternate",optional:!0}],since:"0.4.0"},if_nosave:{args:[{_:"test"},{ -_:"consequent"},{_:"alternate",optional:!0}],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:!0,since:"0.4.0"},retain_catch:{components:!0,since:"0.4.0"},let:{args:[{ -_:"declarations",type:"object"}],components:!0,since:"0.4.0"},mask:{components:!0,since:"0.4.0"},action:{args:[{_:"name", -type:"string"},{_:"action",type:"object",optional:!0}],since:"0.4.0"},composition:{args:[{_:"name",type:"string"},{ -_:"composition"}],since:"0.4.0"},repeat:{args:[{_:"count",type:"number"}],components:!0,since:"0.4.0"},retry:{args:[{_:"count", -type:"number"}],components:!0,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"}};class s extends Error{ -constructor(t,n){super(t+(void 0!==n?"\nArgument: "+e.inspect(n):""))}}class Composition{static[Symbol.hasInstance](e){ -return e.constructor&&e.constructor.name===Composition.name}constructor(e){return Object.assign(this,e)}visit(e){ -const t=n[this.type];t.components&&(this.components=this.components.map(e)) -;for(let n of t.args||[])void 0===n.type&&(this[n._]=e(this[n._],n._))}}class r{task(e){ -if(arguments.length>1)throw new s("Too many arguments");if(null===e)return this.empty();if(e instanceof Composition)return e -;if("function"==typeof e)return this.function(e);if("string"==typeof e)return this.action(e);throw new s("Invalid argument",e)} -function(e){if(arguments.length>1)throw new s("Too many arguments") -;if("function"==typeof e&&-1!==(e=`${e}`).indexOf("[native code]"))throw new s("Cannot capture native function",e) -;if("string"==typeof e&&(e={kind:"nodejs:default",code:e}),"object"!=typeof e||null===e)throw new s("Invalid argument",e) -;return new Composition({type:"function",function:{exec:e}})}_empty(){return this.sequence()}_seq(e){ -return this.sequence(...e.components)}_value(e){return this._literal(e)}_literal(e){return this.let({value:e.value},()=>value)} -_retain(e){return this.let({params:null},e=>{params=e},this.mask(...e.components),e=>({params:params,result:e}))} -_retain_catch(e){return this.seq(this.retain(this.finally(this.seq(...e.components),e=>({result:e}))),({params:e,result:t})=>({ -params:e,result:t.result}))}_if(e){return this.let({params:null},e=>{params=e -},this.if_nosave(this.mask(e.test),this.seq(()=>params,this.mask(e.consequent)),this.seq(()=>params,this.mask(e.alternate))))} -_while(e){return this.let({params:null},e=>{params=e -},this.while_nosave(this.mask(e.test),this.seq(()=>params,this.mask(e.body),e=>{params=e})),()=>params)}_dowhile(e){ -return this.let({params:null},e=>{params=e},this.dowhile_nosave(this.seq(()=>params,this.mask(e.body),e=>{params=e -}),this.mask(e.test)),()=>params)}_repeat(e){return this.let({count:e.count -},this.while(()=>count-- >0,this.mask(this.seq(...e.components))))}_retry(e){return this.let({count:e.count},e=>({params:e -}),this.dowhile(this.finally(({params:e})=>e,this.mask(this.retain_catch(...e.components))),({result:e})=>void 0!==e.error&&count-- >0),({result:e})=>e) -}static init(){for(let e in n){const t=n[e];r.prototype[e]=r.prototype[e]||function(){const n=new Composition({type:e -}),r=t.args&&t.args.length||0;if(!t.components&&arguments.length>r)throw new s("Too many arguments");for(let e=0;ethis.task(e))),n}}} -get combinators(){return n}deserialize(e){if(arguments.length>1)throw new s("Too many arguments") -;return(e=new Composition(e)).visit(e=>this.deserialize(e)),e}label(e){if(arguments.length>1)throw new s("Too many arguments") -;if(!(e instanceof Composition))throw new s("Invalid argument",e) -;const t=e=>(n,s,r)=>((n=new Composition(n)).path=e+(void 0!==s?void 0===r?`.${s}`:`[${s}]`:""),n.visit(t(n.path)),n) -;return t("")(e)}lower(e,n=[]){if(arguments.length>2)throw new s("Too many arguments") -;if(!(e instanceof Composition))throw new s("Invalid argument",e) -;if(!Array.isArray(n)&&"boolean"!=typeof n&&"string"!=typeof n)throw new s("Invalid argument",n);if(!1===n)return e -;!0!==n&&""!==n||(n=[]),"string"==typeof n&&(n=Object.keys(this.combinators).filter(e=>t.gte(n,this.combinators[e].since))) -;const r=e=>{for(e=new Composition(e);n.indexOf(e.type)<0&&this[`_${e.type}`];){const t=e.path;e=this[`_${e.type}`](e), -void 0!==t&&(e.path=t)}return e.visit(r),e};return r(e)}}return r.init(),{ComposerError:s,Composition:Composition,Compiler:r} -}()); diff --git a/samples/demo.js b/samples/demo.js index 71cc939..12c632f 100644 --- a/samples/demo.js +++ b/samples/demo.js @@ -1,11 +1,12 @@ /* - * Copyright 2017 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, @@ -14,7 +15,9 @@ * limitations under the License. */ -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' } } })) +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' } } })) diff --git a/samples/demo.json b/samples/demo.json index 1ecf1d8..d02055d 100644 --- a/samples/demo.json +++ b/samples/demo.json @@ -1,33 +1,143 @@ { - "type": "if", - "test": { - "type": "action", - "name": "/_/authenticate", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function ({ password }) { return { value: password === 'abc123' } }" + "composition": { + "type": "let", + "declarations": { + "params": null + }, + "components": [ + { + "type": "finally", + "body": { + "type": "function", + "function": { + "exec": { + "kind": "nodejs:default", + "code": "args => { params = args }" + } + } + }, + "finalizer": { + "type": "if_nosave", + "test": { + "type": "mask", + "components": [ + { + "type": "action", + "name": "/_/authenticate", + "path": ".test" + } + ] + }, + "consequent": { + "type": "finally", + "body": { + "type": "function", + "function": { + "exec": { + "kind": "nodejs:default", + "code": "() => params" + } + } + }, + "finalizer": { + "type": "mask", + "components": [ + { + "type": "action", + "name": "/_/success", + "path": ".consequent" + } + ] + } + }, + "alternate": { + "type": "finally", + "body": { + "type": "function", + "function": { + "exec": { + "kind": "nodejs:default", + "code": "() => params" + } + } + }, + "finalizer": { + "type": "mask", + "components": [ + { + "type": "action", + "name": "/_/failure", + "path": ".alternate" + } + ] + } + } + } } - } + ], + "path": "" }, - "consequent": { - "type": "action", - "name": "/_/success", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'success' } }" + "ast": { + "type": "if", + "test": { + "type": "action", + "name": "/_/authenticate", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function ({ password }) { return { value: password === 'abc123' } }" + } + } + }, + "consequent": { + "type": "action", + "name": "/_/success", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function () { return { message: 'success' } }" + } + } + }, + "alternate": { + "type": "action", + "name": "/_/failure", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function () { return { message: 'failure' } }" + } } } }, - "alternate": { - "type": "action", - "name": "/_/failure", - "action": { - "exec": { - "kind": "nodejs:default", - "code": "const main = function () { return { message: 'failure' } }" + "version": "0.8.0", + "actions": [ + { + "name": "/_/authenticate", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function ({ password }) { return { value: password === 'abc123' } }" + } + } + }, + { + "name": "/_/success", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function () { return { message: 'success' } }" + } + } + }, + { + "name": "/_/failure", + "action": { + "exec": { + "kind": "nodejs:default", + "code": "const main = function () { return { message: 'failure' } }" + } } } - } + ] } diff --git a/samples/node-demo.js b/samples/node-demo.js deleted file mode 100644 index 5956715..0000000 --- a/samples/node-demo.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2017 IBM Corporation - * - * 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 - * - * 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. - */ - -// require the composer module -const composer = require('@ibm-functions/composer') - -// define the composition -const composition = 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' } } })) - -// instantiate OpenWhisk client -const wsk = composer.openwhisk({ ignore_certs: true }) - -wsk.compositions.deploy(composer.composition('demo', composition)) // deploy composition - .then(() => wsk.actions.invoke({ name: 'demo', params: { password: 'abc123' }, blocking: true })) // invoke composition - .then(({ response }) => console.log(JSON.stringify(response.result, null, 4)), console.error) diff --git a/samples/retry.js b/samples/retry.js new file mode 100644 index 0000000..f0f7e14 --- /dev/null +++ b/samples/retry.js @@ -0,0 +1,38 @@ +/* + * 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. + */ + +const composer = require('@ibm-functions/composer') + +function generateEvenNumber (args) { + const number = Math.floor(Math.random() * 10) + console.log(`generated number ${number}`) + + if (number % 2 !== 0) { + throw new Error(`error: number is not even`) + } + + return { value: number } +} + +function useDefaultNumber (args) { + console.log(`handling error, using default value`) + return { value: 42 } +} + +module.exports = composer.try( + composer.retry(2, composer.action('generateEvenNumber', { action: generateEvenNumber })), + composer.action('useDefaultNumber', { action: useDefaultNumber })) diff --git a/test/cf-plugin-config.json b/test/cf-plugin-config.json new file mode 100644 index 0000000..8047c6e --- /dev/null +++ b/test/cf-plugin-config.json @@ -0,0 +1,3 @@ +{ + "WskCliNamespaceMode": "CF" +} \ No newline at end of file diff --git a/test/composer.js b/test/composer.js new file mode 100644 index 0000000..ce0fc0c --- /dev/null +++ b/test/composer.js @@ -0,0 +1,433 @@ +/* + * 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-env mocha */ + +'use strict' + +const assert = require('assert') +const composer = require('../composer') + +function check (combinator, n, p, name) { + if (n === undefined) { + it('variable argument count', function () { + for (let i = 0; i < 5; i++) composer[combinator](...Array(i).fill('foo')) + for (let i = 0; i < 5; i++) composer[combinator](...Array(i).fill(() => { })) + }) + } else { + it('argument count', function () { + for (let i = n; i <= (p || n); i++) composer[combinator](...Array(i).fill('foo')) + }) + it('too many arguments', function () { + try { + composer[combinator](...Array((p || n) + 1).fill('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + if (n > 0) { + it('too few arguments', function () { + try { + composer[combinator](...Array(n - 1).fill('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + } + } + it('combinator type', function () { + assert.ok(composer[combinator](...Array(n || 0).fill('foo')).type === name || combinator) + }) +} + +describe('composer', function () { + describe('composer.action', function () { + it('argument count', function () { + composer.action('foo') + }) + + it('too many arguments', function () { + try { + composer.action('foo', {}, 'foo') + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + + it('too few arguments', function () { + try { + composer.action() + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Name must be a string')) + } + }) + + it('combinator type', function () { + assert.ok(composer.action('foo').type === 'action') + }) + + it('valid and invalid names', function () { + let combos = [ + { n: 42, s: false, e: 'Name must be a string' }, + { n: '', s: false, e: 'Name is not valid' }, + { n: ' ', s: false, e: 'Name is not valid' }, + { n: '/', s: false, e: 'Name is not valid' }, + { n: '//', s: false, e: 'Name is not valid' }, + { n: '/a', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, + { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/ /b', s: false, e: 'Name is not valid' }, + { n: 'a', e: false, s: '/_/a' }, + { n: 'a/b', e: false, s: '/_/a/b' }, + { n: 'a/b/c', e: false, s: '/a/b/c' }, + { n: '/a/b', e: false, s: '/a/b' }, + { n: '/a/b/c', e: false, s: '/a/b/c' } + ] + combos.forEach(({ n, s, e }) => { + if (s) { + // good cases + assert.ok(composer.action(n).name, s) + } else { + // error cases + try { + composer.action(n) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith(e)) + } + } + }) + }) + + it('valid and invalid options', function () { + composer.action('foo', {}) + try { + composer.action('foo', 42) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('composer.function', function () { + check('function', 1) + + it('function', function () { + composer.function(() => { }) + }) + + it('string', function () { + composer.function('() => {}') + }) + + it('number (invalid)', function () { + try { + composer.function(42) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('composer.literal', function () { + check('literal', 1) + + it('boolean', function () { + composer.literal(true) + }) + + it('number', function () { + composer.literal(42) + }) + + it('string', function () { + composer.literal('foo') + }) + + it('dictionary', function () { + composer.literal({ foo: 42 }) + }) + + it('function (invalid)', function () { + try { + composer.literal(() => { }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('composer.value', function () { + check('value', 1) + + it('boolean', function () { + composer.value(true) + }) + + it('number', function () { + composer.value(42) + }) + + it('string', function () { + composer.value('foo') + }) + + it('dictionary', function () { + composer.value({ foo: 42 }) + }) + + it('function (invalid)', function () { + try { + composer.value(() => { }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('composer.parse', function () { + it('argument count', function () { + composer.parse({ 'type': 'sequence', 'components': [] }) + }) + + it('too many arguments', function () { + try { + composer.parse({ 'type': 'sequence', 'components': [] }, 'foo') + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + + it('too few arguments', function () { + try { + composer.parse() + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('combinator type', function () { + assert.ok(composer.parse({ + 'type': 'sequence', + 'components': [{ + 'type': 'action', + 'name': 'echo' + }, { + 'type': 'action', + 'name': 'echo' + }] + }).type === 'sequence') + }) + }) + + describe('composer.task', function () { + check('task', 1, 1, 'action') + + it('string', function () { + composer.task('isNotOne') + }) + + it('function', function () { + composer.task(() => { }) + }) + + it('null', function () { + composer.task(null) + }) + + it('boolean (invalid)', function () { + try { + composer.task(false) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('number (invalid)', function () { + try { + composer.task(42) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('dictionary (invalid)', function () { + try { + composer.task({ foo: 42 }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('composer.let', function () { + it('variable argument count', function () { + composer.let({}) + composer.let({}, 'foo') + composer.let({}, 'foo', 'foo') + }) + + it('too few arguments', function () { + try { + composer.let() + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('combinator type', function () { + assert.ok(composer.let({}).type === 'let') + }) + }) + + describe('composer.repeat', function () { + it('variable argument count', function () { + composer.repeat(42) + composer.repeat(42, 'foo') + composer.repeat(42, 'foo', 'foo') + }) + + it('too few arguments', function () { + try { + composer.repeat() + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('combinator type', function () { + assert.ok(composer.repeat(42).type === 'repeat') + }) + }) + + describe('composer.retry', function () { + it('variable argument count', function () { + composer.retry(42) + composer.retry(42, 'foo') + composer.retry(42, 'foo', 'foo') + }) + + it('too few arguments', function () { + try { + composer.retry() + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('combinator type', function () { + assert.ok(composer.retry(42).type === 'retry') + }) + }) + + describe('composer.if', function () { + check('if', 2, 3) + }) + + describe('composer.if_nosave', function () { + check('if_nosave', 2, 3) + }) + + describe('composer.while', function () { + check('while', 2) + }) + + describe('composer.while_nosave', function () { + check('while_nosave', 2) + }) + + describe('composer.dowhile', function () { + check('dowhile', 2) + }) + + describe('composer.dowhile_nosave', function () { + check('dowhile_nosave', 2) + }) + + describe('composer.try', function () { + check('try', 2) + }) + + describe('composer.finally', function () { + check('finally', 2) + }) + + describe('composer.empty', function () { + check('empty', 0) + }) + + describe('composer.mask', function () { + check('mask') + }) + + describe('composer.async', function () { + check('async') + }) + + describe('composer.retain', function () { + check('retain') + }) + + describe('composer.retain_catch', function () { + check('retain_catch') + }) + + describe('composer.sequence', function () { + check('sequence') + }) + + describe('composer.seq', function () { + check('seq') + }) + + describe('composer.merge', function () { + check('merge') + }) + + describe('composer.parallel', function () { + check('parallel') + }) + + describe('composer.par', function () { + check('par') + }) + + describe('composer.map', function () { + check('map') + }) + + describe('composer.dynamic', function () { + check('dynamic', 0) + }) +}) diff --git a/test/conductor.js b/test/conductor.js new file mode 100644 index 0000000..9e89f01 --- /dev/null +++ b/test/conductor.js @@ -0,0 +1,639 @@ +/* + * 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-env mocha */ + +'use strict' + +const assert = require('assert') +const composer = require('../composer') +const wsk = require('../client')() +const name = 'TestAction' + +// deploy action +const define = action => wsk.actions.delete(action.name).catch(() => { }).then(() => wsk.actions.create(action)) + +// deploy and invoke composition +const invoke = (composition, params = {}, blocking = true) => wsk.compositions.deploy(Object.assign({ name }, composition.compile()), true) + .then(() => wsk.actions.invoke({ name, params, blocking })) + .then(activation => activation.response.success ? activation : Promise.reject(Object.assign(new Error(), { error: activation }))) + +// redis configuration +const redis = process.env.REDIS ? { uri: process.env.REDIS } : false +if (process.env.REDIS && process.env.REDIS_CA) redis.ca = process.env.REDIS_CA + +// openwhisk configuration +const openwhisk = process.env.__OW_IGNORE_CERTS ? { ignore_certs: true } : {} + +describe('composer', function () { + let n, x, y // dummy variables + + this.timeout(60000) + + before('deploy test actions', function () { + if (!redis) console.error('------------------------------------------------\nMissing redis configuration, skipping some tests\n------------------------------------------------') + return define({ name: 'echo', action: 'const main = x=>x' }) + .then(() => define({ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' })) + .then(() => define({ name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' })) + .then(() => define({ name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' })) + .then(() => define({ name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' })) + .then(() => wsk.compositions.deploy(Object.assign({ name: '_DivideByTwo' }, composer.seq('DivideByTwo').compile()), true)) + }) + + describe('blocking invocations', function () { + describe('actions', function () { + it('action must return true', function () { + return invoke(composer.action('isNotOne'), { n: 0 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('action must return false', function () { + return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: false })) + }) + + it('action must return activationId', function () { + return invoke(composer.async('isNotOne'), { n: 1, $composer: { openwhisk } }).then(activation => assert.ok(activation.response.result.activationId)) + }) + + it('action name must parse to fully qualified', function () { + let combos = [ + { n: 42, s: false, e: 'Name must be a string' }, + { n: '', s: false, e: 'Name is not valid' }, + { n: ' ', s: false, e: 'Name is not valid' }, + { n: '/', s: false, e: 'Name is not valid' }, + { n: '//', s: false, e: 'Name is not valid' }, + { n: '/a', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, + { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/ /b', s: false, e: 'Name is not valid' }, + { n: 'a', e: false, s: '/_/a' }, + { n: 'a/b', e: false, s: '/_/a/b' }, + { n: 'a/b/c', e: false, s: '/a/b/c' }, + { n: '/a/b', e: false, s: '/a/b' }, + { n: '/a/b/c', e: false, s: '/a/b/c' } + ] + combos.forEach(({ n, s, e }) => { + if (s) { + // good cases + assert.ok(composer.action(n).name, s) + } else { + // error cases + try { + composer.action(n) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith(e)) + } + } + }) + }) + + it('invalid options', function () { + try { + invoke(composer.action('foo', 42)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.action('foo', {}, 'foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('dynamic', function () { + it('dynamic action invocation', function () { + return invoke(composer.dynamic(), { type: 'action', name: 'DivideByTwo', params: { n: 42 } }).then(activation => assert.deepStrictEqual(activation.response.result, { n: 21 })) + }) + + it('missing type', function () { + return invoke(composer.dynamic(), { name: 'DivideByTwo', params: { n: 42 } }).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error)) + }) + + it('invalid type', function () { + return invoke(composer.dynamic(), { type: 42, name: 'DivideByTwo', params: { n: 42 } }).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error)) + }) + + it('missing name', function () { + return invoke(composer.dynamic(), { type: 'action', params: { n: 42 } }).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error)) + }) + + it('missing params', function () { + return invoke(composer.dynamic(), { type: 'action', name: 'DivideByTwo' }).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error)) + }) + }) + + describe('literals', function () { + it('true', function () { + return invoke(composer.literal(true)).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('42', function () { + return invoke(composer.literal(42)).then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('invalid argument', function () { + try { + invoke(composer.literal(invoke)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.literal('foo', 'foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('functions', function () { + it('function must return true', function () { + return invoke(composer.function(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('function must return false', function () { + return invoke(composer.function(function ({ n }) { return n % 2 === 0 }), { n: 3 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: false })) + }) + + it('function must fail', function () { + return invoke(composer.function(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error)) + }) + + it('function must throw', function () { + return invoke(composer.function(() => ({ error: 'foo', n: 42 }))).then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' })) + }) + + it('function must mutate params', function () { + return invoke(composer.function(params => { params.foo = 'foo' }), { n: 42 }).then(activation => assert.deepStrictEqual(activation.response.result, { foo: 'foo', n: 42 })) + }) + + it('function as string', function () { + return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('function may return a promise', function () { + return invoke(composer.function(({ n }) => Promise.resolve(n % 2 === 0)), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('invalid argument', function () { + try { + invoke(composer.function(42)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('too many arguments', function () { + try { + invoke(composer.function(() => n, () => { })) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('deserialize', function () { + it('should deserialize a serialized composition', function () { + const json = { + 'type': 'sequence', + 'components': [{ + 'type': 'action', + 'name': 'echo' + }, { + 'type': 'action', + 'name': 'echo' + }] + } + return invoke(composer.parse(json), { message: 'hi' }).then(activation => assert.deepStrictEqual(activation.response.result, { message: 'hi' })) + }) + }) + + describe('tasks', function () { + describe('action tasks', function () { + it('action must return true', function () { + return invoke(composer.task('isNotOne'), { n: 0 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + }) + + describe('function tasks', function () { + it('function must return true', function () { + return invoke(composer.task(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + }) + + describe('null task', function () { + it('null task must return input', function () { + return invoke(composer.task(null), { foo: 'foo' }).then(activation => assert.deepStrictEqual(activation.response.result, { foo: 'foo' })) + }) + + it('null task must fail on error input', function () { + return invoke(composer.task(null), { error: 'foo' }).then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' })) + }) + }) + + describe('invalid tasks', function () { + it('a Boolean is not a valid task', function () { + try { + invoke(composer.task(false)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('a number is not a valid task', function () { + try { + invoke(composer.task(42)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + + it('a dictionary is not a valid task', function () { + try { + invoke(composer.task({ foo: 'foo' })) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + it('too many arguments', function () { + try { + invoke(composer.task('foo', 'foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('combinators', function () { + describe('sequence', function () { + it('flat', function () { + return invoke(composer.sequence('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 })) + }) + + it('nested right', function () { + return invoke(composer.sequence('TripleAndIncrement', composer.sequence('DivideByTwo', 'DivideByTwo')), { n: 5 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 })) + }) + + it('nested left', function () { + return invoke(composer.sequence(composer.sequence('TripleAndIncrement', 'DivideByTwo'), 'DivideByTwo'), { n: 5 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 })) + }) + + it('seq', function () { + return invoke(composer.seq('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 4 })) + }) + }) + + describe('parallel', function () { + const test = redis ? it : it.skip + test('parallel', function () { + return invoke(composer.parallel('TripleAndIncrement', 'DivideByTwo'), { n: 42, $composer: { redis, openwhisk } }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: [{ n: 127 }, { n: 21 }] })) + }) + + test('par', function () { + return invoke(composer.par('DivideByTwo', 'TripleAndIncrement', 'isEven'), { n: 42, $composer: { redis, openwhisk } }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: [{ n: 21 }, { n: 127 }, { value: true }] })) + }) + + test('map', function () { + return invoke(composer.map('TripleAndIncrement', 'DivideByTwo'), { value: [{ n: 3 }, { n: 5 }, { n: 7 }], $composer: { redis, openwhisk } }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: [{ n: 5 }, { n: 8 }, { n: 11 }] })) + }) + }) + + describe('if', function () { + it('condition = true', function () { + return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 2 })) + }) + + it('condition = false', function () { + return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 10 })) + }) + + it('condition = true, then branch only', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 2 })) + }) + + it('condition = false, then branch only', function () { + return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 3 })) + }) + + it('condition = true, nosave option', function () { + return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 2 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: true, then: true })) + }) + + it('condition = false, nosave option', function () { + return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, else: true })) + }) + + it('too many arguments', function () { + try { + invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('while', function () { + it('a few iterations', function () { + return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 })) + }) + + it('no iteration', function () { + return invoke(composer.while(() => false, ({ n }) => ({ n: n - 1 })), { n: 1 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 })) + }) + + it('nosave option', function () { + return invoke(composer.while_nosave(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, n: 1 })) + }) + + it('too many arguments', function () { + try { + invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('dowhile', function () { + it('a few iterations', function () { + return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne'), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 })) + }) + + it('one iteration', function () { + return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), () => false), { n: 1 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 0 })) + }) + + it('nosave option', function () { + return invoke(composer.dowhile_nosave(({ n }) => ({ n: n - 1 }), ({ n }) => ({ n, value: n !== 1 })), { n: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: false, n: 1 })) + }) + + it('too many arguments', function () { + try { + invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('try', function () { + it('no error', function () { + return invoke(composer.try(() => true, error => ({ message: error.error }))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: true })) + }) + + it('error', function () { + return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error }))) + .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' })) + }) + + it('try must throw', function () { + return invoke(composer.try(composer.task(null), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' })) + }) + + it('while must throw', function () { + return invoke(composer.try(composer.while(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' })) + }) + + it('if must throw', function () { + return invoke(composer.try(composer.if(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' }) + .then(activation => assert.deepStrictEqual(activation.response.result, { message: 'foo' })) + }) + + it('retain', function () { + return invoke(composer.retain(composer.try(() => ({ p: 4 }), null)), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { p: 4 } })) + }) + + it('too many arguments', function () { + try { + invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('finally', function () { + it('no error', function () { + return invoke(composer.finally(() => true, params => ({ params }))) + .then(activation => assert.deepStrictEqual(activation.response.result, { params: { value: true } })) + }) + + it('error', function () { + return invoke(composer.finally(() => ({ error: 'foo' }), params => ({ params }))) + .then(activation => assert.deepStrictEqual(activation.response.result, { params: { error: 'foo' } })) + }) + + it('too many arguments', function () { + try { + invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Too many arguments')) + } + }) + }) + + describe('let', function () { + it('one variable', function () { + return invoke(composer.let({ x: 42 }, () => x)) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('masking', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 })) + }) + + it('two variables', function () { + return invoke(composer.let({ x: 42 }, composer.let({ y: 69 }, () => x + y))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 })) + }) + + it('two variables combined', function () { + return invoke(composer.let({ x: 42, y: 69 }, () => x + y)) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 })) + }) + + it('scoping', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x), ({ value }) => value + x)) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 111 })) + }) + + it('invalid argument', function () { + try { + invoke(composer.let(invoke)) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('mask', function () { + it('let/let/mask', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, composer.mask(() => x)))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('let/mask/let', function () { + return invoke(composer.let({ x: 42 }, composer.mask(composer.let({ x: 69 }, () => x)))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 })) + }) + + it('let/let/try/mask', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, + composer.try(composer.mask(() => x), () => { })))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('let/let/let/mask', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, + composer.let({ x: -1 }, composer.mask(() => x))))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 69 })) + }) + + it('let/let/let/mask/mask', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, + composer.let({ x: -1 }, composer.mask(composer.mask(() => x)))))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('let/let/mask/let/mask', function () { + return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, + composer.mask(composer.let({ x: -1 }, composer.mask(() => x)))))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + }) + + describe('retain', function () { + it('base case', function () { + return invoke(composer.retain('TripleAndIncrement'), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) + }) + + it('throw error', function () { + return invoke(composer.retain(() => ({ error: 'foo' })), { n: 3 }) + .then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result, { error: 'foo' })) + }) + + it('catch error', function () { + return invoke(composer.retain_catch(() => ({ error: 'foo' })), { n: 3 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { params: { n: 3 }, result: { error: 'foo' } })) + }) + }) + + describe('merge', function () { + it('base case', function () { + return invoke(composer.merge('TripleAndIncrement'), { n: 3, p: 4 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 10, p: 4 })) + }) + }) + + describe('repeat', function () { + it('a few iterations', function () { + return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 })) + }) + + it('invalid argument', function () { + try { + invoke(composer.repeat('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + + describe('retry', function () { + it('success', function () { + return invoke(composer.let({ x: 2 }, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42))) + .then(activation => assert.deepStrictEqual(activation.response.result, { value: 42 })) + }) + + it('failure', function () { + return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) + .then(() => assert.fail(), activation => assert.deepStrictEqual(activation.error.response.result.error, 'foo')) + }) + + it('invalid argument', function () { + try { + invoke(composer.retry('foo')) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith('Invalid argument')) + } + }) + }) + }) + }) + + describe('compositions', function () { + describe('collatz', function () { + it('composition must return { n: 1 }', function () { + return invoke(composer.while('isNotOne', composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement')), { n: 5 }) + .then(activation => assert.deepStrictEqual(activation.response.result, { n: 1 })) + }) + }) + }) +}) diff --git a/test/fqn.js b/test/fqn.js new file mode 100644 index 0000000..1c7afe3 --- /dev/null +++ b/test/fqn.js @@ -0,0 +1,64 @@ +/* + * 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-env mocha */ + +'use strict' + +const assert = require('assert') +const fqn = require('../fqn') + +describe('fqn', function () { + let combos = [ + { n: undefined, s: false, e: 'Name must be a string' }, + { n: null, s: false, e: 'Name must be a string' }, + { n: 0, s: false, e: 'Name must be a string' }, + { n: 42, s: false, e: 'Name must be a string' }, + { n: true, s: false, e: 'Name must be a string' }, + { n: false, s: false, e: 'Name must be a string' }, + { n: '', s: false, e: 'Name is not valid' }, + { n: ' ', s: false, e: 'Name is not valid' }, + { n: '/', s: false, e: 'Name is not valid' }, + { n: '//', s: false, e: 'Name is not valid' }, + { n: '/a', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, + { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, + { n: '/a/ /b', s: false, e: 'Name is not valid' }, + { n: 'a', e: false, s: '/_/a' }, + { n: 'a/b', e: false, s: '/_/a/b' }, + { n: 'a/b/c', e: false, s: '/a/b/c' }, + { n: '/a/b', e: false, s: '/a/b' }, + { n: '/a/b/c', e: false, s: '/a/b/c' } + ] + combos.forEach(({ n, s, e }) => { + it(typeof n === 'string' ? `'${n}'` : `${n}`, function () { + if (s) { + // good cases + assert.strictEqual(fqn(n), s) + } else { + // error cases + try { + fqn(n) + assert.fail() + } catch (error) { + assert.ok(error.message.startsWith(e)) + } + } + }) + }) +}) diff --git a/test/ibmcloud-utils.js b/test/ibmcloud-utils.js new file mode 100644 index 0000000..34f99a6 --- /dev/null +++ b/test/ibmcloud-utils.js @@ -0,0 +1,69 @@ +/* + * 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-env mocha */ + +'use strict' + +const assert = require('assert') +const mock = require('mock-fs') +const path = require('path') +const os = require('os') + +const ibmcloudUtils = require('../ibmcloud-utils') +const client = require('../client') + +describe('ibmcloud-utils', function () { + describe('ibmcloud-utils.token-expiration', function () { + const ibmCloudFunctionsPropsPath = + process.env.IC_FN_CONFIG_FILE || path.join(os.homedir(), '.bluemix/plugins/cloud-functions/config.json') + const ibmCloudPropsPath = process.env.IC_CONFIG_FILE || path.join(os.homedir(), '.bluemix/config.json') + + it('read timestamp', function () { + mock({ + [ibmCloudFunctionsPropsPath]: '{ "IamTimeTokenRefreshed": "2021-03-15T13:24:14+01:00" }' + }) + const timestamp = ibmcloudUtils.getIamTokenTimestamp() + assert.strictEqual(timestamp.getTime(), Date.UTC(2021, 2, 15, 12, 24, 14)) + }) + + it('token not expired', function () { + const timeRefreshed = new Date(2021, 2, 13, 13, 24, 14) + const timeReference = new Date(2021, 2, 13, 13, 30, 0) + const tokenExpired = ibmcloudUtils.iamTokenExpired(timeRefreshed, timeReference) + assert.strictEqual(tokenExpired, false) + }) + + it('token expired', function () { + const timeRefreshed = new Date(2021, 2, 13, 13, 24, 14) + const timeReference = new Date(2021, 2, 13, 14, 25, 0) + const tokenExpired = ibmcloudUtils.iamTokenExpired(timeRefreshed, timeReference) + assert.strictEqual(tokenExpired, true) + }) + + it('client fails when token expired', function () { + mock({ + [ibmCloudFunctionsPropsPath]: '{ "IamTimeTokenRefreshed": "2021-03-14T12:00:00+01:00", ' + + '"WskCliNamespaceId": "some-namespace-id", ' + + '"WskCliNamespaceMode": "IAM" }', + [ibmCloudPropsPath]: '{ "IAMToken": "some-token" }' + }) + + assert.throws(() => client(), /IAM token expired/) + }) + }) +}) diff --git a/test/test.js b/test/test.js deleted file mode 100644 index eed442f..0000000 --- a/test/test.js +++ /dev/null @@ -1,551 +0,0 @@ -const assert = require('assert') -const composer = require('../composer') -const name = 'TestAction' -const wsk = composer.openwhisk({ ignore_certs: process.env.IGNORE_CERTS && process.env.IGNORE_CERTS !== 'false' && process.env.IGNORE_CERTS !== '0' }) - -// deploy action -const define = action => wsk.actions.delete(action.name).catch(() => { }).then(() => wsk.actions.create(action)) - -// deploy and invoke composition -const invoke = (task, params = {}, blocking = true) => wsk.compositions.deploy(composer.composition(name, task)).then(() => wsk.actions.invoke({ name, params, blocking })) - -describe('composer', function () { - this.timeout(60000) - - before('deploy test actions', function () { - return define({ name: 'echo', action: 'const main = x=>x' }) - .then(() => define({ name: 'DivideByTwo', action: 'function main({n}) { return { n: n / 2 } }' })) - .then(() => define({ name: 'TripleAndIncrement', action: 'function main({n}) { return { n: n * 3 + 1 } }' })) - .then(() => define({ name: 'isNotOne', action: 'function main({n}) { return { value: n != 1 } }' })) - .then(() => define({ name: 'isEven', action: 'function main({n}) { return { value: n % 2 == 0 } }' })) - }) - - - describe('blocking invocations', function () { - describe('actions', function () { - it('action must return true', function () { - return invoke(composer.action('isNotOne'), { n: 0 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - - it('action must return false', function () { - return invoke(composer.action('isNotOne'), { n: 1 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) - }) - - it('action name must parse to fully qualified', function () { - let combos = [ - { n: '', s: false, e: 'Name is not specified' }, - { n: ' ', s: false, e: 'Name is not specified' }, - { n: '/', s: false, e: 'Name is not valid' }, - { n: '//', s: false, e: 'Name is not valid' }, - { n: '/a', s: false, e: 'Name is not valid' }, - { n: '/a/b/c/d', s: false, e: 'Name is not valid' }, - { n: '/a/b/c/d/', s: false, e: 'Name is not valid' }, - { n: 'a/b/c/d', s: false, e: 'Name is not valid' }, - { n: '/a/ /b', s: false, e: 'Name is not valid' }, - { n: 'a', e: false, s: '/_/a' }, - { n: 'a/b', e: false, s: '/_/a/b' }, - { n: 'a/b/c', e: false, s: '/a/b/c' }, - { n: '/a/b', e: false, s: '/a/b' }, - { n: '/a/b/c', e: false, s: '/a/b/c' } - ] - combos.forEach(({ n, s, e }) => { - if (s) { - // good cases - assert.ok(composer.action(n).name, s) - } else { - // error cases - try { - composer.action(n) - assert.fail() - } catch (error) { - assert.ok(error.message == e) - } - } - }) - }) - - it('invalid argument', function () { - try { - invoke(composer.function(42)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - - it('too many arguments', function () { - try { - invoke(composer.function('foo', 'foo')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('literals', function () { - it('true', function () { - return invoke(composer.literal(true)).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - - it('42', function () { - return invoke(composer.literal(42)).then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('invalid argument', function () { - try { - invoke(composer.literal(invoke)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - - it('too many arguments', function () { - try { - invoke(composer.literal('foo', 'foo')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('functions', function () { - it('function must return true', function () { - return invoke(composer.function(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - - it('function must return false', function () { - return invoke(composer.function(function ({ n }) { return n % 2 === 0 }), { n: 3 }).then(activation => assert.deepEqual(activation.response.result, { value: false })) - }) - - it('function must fail', function () { - return invoke(composer.function(() => n)).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error.startsWith('An exception was caught'))) - }) - - it('function must throw', function () { - return invoke(composer.function(() => ({ error: 'foo', n: 42 }))).then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) - }) - - it('function must mutate params', function () { - return invoke(composer.function(params => { params.foo = 'foo' }), { n: 42 }).then(activation => assert.deepEqual(activation.response.result, { foo: 'foo', n: 42 })) - }) - - it('function as string', function () { - return invoke(composer.function('({ n }) => n % 2 === 0'), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - - it('invalid argument', function () { - try { - invoke(composer.function(42)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - - it('too many arguments', function () { - try { - invoke(composer.function(() => n, () => { })) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('deserialize', function () { - it('should deserialize a serialized composition', function () { - const json = { - "type": "sequence", - "components": [{ - "type": "action", - "name": "echo" - }, { - "type": "action", - "name": "echo" - }] - } - return invoke(composer.deserialize(json), { message: 'hi' }).then(activation => assert.deepEqual(activation.response.result, { message: 'hi' })) - }) - }) - - describe('tasks', function () { - describe('action tasks', function () { - it('action must return true', function () { - return invoke(composer.task('isNotOne'), { n: 0 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - }) - - describe('function tasks', function () { - it('function must return true', function () { - return invoke(composer.task(({ n }) => n % 2 === 0), { n: 4 }).then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - }) - - describe('null task', function () { - it('null task must return input', function () { - return invoke(composer.task(null), { foo: 'foo' }).then(activation => assert.deepEqual(activation.response.result, { foo: 'foo' })) - }) - - it('null task must fail on error input', function () { - return invoke(composer.task(null), { error: 'foo' }).then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) - }) - }) - - describe('invalid tasks', function () { - it('a Boolean is not a valid task', function () { - try { - invoke(composer.task(false)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - - it('a number is not a valid task', function () { - try { - invoke(composer.task(42)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - - it('a dictionary is not a valid task', function () { - try { - invoke(composer.task({ foo: 'foo' })) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - }) - - it('too many arguments', function () { - try { - invoke(composer.task('foo', 'foo')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('combinators', function () { - describe('sequence', function () { - it('flat', function () { - return invoke(composer.sequence('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 4 })) - }) - - it('nested right', function () { - return invoke(composer.sequence('TripleAndIncrement', composer.sequence('DivideByTwo', 'DivideByTwo')), { n: 5 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 4 })) - }) - - it('nested left', function () { - return invoke(composer.sequence(composer.sequence('TripleAndIncrement', 'DivideByTwo'), 'DivideByTwo'), { n: 5 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 4 })) - }) - - it('seq', function () { - return invoke(composer.seq('TripleAndIncrement', 'DivideByTwo', 'DivideByTwo'), { n: 5 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 4 })) - }) - }) - - describe('if', function () { - it('condition = true', function () { - return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) - }) - - it('condition = false', function () { - return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 10 })) - }) - - it('condition = true, then branch only', function () { - return invoke(composer.if('isEven', 'DivideByTwo'), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 2 })) - }) - - it('condition = false, then branch only', function () { - return invoke(composer.if('isEven', 'DivideByTwo'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 3 })) - }) - - it('condition = true, nosave option', function () { - return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 2 }) - .then(activation => assert.deepEqual(activation.response.result, { value: true, then: true })) - }) - - it('condition = false, nosave option', function () { - return invoke(composer.if_nosave('isEven', params => { params.then = true }, params => { params.else = true }), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { value: false, else: true })) - }) - - it('too many arguments', function () { - try { - invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement', 'TripleAndIncrement')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('while', function () { - it('a few iterations', function () { - return invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) - }) - - it('no iteration', function () { - return invoke(composer.while(() => false, ({ n }) => ({ n: n - 1 })), { n: 1 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) - }) - - it('nosave option', function () { - return invoke(composer.while_nosave(({ n }) => ({ n, value: n !== 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) - }) - - it('too many arguments', function () { - try { - invoke(composer.while('isNotOne', ({ n }) => ({ n: n - 1 }), ({ n }) => ({ n: n - 1 })), { n: 4 }) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('dowhile', function () { - it('a few iterations', function () { - return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne'), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) - }) - - it('one iteration', function () { - return invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), () => false), { n: 1 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 0 })) - }) - - it('nosave option', function () { - return invoke(composer.dowhile_nosave(({ n }) => ({ n: n - 1 }), ({ n }) => ({ n, value: n !== 1 })), { n: 4 }) - .then(activation => assert.deepEqual(activation.response.result, { value: false, n: 1 })) - }) - - it('too many arguments', function () { - try { - invoke(composer.dowhile(({ n }) => ({ n: n - 1 }), 'isNotOne', ({ n }) => ({ n: n - 1 })), { n: 4 }) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('try', function () { - it('no error', function () { - return invoke(composer.try(() => true, error => ({ message: error.error }))) - .then(activation => assert.deepEqual(activation.response.result, { value: true })) - }) - - it('error', function () { - return invoke(composer.try(() => ({ error: 'foo' }), error => ({ message: error.error }))) - .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) - }) - - it('try must throw', function () { - return invoke(composer.try(composer.task(null), error => ({ message: error.error })), { error: 'foo' }) - .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) - }) - - it('while must throw', function () { - return invoke(composer.try(composer.while(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' }) - .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) - }) - - it('if must throw', function () { - return invoke(composer.try(composer.if(composer.literal(false), null), error => ({ message: error.error })), { error: 'foo' }) - .then(activation => assert.deepEqual(activation.response.result, { message: 'foo' })) - }) - - it('retain', function () { - return invoke(composer.retain(composer.try(() => ({ p: 4 }), null)), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { p: 4 } })) - }) - - it('too many arguments', function () { - try { - invoke(composer.try('isNotOne', 'isNotOne', 'isNotOne')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('finally', function () { - it('no error', function () { - return invoke(composer.finally(() => true, params => ({ params }))) - .then(activation => assert.deepEqual(activation.response.result, { params: { value: true } })) - }) - - it('error', function () { - return invoke(composer.finally(() => ({ error: 'foo' }), params => ({ params }))) - .then(activation => assert.deepEqual(activation.response.result, { params: { error: 'foo' } })) - }) - - it('too many arguments', function () { - try { - invoke(composer.finally('isNotOne', 'isNotOne', 'isNotOne')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Too many arguments')) - } - }) - }) - - describe('let', function () { - it('one variable', function () { - return invoke(composer.let({ x: 42 }, () => x)) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('masking', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x))) - .then(activation => assert.deepEqual(activation.response.result, { value: 69 })) - }) - - it('two variables', function () { - return invoke(composer.let({ x: 42 }, composer.let({ y: 69 }, () => x + y))) - .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) - }) - - it('two variables combined', function () { - return invoke(composer.let({ x: 42, y: 69 }, () => x + y)) - .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) - }) - - it('scoping', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, () => x), ({ value }) => value + x)) - .then(activation => assert.deepEqual(activation.response.result, { value: 111 })) - }) - - it('invalid argument', function () { - try { - invoke(composer.let(invoke)) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - }) - - describe('mask', function () { - it('let/let/mask', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, composer.mask(() => x)))) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('let/mask/let', function () { - return invoke(composer.let({ x: 42 }, composer.mask(composer.let({ x: 69 }, () => x)))) - .then(activation => assert.deepEqual(activation.response.result, { value: 69 })) - }) - - it('let/let/try/mask', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, - composer.try(composer.mask(() => x), () => { })))) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('let/let/let/mask', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, - composer.let({ x: -1 }, composer.mask(() => x))))) - .then(activation => assert.deepEqual(activation.response.result, { value: 69 })) - }) - - it('let/let/let/mask/mask', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, - composer.let({ x: -1 }, composer.mask(composer.mask(() => x)))))) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('let/let/mask/let/mask', function () { - return invoke(composer.let({ x: 42 }, composer.let({ x: 69 }, - composer.mask(composer.let({ x: -1 }, composer.mask(() => x)))))) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - }) - - describe('retain', function () { - it('base case', function () { - return invoke(composer.retain('TripleAndIncrement'), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { n: 10 } })) - }) - - it('throw error', function () { - return invoke(composer.retain(() => ({ error: 'foo' })), { n: 3 }) - .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result, { error: 'foo' })) - }) - - it('catch error', function () { - return invoke(composer.retain_catch(() => ({ error: 'foo' })), { n: 3 }) - .then(activation => assert.deepEqual(activation.response.result, { params: { n: 3 }, result: { error: 'foo' } })) - }) - }) - - describe('repeat', function () { - it('a few iterations', function () { - return invoke(composer.repeat(3, 'DivideByTwo'), { n: 8 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) - }) - - it('invalid argument', function () { - try { - invoke(composer.repeat('foo')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - }) - - describe('retry', function () { - it('success', function () { - return invoke(composer.let({ x: 2 }, composer.retry(2, () => x-- > 0 ? { error: 'foo' } : 42))) - .then(activation => assert.deepEqual(activation.response.result, { value: 42 })) - }) - - it('failure', function () { - return invoke(composer.let({ x: 2 }, composer.retry(1, () => x-- > 0 ? { error: 'foo' } : 42))) - .then(() => assert.fail(), activation => assert.deepEqual(activation.error.response.result.error, 'foo')) - - }) - - it('invalid argument', function () { - try { - invoke(composer.retry('foo')) - assert.fail() - } catch (error) { - assert.ok(error.message.startsWith('Invalid argument')) - } - }) - }) - }) - }) - - describe('compositions', function () { - describe('collatz', function () { - it('composition must return { n: 1 }', function () { - return invoke(composer.while('isNotOne', composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement')), { n: 5 }) - .then(activation => assert.deepEqual(activation.response.result, { n: 1 })) - }) - }) - }) -}) diff --git a/travis/runtimes.json b/travis/runtimes.json new file mode 100644 index 0000000..5673232 --- /dev/null +++ b/travis/runtimes.json @@ -0,0 +1,27 @@ +{ + "runtimes": { + "nodejs": [ + { + "kind": "nodejs:10", + "default": true, + "image": { + "prefix": "openwhisk", + "name": "action-nodejs-v10", + "tag": "nightly" + }, + "deprecated": false, + "attached": { + "attachmentName": "codefile", + "attachmentType": "text/plain" + }, + "stemCells": [ + { + "count": 2, + "memory": "256 MB" + } + ] + } + ] + }, + "blackboxes": [] +} diff --git a/travis/scancode.sh b/travis/scancode.sh new file mode 100755 index 0000000..2c354bb --- /dev/null +++ b/travis/scancode.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# +# 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. +# + +set -e + +SCRIPTDIR=$(cd $(dirname "$0") && pwd) +ROOTDIR="$SCRIPTDIR/../" +HOMEDIR="$SCRIPTDIR/../../" +UTIL_DIR="$HOMEDIR/openwhisk-utilities" + +# clone OpenWhisk utilities repo. in order to run scanCode.py +cd $HOMEDIR +git clone https://github.com/apache/openwhisk-utilities.git + +# run scancode +cd $UTIL_DIR +scancode/scanCode.py --config scancode/ASF-Release.cfg $ROOTDIR diff --git a/travis/setup.sh b/travis/setup.sh index 138c3a6..4e247f4 100755 --- a/travis/setup.sh +++ b/travis/setup.sh @@ -1,30 +1,48 @@ #!/bin/bash + +# +# 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. +# + set -e # Build script for Travis-CI. SCRIPTDIR=$(cd $(dirname "$0") && pwd) ROOTDIR="$SCRIPTDIR/.." -IMAGE_PREFIX="composer" WHISKDIR="$ROOTDIR/openwhisk" -# OpenWhisk stuff +# Prefetch docker images +docker pull openwhisk/controller & +docker pull openwhisk/invoker & +docker pull openwhisk/nodejs6action & + +# Clone OpenWhisk cd $ROOTDIR -git clone --depth=1 https://github.com/apache/incubator-openwhisk.git openwhisk -cd openwhisk -./tools/travis/setup.sh - -# Pull down images -docker pull openwhisk/controller -docker tag openwhisk/controller ${IMAGE_PREFIX}/controller -docker pull openwhisk/invoker -docker tag openwhisk/invoker ${IMAGE_PREFIX}/invoker -docker pull openwhisk/nodejs6action -docker tag openwhisk/nodejs6action ${IMAGE_PREFIX}/nodejs6action +git clone --depth=1 https://github.com/apache/openwhisk.git openwhisk + +# Install Ansible +pip install --user ansible==2.8.19 + +# Configure runtimes +cp $SCRIPTDIR/runtimes.json $WHISKDIR/ansible/files # Deploy OpenWhisk cd $WHISKDIR/ansible -ANSIBLE_CMD="ansible-playbook -i ${WHISKDIR}/ansible/environments/local -e docker_image_prefix=${IMAGE_PREFIX}" +ANSIBLE_CMD="ansible-playbook -i ${WHISKDIR}/ansible/environments/local -e docker_image_prefix=openwhisk -e docker_image_tag=nightly" $ANSIBLE_CMD setup.yml $ANSIBLE_CMD prereq.yml $ANSIBLE_CMD couchdb.yml @@ -33,16 +51,15 @@ $ANSIBLE_CMD wipe.yml $ANSIBLE_CMD openwhisk.yml -e cli_installation_mode=remote -e limit_invocations_per_minute=600 # Deploy Redis -docker run -d -p 6379:6379 --name redis redis:3.2 +docker run -d -p 6379:6379 --name redis redis:4.0 +# Log configuration docker images docker ps - -cat $WHISKDIR/whisk.properties curl -s -k https://172.17.0.1 | jq . curl -s -k https://172.17.0.1/api/v1 | jq . -# Setup +# Setup CLI WHISK_APIHOST="172.17.0.1" WHISK_AUTH=`cat ${WHISKDIR}/ansible/files/auth.guest` WHISK_CLI="${WHISKDIR}/bin/wsk -i" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy