How to add an action to a charm

See first: Juju | Action, Juju | How to manage actions

Contents:

Implement the action

Declare the action in charmcraft.yaml

To tell users what actions can be performed on the charm, define an ‘actionssection incharmcraft.yaml` that lists the actions and information about each action. The actions should include a short description that explains what running the action will do. Normally, all parameters that can be passed to the action are also included here, including the type of parameter and any default value. You can also specify that some parameters are required when the action is run. For example:

actions:
  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
            default: gzip
          quality:
            description: Compression quality
            type: integer
            default: 5
            minimum: 0
            maximum: 9
    required:
    - filename
    additionalProperties: false

See more: File charmcraft.yaml > actions

Observe the action event and define an event handler

In the src/charm.py file, in the __init__ function of your charm, set up an observer for the action event associated with your action and pair that with an event handler. For example:

self.framework.observe(self.on.grant_admin_role_action, self._on_grant_admin_role_action)

See more: Event <action name>-action

Now, in the body of the charm definition, define the action event handler. For example:

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})

More detail below:

Use action params

To make use of action parameters, either ones that the user has explicitly passed, or default values, use the params attribute of the event object that is passed to the handler. This is a dictionary of parameter name (string) to parameter value. For example:

def _on_snapshot(self, event: ops.ActionEvent):
    filename = event.params["filename"]
    …

See more: ops.ActionEvent.params

Report that an action has failed

To report that an action has failed, in the event handler definition, use the fail() method along with a message explaining the failure to be shown to the person running the action. Note that the fail() method doesn’t interrupt code execution, so you will usually want to immediately follow the call to fail() with a return, rather than continue with the event handler. For example:

def _on_snapshot(self, event: ops.ActionEvent):
    filename = event.params['filename']
    kind = event.params['compression']['kind']
    quality = event.params['compression']['quality']
    cmd = ['/usr/bin/do-snapshot', f'--kind={kind}', f'--quality={quality}', filename]
    subprocess.run(cmd, capture_output=True)
    if granted.returncode != 0:
        event.fail(
            f"Failed to run {' '.join(cmd)!r}. Output was:\n{granted.stderr.decode('utf-8')}"
        )
   …

]

See more: ops.ActionEvent.fail

Return the results of an action

To pass back the results of an action to the user, use the set_results method of the action event. These will be displayed in the juju run output. For example:

def _on_snapshot(self, event: ops.ActionEvent):
    size = self.do_snapshot(event.params['filename'])
    event.set_results({'snapshot-size': size})

See more: ops.ActionEvent.set_results

Log the progress of an action

In a long-running action, to give the user updates on progress, use the .log() method of the action event. This is sent back to the user, via Juju, in real-time, and appears in the output of the juju run command. For example:

def _on_snapshot(self, event: ops.ActionEvent):
    event.log('Starting snapshot')
    self.snapshot_table1()
    event.log('Table1 complete')
    self.snapshot_table2()
    event.log('Table2 complete')
    self.snapshot_table3()

See more: ops.ActionEvent.log

Record the ID of an action task

When a unique ID is needed for the action task - for example, for logging or creating temporary files, use the .id attribute of the action event. For example:

def _on_snapshot(self, event: ops.ActionEvent):
    temp_filename = f'backup-{event.id}.tar.gz'
    logger.info("Using %s as the temporary backup filename in task %s", filename, event.id)
    self.create_backup(temp_filename)
    ... 

See more: ops.ActionEvent.id

Test the action

See first: Get started with charm testing

What you need to do depends on what kind of tests you want to write.

Write unit tests

See first: How to write unit tests for a charm

When using Harness for unit tests, use the run_action method to verify that charm actions have the expected behaviour. This method will either raise an ActionFailed exception (if the charm used the event.fail() method) or return an ActionOutput object. These can be used to verify the failure message, logs, and results of the action. For example:

def test_backup_action():
    harness = ops.testing.Harness()
    harness.begin()
    try:
        out = harness.run_action('snapshot', {'filename': 'db-snapshot.tar.gz'})
    except ops.testing.ActionFailed as e:
        assert "Could not backup because" in e.message
    else:
        assert out.logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete']
        assert 'snapshot-size' in out.results
    finally:
        harness.cleanup()


See more: ops.testing.Harness.run_action](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.run_action)

Write scenario tests

See first: How to write scenario tests for a charm

When using Scenario for unit tests, to verify that the charm state is as expected after executing an action, use the run_action method of the Scenario Context object. The method returns an ActionOutput object that contains any logs and results that the charm set. For example:

def test_backup_action():
    action = scenario.Action('snapshot', params={'filename': 'db-snapshot.tar.gz'})
    ctx = scenario.Context(MyCharm)
    out = ctx.run_action(action, scenario.State())
    assert out.logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete']
    if out.success:
      assert 'snapshot-size' in out.results
    else:
      assert 'Failed to run' in out.failure

See more: Scenario action testing

Write integration tests

See first: How to write integration tests for a charm

To verify that an action works correctly against a real Juju instance, write an integration test with pytest_operator. For example:

async def test_logger(ops_test):
    app = ops_test.model.applications[APP_NAME]
    unit = app.units[0]  # Run the action against the first unit.
    action = await unit.run_action('snapshot', filename='db-snapshot.tar.gz')
    action = await action.wait()
    assert action.status == 'completed'
    assert action.results['snapshot-size'].isdigit()

Contributors:@benhoyt, @danieleprocida, @jnsgruk, @michele-mancioppi, @mmkay, @tmihoc, @tony-meyer, @toto

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 ?