How to handle actions

Actions are methods defined by a charm developer designed to be invoked by an administrator against a deployed charm. They are commonly used to expose maintenance or operations tasks, such as creating a snapshot of a database, adding a user to a system or dumping debug information.

Actions are defined in the actions.yaml file. As with configuration, each action is defined as a top-level key of a YAML map. The name of the key is the name of the action, and corresponds to a map of fields that define the action.

Action names are validated to ensure they do not collide with Python keywords, and that they are valid identifiers:

  • Identifiers contain only alphanumeric characters or underscores
  • First character must not be a digit
  • Action names may also include hyphens

Each action should define a description, and can optionally define some parameters in a YAML map named params, which is a JSON Schema transformed into a YAML map. Parameters can contain nested JSON schema.

Some notes on the use of JSON Schema in actions.yaml:

  • The $schema and $ref keys from JSON schema are not currently supported
  • The additionalProperties and required keys from JSON Schema can be used at the top-level of an action (adjacent to description and params), but also used anywhere within a nested schema

Contents:

Example action definitions

The following illustrate some example action definitions:

Simple action example

The following shows a simple example of an actions.yaml file, defining three actions named pause, resume, and snapshot. The snapshot action takes a single string parameter named outfile:

pause:
  description: Pause the database.
resume:
  description: Resume a paused database.
snapshot:
  description: Take a snapshot of the database.
  params:
    outfile:
      type: string
      description: The filename to write to.

Complex action example

The following example showcases a more complex configuration file that uses a nested schema to define detailed options. It also mandates that the action should not run if extra parameters are provided (additionalProperties: false) and makes the filename field mandatory:

pause:
  description: Pause the database.
resume:
  description: Resume a paused database.
snapshot:
  description: Take a snapshot of the database.
  params:
    filename:
      type: string
      description: The name of the snapshot file.
    compression:
      type: object
      description: The type of compression to use.
      properties:
        kind:
          type: string
          enum: [gzip, bzip2, xz]
        quality:
          description: Compression quality
          type: integer
          minimum: 0
          maximum: 9
  required: [filename]
  additionalProperties: false

An administrator would invoke this action like so:

$ juju run <unit> snapshot filename=out.tar.gz compression.kind=gzip

You can opt-in to a nicer actions user experience at the command line by using a feature flag with Juju. Read more on Discourse. This will become the default in future releases of Juju.

Action handling

Actions are surfaced as events that a charm can observe, similarly to lifecycle events. Action names are parsed by the Charmed Operator Framework and surfaced as events named <action_name>_action.

If hyphens are used in action names, they are replaced with underscores in the corresponding event names. For example, an action named snapshot-database would result in an event named snapshot_database_action being triggered when the action is invoked.

Action handlers are passed an ActionEvent as their first parameter, and thus have access to the action parameter values through the <event>.params construct. In addition to the params dict, the ActionEvent class provides three convenience methods to the developer:

  • <event>.fail(message=""): Report to the administrator that the action has failed, optionally providing a message indicating why the failure occured.
  • <event>.log(message): Log a message for the administrator (available while the action is running).
  • <event>.set_results(results): Report the result of the action, where results is a dictionary.

Messages are displayed to administrators in real time when they run juju run-action with the --wait flag. For more information on working with actions as an administrator see Juju OLM | How to manage actions.

When you run an action via juju run, you either select a specific unit to run the action, or all units in your Juju application. Inside the unit(s) that will run the action, how the action is run depends on the type of the charm. If your charm is a machine charm, actions are executed on the same machine as the application. If your charm is a Kubernetes charm implementing the sidecar pattern, the charm action is run in the charm container.

Action handling example

For the following actions.yaml:

grant-admin-role:
  description: >
    Grant the "system_admin" role to a user. The user will need to log out and
    log back in to realise their permissions upgrade.
  params:
    user:
      type: string
      description: The user to grant "system_admin" role to.
  required: [user]

A charm developer could handle the action invocation like so:

class ActionsCharm(CharmBase):
    def __init__(self, *args):
        super().__init__(*args)
        # ...
        self.framework.observe(self.on.grant_admin_role_action, self._on_grant_admin_role_action)
        # ...

    def _on_grant_admin_role_action(self, event):
        """Handle the grant-admin-role action."""
        # Fetch the user parameter from the ActionEvent params dict
        user = event.params["user"]
        # Do something useful with it
        cmd = ["/usr/bin/myapp", "roles", "system_admin", user]
        # Set a log message for the action
        event.log(f"Running this command: {' '.join(cmd)}")
        granted = subprocess.run(cmd, capture_output=True)
        if granted.returncode != 0:
            # Fail the action if there is an error
            event.fail(
                f"Failed to run '{' '.join(cmd)}'. Output was:\n{granted.stderr.decode('utf-8')}"
            )
        else:
            # Set the results of the action
            msg = f"Ran grant-admin-role for user '{user}'"
            event.set_results({"result": msg})

Hi Jon,

In the code example we have:

event.log(f"Running this command: {' '.join(cmd)}")

Since in the Logging section we say:

“When passing messages to the logger, do not build the strings yourself. Allow the logger to do this for you as required by the specified log level. An example is shown below:”

# Do this!
logger.info("Got some information %s", info)
# Don't do this
logger.info("Got some information {}".format(info))
# Or this ...
logger.info(f"Got some more information {more_info}")

I know that it is not exactly the same but, shouldn’t we follow the same pattern here?

Yes, good catch. I’ll update now :slight_smile:

Hi, quick question,

Should this be “<event>.set_results(results)”?

Yep, fixed! thanks! :slight_smile:

1 Like

Sorry @jnsgruk I have made a mistake here, because our implementation of the <event>.log method only receives a string:

def log(self, message: str) -> None:

And, for instance, the .info() method implementation in the python logging module receives:

def info(self, msg, *args, **kwargs):

Because of that when you run:

event.log("Username %s created", username)

You get:

log() takes 2 positional arguments but 3 were given

Maybe it would be good to have the same logging interface…
@jnsgruk, @rbarry what do you think about this? If you agree I can write a PR.

Arg. I’ll switch it back for now.

This is tricky, since they aren’t the same at all. We aren’t guaranteeing that we’re compatible with Python logging. logging.info ultimately calls back to a factory which interpolates the string with msg % args, basically.

event.log -> ops.model.backend.action_log -> juju action-log {msg}

While we could take varargs and try the same interpolation, we’d ultimately need to pull the entire CPython LogRecord in to match the syntax (since that can also take a dict as the first arg), at which point we are inevitably playing catchup any/every time that interface changes.

I don’t think it’s a contradiction to say “allow the logger to do this for you” as long as we are clear about the fact that event.log does not implement the Python logging interface.

What would be the best way to provide binary data to an action? I.e. in a use case where an action is used to upload a tar.gz file that the action then will extract and do something sensible with its contents?

Interesting question! @mthaddon or @james-page may have some background on how this is done in other charms?

I can think of various hacks with base64 encoded files etc., but for large files I’d imagine that using juju scp to move the file over to the charm would be better, then using the action to either just deal with the file at a known path, or pass the path of the file you want to work on?

So the flow would be something like…

$ juju scp ~/downloads/large-file.tgz my-app/0:/tmp/large-file-tgz
$ juju run-action my-app/0 process-file --string-args path=/tmp/large-file.tgz

Sounds like Bug #1670838 “Actions should accept files as a parameter” : Bugs : juju might be relevant here.

Just a remark, it seems like the results dict keys cannot contain “_” or “.” You get the following error if the key is “malformed”: must be similar to ‘key’, ‘some-key2’, or ‘some.key’

Could I mention it for information in the example section ?