robocorp-action-server

Serving Actions with the Action Server

The action server's main purpose is managing the lifecycle of actions in an action package.

There are a number of customizations available which make it flexible to work and manage which actions should be executed. The guide shows a few of these use-cases below.

Serving actions from the current directory

The simplest case is just starting the action server at a given folder with:

action-server start

In this case, the action server will search recursively for all the Actions marked as @action in files named as *action*.py and then it'll start serving such actions. Any @action which is no longer found from a previous run will be disabled.

All the settings and data related to this run will be stored a folder located in a directory in ~/robocorp/.action_server (or %LOCALAPPDATA%/robocorp/.action_server in windows) which is automatically computed based on the current directory location.

It's possible to customize the directory by using the --datadir flag.

Example:

action-server start --datadir=<path to datadir>

Serving actions from multiple directories

In this case, instead of just using action-server start, one needs to import the actions saving the settings to a given datadir and then start the server pointing to that datadir asking the action-server not to synchronize the actions again when starting.

Example:

action-server import --dir=<path to action-package 1> --datadir=<path to datadir> action-server import --dir=<path to action-package 2> --datadir=<path to datadir> action-server start --actions-sync=false --datadir=<path to datadir>

package.yaml

Note: introduced in the Action Server version: 0.0.21

The package.yaml file is the base file which defines everything related to the actions available in the action package.

Note: previous versions of the action server used a conda.yaml or action-server.yaml, which are not directly compatible to package.yaml (so, they can't be just renamed directly and some changes are expected in how to define the environment).

Running: action-server package update can be used to automatically upgrade a package in an older version to the new expected format.

An example package.yaml would be something as:

# Required: Defines the name of the action package. name: My awesome cookie maker # Required: A description of what's in the action package. description: This does cookies # Required: The current version of this action package. version: 0.2.3 # Required: A link to where the documentation on the package lives. documentation: https://github.com/robocorp/actions-cookbook/blob/master/database-postgres/README.md # Required: # Defines the Python dependencies which should be used to launch the # actions. # The action server will automatically create a new python environment # based on this specification. # Note that at this point the only operator supported is `=`. dependencies: conda-forge: # This section is required: at least the python version must be specified. - python=3.10.12 - pip=23.2.1 - robocorp-truststore=0.8.0 pypi: # This section is required: at least `robocorp-actions` must # be specified. # Note: robocorp-actions is special case because the action server # has coupling with the library. This means that if the version of # robocorp-actions is not pinned to a value the action server will # select a version based on a version that's known to work with the # current version of the action server. # If the version is pinned, then the action server will validate # if the given version can be used with it. - robocorp-actions - robocorp=1.4.3 - pytz=2023.3 post-install: # This can be used to run custom commands which will still affect the # environment after it is created (the changes to the environment will # be cached). - python -m robocorp.browser install chrome --isolated packaging: # This section is optional. # By default all files and folders in this directory are packaged when uploaded. # Add exclusion rules below (expects glob format: https://docs.python.org/3/library/glob.html) exclude: - *.temp - .vscode/**

Running actions

The action server has a few command line parameters when it's started which define how actions will be run and whether actions must be run isolated from other actions.

Reuse process

The most important flag here is --reuse-process. When this flag is activated, the action server will invoke the same @action multiple times, utilizing the same process where the @action was previously executed.

If --reuse-process is not passed, a new process will be created to run the action and each new invocation will run in a completely clean environment.

Notes on process reusal:

It's usually faster to run with --reuse-process as after the action runs once, all modules are already imported and the global state is reused (so, subsequent calls to the action will have the same global variables in place).

Keep in mind that some environment variables may be changed across calls to an @action (for instance to update where the results should be written). In such cases, those should not be stored in a global variable.

Process pool

The action server can execute processes using a process pool (by providing the --min-processes and --max-processes command line arguments).

In general, if --reuse-process is set and more than one process is available, the action server may redirect a new request to any of the existing processes (as long as the environment is compatible), so, for instance, if there are three different @actions in the same package, the action server can forward a request to any of the existing processes.

This implies that unless --min-processes and --max-processes are both set to 1 and --reuse-process is enabled, global state that needs to persist for handling session information should be stored externally (e.g., in a sqlite database or another external service/location for storing user-session information).

Whitelisting actions to be run

There are 2 situations where one can whitelist the actions which should be used, at import and at server start time.

This can be specified in the --whitelist command line parameter.

Whitelist argument spec

  • The format of the whitelist is flexible so that it accepts the format accepted by fnmatch.

  • The package name can potentially be matched too (if / is added the package name is matched, otherwise just the action name is matched).

  • It's possible to specify multiple actions by separating them with a comma.

  • - and _ may be used interchangeably.

Examples

--whitelist "action-1,action_2"

--whitelist "package1/action*1,package2/action*2"

--whitelist "*foo/sheet,*bar/sheet"

--whitelist "*foo/*"

Importing actions

When actions are imported, it's possible to whitelist which actions should actually be imported.

Example:

action-server import --datadir=<path to datadir> --whitelist my_action1,my_action2 action-server start --actions-sync=false --datadir=<path to datadir>

Serving actions

When actions are served, it's possible to whitelist which actions should actually be available to run.

Example:

action-server start --datadir=<path to datadir> --whitelist my_action1,my_action2

Dealing with custom data models

Starting with robocorp-actions 0.0.8 and Action Server 0.0.28, custom pydantic models may be used to define a schema containing complex objects as the input/output of a an @action.

-- previous versions supported just str, int, float, bool.

Example

To define a custom data model, pydantic classes must be used to define the shape of the data.

Below is an example which defines an @action with a custom input and output:

from typing import Annotated from pydantic import BaseModel, Field from robocorp.actions import action class InputData(BaseModel): name: Annotated[str, Field(description="This is the name.")] price: Annotated[float, Field(description="This is the price.")] class OutputData(BaseModel): accept_input: Annotated[bool, Field(description="Defines whether the input was accepted.")] @action def accept_data(data: InputData) -> OutputData: assert isinstance(data, InputData) return OutputData(accept_input=data.price < 100)

Note

Note: pydantic is not a hard dependency of robocorp-actions and must be included as a custom dependency in projects that require custom data models.

Exposing a local action server

To expose a local running action server for public access, it's possible to use action-server start --expose.

By doing so, the action-server will automatically connect to a server and you'll get a public reference to it on the robocorp.link domain (for instance https://twently-cuddly-dinosaurs.robocorp.link).

Note: if the server is stopped and restarted, it'll ask to reconnect to the same server afterwards as url/access secret is stored in the datadir. If you say No, a new url/access secret will be generated and the old one will be lost, so, it may be interesting to backup the expose_session.json from your datadir if you plan to keep using the same url later on (the datadir is printed in the console whenever you start your action server).

Authentication

Currently the Action Server supports a basic authentication scheme using an API key with "Bearer" authentication.

To set it up, it's possible to pass --api-key=<api key> in the to the action-server start.

Note that if --api-key is not passed along with --expose, an api key will be automatically generated and saved in your datadir/.api_key. Consider backing it up if you want to keep using the same api key.

Calling server with API key enabled

If the --api-key was passed (or if this was an exposed server which had the api automatcially generated), to call any method from its API, a header such as:

"Authorization": "Bearer <api-key>"

must be passed in all requests (excluding /openapi.json).

Building Action Package zip to distribute:

The action server can be used to serve Action Packages, which is a project containing multiple defined actions (specified by using the @action decorator in .py files), with the related metadata (package.yaml).

To share these Action Packages the action server has the following command:

action-server package build

This will build a .zip containing the files from the Action Package. By default all the files from the Action Package directory are recursively added to the .zip, and it's possible to customize files which shouldn't be added to the action server by specifying them in the package.yaml in the packaging/exclude section (entries are based on the glob format.

Example:

name: CRM automation description: Automates dealing with the CRM. version: 0.2.3 documentation: https://github.com/robocorp/actions-cookbook/blob/master/database-postgres/README.md dependencies: conda-forge: - python=3.10.12 - pip=23.2.1 - robocorp-truststore=0.8.0 pypi: - robocorp-actions=0.1.0 - robocorp=1.4.3 - pytz=2023.3 packaging: exclude: - "*.pyc" # Excludes .pyc files anywhere - "./devdata/**" # Excludes the `devdata` directory in the current dir - "**/secret/**" # Excludes any `secret` folder anywhere

Note: using action-server package build with the package.yaml above will create a .zip named: crm-automation-0.2.3.zip in the current directory.

Extracting zip with Action Package:

To extract the Action Package from the crm-automation-0.2.3.zip previously created, it's possible to use:

action-server package extract crm-automation-0.2.3.zip --output-dir=v023

Note: if --output-dir is not given, the current directory will be used.

Note: --override can be given to override the contents of the directory without asking for confirmation.

Collecting metadata from an Action Package:

To collect metadata from a given Action Package (which must be extracted in the filesystem), it's possible to run:

action-server package metadata

By doing so it'll write to stdout the metadata from the Action Package (in version 0.2.0 this only includes one openapi.json entry with the openapi.json contents, but it's expected that this will have more information in the future).

Note: logging may still be written to stderr and if the process returns with a non-zero value the stderr should have information on what failed.

From action-server 0.3.0 onwards, data on the expected secrets is also available in the returned metadata.

The full structure given in the output is something as:

openapi.json: <OpenAPI Contents> metadata: # Note: optional as no additional metadata may be needed secrets: # Note: optional as secrets may not be there <url-for-secret>: action: <action-name> actionPackage: <action-package-name> secrets: <secret-name>: type: Secret

Secrets

Important: Requires robocorp-actions 0.2.0 onwards to work. Important: On robocorp-actions 0.2.1 the auth-tag is accepted (on 0.2.0 an empty string would always be used as the auth-tag).

Receiving a Secret

To receive secrets using actions, it's possible to add a parameter with a 'Secret' type so that it's automatically received by the action.

i.e.:

from robocorp.actions import action, Secret @action def my_action(my_secret: Secret): login(my_secret.value)

Passing Secrets (Development mode inside of VSCode)

In development mode a secret can be passed by using the input.json (which is automatically created when an action is about to be run).

i.e.: in the case above a my_secret entry in the json will be automatically used as the my_secret.value.

Example input.json:

{ "my_secret": "secret-value" }

Passing Secrets (Production mode)

In production secrets should be passed in the X-Action-Context header.

The expected format of that header is a base64(JSON.stringify(content)) where the content is a json object such as:

{ "secrets": { "secret-name": "secret-value" } }

In python code it'd be something as:

payload = { 'secrets': {'secret-name': 'secret-value'} } x_action_server_header = base64.b64encode( json.dumps( payload ).encode("utf-8") ).decode("ascii")

Note: the X-Action-Context header can also be passed encrypted with a key shared with the action server in the environment variables.

In that case the X-Action-Context header contents should be something as:

base64({ "cipher": base64(encrypted_data(JSON.stringify(content))), "algorithm": "aes256-gcm", "iv": base64(nonce), "auth-tag": base64(auth-tag), # Note: requires robocorp-actions 0.2.1 })

In python code it'd be something as:

payload = { 'secrets': {'secret-name': 'secret-value'} } data: bytes = json.dumps(payload).encode("utf-8") # def encrypt(...) implementation can be created using the cryptography library. encrypted_data = encrypt(key, nonce, data, auth_tag) action_server_context = { "cipher": base64.b64encode(encrypted_data).decode("ascii"), "algorithm": "aes256-gcm", "iv": base64.b64encode(nonce).decode("ascii"), "auth-tag": base64.b64encode(auth_tag).decode("ascii"), # Note: requires robocorp-actions 0.2.1 } x_action_server_header: str = base64.b64encode( json.dumps(action_server_context).encode("utf-8") ).decode("ascii")

The actual key used in the encryption should be set in ACTION_SERVER_DECRYPT_KEYS in the environment variables such that it's a json with the keys in base64.

In python code:

ACTION_SERVER_DECRYPT_KEYS=json.dumps( [base64.b64encode(k).decode("ascii") for k in keys] )

Note: all the keys will be checked in order and the caller may use any of the keys set to encrypt the data.