Writing robust Bolt Tasks: a guide
Hello, fearless reader! In case you missed it, we hosted a workshop for the Contributor Summit Online event to introduce best practices for writing Bolt Tasks. The workshop was a speedy general-overview, and I want to dive deeper into writing robust tasks and how the helper modules for Python and Ruby that we just released help you do just that.
What are Bolt Tasks?
In case you’re new to Bolt Tasks, they’re scripts with optional metadata that you can run on target machines in your infrastructure, allowing you to make as-needed changes to remote systems. They can be written in any language your target machines support: bash, powershell, python, ruby, etc. Tasks can be run by our open-source remote task runner Bolt, or in Puppet Enterprise, and are shared on the Puppet Forge. These principles are generally applicable, and will help whether you’re just starting out with Bolt Tasks or whether you’ve already got tasks and are looking to make them better!
How to install Task Helpers
bolt puppetfile install from the same directory.
You’ll also need to add the helper files to your task metadata:
Check out the Bolt documentation for more information on installing modules and using them in your tasks!
One thing that distinguishes a task from a script is it’s API - how it receives input from the task runner, and what output it produces. Task parameters can be received by the task as environment variables, as JSON from stdin, or as powershell arguments. Here’s what we recommend when parsing input:
- Use environment variables if you have simple parameter types, like strings and integers. If you have any more complex parameters, like arrays or hashes, use stdin.
- Stick to one input method. The default parameter input method is
both, which stores the parameters as environment variables and passes them over stdin. If parameters are passed over stdin but are not consumed by the task it can cause unexpected behavior for certain edge cases. To avoid this unexpected behavior, it’s best to specify an
input_methodin your task metadata and ensure you consume the parameters by that method.
The task helper modules will parse input as JSON for you, so you can write task methods that accept parameters as their input like so:
This ruby script uses the ruby task helper by extending the
TaskHelper class imported from the task helper module, then defines a new method
task which accepts a name parameter and then any other optional parameters the task runner might supply. The name parameter is a parameter of the task we’re writing itself, while
**kwargs accounts for any optional or extra parameters our task accepts. I can then refer to the parameter directly by name, instead of having to parse the JSON hash and refer to keys of that hash, and parse optional args from
Task output can be just about anything that’s printed during or returned from a task. You can see what happened in your task, debug your task, raise errors, and output JSON to be consumed by other tasks or programs. There are a few things to keep in mind when thinking about what output your task produces:
- Bolt will catch any task that returns a non-zero exit code, and print stdout as the result. So if you’d like your task to error, it’s best to exit with a non-zero code and print any messages to stdout.
- If you’re using your task as part of a plan and want to use the output, it’s best to print a single JSON object to stdout. This will be the result of the task, and can be parsed within a bolt plan
The task helper modules will catch any errors raised in the task and turn them into formatted results to return to the task runner. There’s also a
TaskHelper::Error class you can use in your task to set custom error messages, types, and details.
The best thing you can do to write better tasks is write good metadata! And in this case, ‘good’ means ‘as much as possible’. Good metadata is detailed, thorough, and includes as much information about the task as you can provide. Most importantly:
- Give your task a description! This will show up in the ‘bolt task show ’ output, and help other users understand what your task does
- Specify an input_method. As previously discussed this avoids some weird edge cases.
- List all parameters, and give them types and descriptions. Assigning types to your parameters helps avoid malformed input (or worse, malicious input), and again you want to be as specific as possible. Instead of just “String” type, if your parameter can only be one of a few options use “Enum”, or if it matches a regex use “Pattern”. Providing descriptions also helps users know what to pass as input.
- List implementations for the task, including any additional files the implementation might require and any requirements it has.
- List any additional files your task depends on to run
- Specify private or supports_noop if applicable. The private key will hide the task from bolt task show output, and the supports_noop key will indicate if your task supports running in a noop mode.
How to test Tasks
For tasks written and tested in ruby we have the BoltSpec library, which provides functions for running bolt commands from a ruby script (basically bolt-as-a-library) and is great for integration-level testing. This post assumes you’re already familiar with testing Puppet modules, but if not we have tons of great resources for learning! In your module, you can simply install Bolt as a gem (preferably with bundler,
require ‘bolt_spec/run’, include the
BoltSpec::Run class in your test, then have access to helpers to run tasks, plans, and commands. Note: Bolt requires Puppet 6, so if your module is using an older version of Puppet you should include the Puppet 6 gem conditionally.
Additionally, using the task helper modules to isolate task functionality makes it easy to test the tasks, passing in fake data and verifying the output. For an example of testing a real module check out aws_inventory.
Here are some places to interact more with Bolt!