Community OpenWrt ucode Module Developer Guide
This guide is about writing modules for community.openwrt in ucode.
If you are reading it, you probably already use the collection and want to extend it: Awesome! Everyone is
welcome to contribute!
ucode is OpenWrt’s own scripting language. It looks like JavaScript, it is small enough to live on a router, and - the part that matters here - it speaks JSON natively. Modules written in it hold to the premise of this collection: the target does not need Python, and the module runs on the device rather than on the controller.
Every OpenWrt release this collection supports carries ucode in its base image, together with the fs,
uci, ubus and math modules, so a module can read files, drive UCI and call ubus straight away.
The language gives you JSON in and out with no encoding work, arrays and dictionaries that nest and can be
passed around, and exceptions with try/catch.
One word of warning before anything else: ucode is not JavaScript. It is close enough to be readable at a
glance and different enough to trip you up - there are no default values for function parameters, arrays are
manipulated with global functions such as push() and length(), strings cannot be indexed with [],
and errors are raised with die() rather than throw. Knowing JavaScript helps, but do not assume it
carries over: the differences that come up while writing modules are collected in
ucode Language Notes.
Skills that help
A fair understanding of how Ansible modules work in general. Having written Python modules helps the most, since some of the idioms here deliberately resemble the ones used on the Python side.
Enough JavaScript to read ucode comfortably, plus the willingness to check the ucode documentation when something behaves unexpectedly.
Familiarity with the OpenWrt userspace -
uci,ubus,opkg/apk, BusyBox - since that is still what a module ends up driving.
You are welcome to use AI to help you write modules, roles and anything else - but remember that your code will be your responsibility, so you MUST understand what is happening in it.
Target requirements
A ucode module runs on the device, so everything it relies on must already be there. In practice that is a short list, and it is worth knowing where its edges are before writing code against it.
The interpreter is not a concern. ucode is part of the base image of every OpenWrt release this collection
supports, so nothing needs to be installed for a module to run: on a stock image, or on anything derived from
one, the interpreter is simply present.
A few ucode modules ship with it, and those are the ones a module may import:
on all supported releases:
fs,math,uci,ubus,htmlandlucihttp;on 25.12 also
log,uclientanduloop.
Importing anything from the second group makes the module fail on older releases, so use it only in a module
that targets 25.12 and above anyway. The versions currently exercised by the test suite are listed in
tests/molecule/openwrt.yml; unless a module is deliberately release-specific, write it for the oldest of
them.
Everything else is what the router happens to carry, and the assumptions worth spelling out are:
Nothing beyond the base image is guaranteed. A module needing an external command or an extra package declares it under
requirementsin its documentation, just as a Python module does: providing it is the user’s job, detecting its absence and failing cleanly is the module’s.Devices are small. Flash is limited and RAM even more so, so reading a whole file into memory or shelling out in a loop over a large set of items is a real cost, not a theoretical one.
The control node needs nothing at all: ucode is never executed there.
Runtime architecture
A ucode module is an ordinary program: it is copied to the target, run there once per host, and judged by what it prints. Three consequences of that shape how you write it.
Getting there. Write every module as two files: the .uc file that runs on the device, and a companion
action plugin that runs on the control node. The action plugin is mandatory: ansible-core bundles
module_utils only for Python modules, so a ucode module’s dependencies are transferred by its action
plugin.
Declare there every ucode module your module imports symbols from, apart from _basic, which is always
transferred, and the ones bundled with the interpreter. Nothing else reaches the target unless it is declared,
so an import you did not declare may not be available there and the module fails. The form of that
declaration is covered further down.
What is declared lands in a temporary directory alongside the module, and the interpreter is started with those module utils on its search path, so a plain name resolves to one of them:
import { AnsibleModule } from '_basic';
Importing by path works as it does anywhere else - it names a file, and that file has to be on the target.
Parameters in. Read the task’s parameters from module.params, keyed by the name you gave them in the
argument_spec. They arrive validated: converted to the declared type, defaults filled in, aliases resolved
to the parameter they stand for, and a parameter the user did not set present with a null value.
Parameters not matching the spec cause the module to fail.
Results out. End the module with module.exit_json() or module.fail_json(), which write the result
as a single JSON object on standard output for ansible-core to read. Keep that channel clean: print
nothing else to standard output, and put whatever you would want to look at in the result instead. Use
module.run_command() to run commands - it captures their output and hands it to you, leaving standard
output untouched.
Anatomy of a module
A module is three files, all named after it. For apk they are:
plugins/modules/apk.uc- the implementation, which runs on the device;plugins/modules/apk.pyorplugins/modules/apk.yml- the documentation, holdingDOCUMENTATION,EXAMPLESandRETURN: a Python file carrying nothing but those, or a YAML file of the same name.ansible-corereads either;plugins/action/apk.py- the action plugin, which runs on the control node.
Write the action plugin first, since it is the shortest thing in the collection: derive it from
UCodeActionBase and, if the module needs any module utils beyond _basic, list them.
from ansible_collections.community.openwrt.plugins.plugin_utils.ucode_action import UCodeActionBase
class ActionModule(UCodeActionBase):
pass
_basic is always transferred, so it never goes in that list. Anything else the module imports does:
module_utils = ["_file"].
Give the module itself the shape below: declare the interface, do the work, report the result.
import { AnsibleModule } from '_basic';
const module = AnsibleModule({
argument_spec: {
name: { type: 'str', required: true },
state: { type: 'str', default: 'present', choices: [ 'present', 'absent' ] },
},
supports_check_mode: true,
});
const params = module.params;
// ... the work ...
module.exit_json();
Document the module as you would document any other Ansible module: version_added takes the version in
galaxy.yml, and the check_mode and diff_mode attributes state what the module actually supports.
Keep the documentation in step with the argument_spec by hand - the two are not cross-checked, so a
parameter renamed in one and not the other is only found by someone using it.
A new module also needs an entry in .github/BOTMETA.yml for each of its files, and an integration test
target under tests/integration/targets/.
The AnsibleModule API
Call AnsibleModule() once, at the top of the module. The object it returns is everything the module gets
from the framework:
module.params- the validated parameters;module.check_modeandmodule.diff_mode- whether the task runs in check mode or diff mode;module.result- the result being built up;module.run_command()- run a command on the device;module.exit_json()andmodule.fail_json()- end the module;module.deprecate()- record a deprecation.
Building the result
module.result starts with changed and failed set to false and an empty msg. Add to it as the
module goes, rather than assembling a dict at the end:
result.update({ rc: res.rc, stdout: res.stdout, stderr: res.stderr });
result.changed();
result.changed() marks the task as changed and result.changed(false) clears it again. Every field you
put in the result is returned to the user, and the ones your module adds should be described in RETURN.
The common return values
are documented by ansible-core and do not belong there.
Running commands
module.run_command() returns { rc, stdout, stderr }:
let res = module.run_command([ 'apk', 'info', '-e', pkg ]);
if (res.rc != 0)
...
Pass the command as an array. Each element is quoted before the command line is assembled, so a parameter value containing spaces, quotes or a semicolon is passed through as the single argument it is meant to be. A string is handed to the shell as written, which buys you pipelines and redirections at the price of quoting everything yourself - use it only when you need those, and never build it out of parameter values.
Pass { check_rc: true } to end the module in failure when the command exits non-zero, with the command’s
standard error as the message.
Deprecations
Deprecating something the module used to do is module.deprecate(msg, version), where version is the
version the behavior is removed in. It is mandatory: a deprecation without a removal target is a bug, and the
module fails instead of recording it. The warning reaches the user when the module ends.
Ending the module
module.exit_json() prints the accumulated result and ends the module, and module.fail_json() does the
same for a failure. Both take the same argument, and it is optional: a dict, whose fields are merged on top of
the result, or a message, which becomes msg.
module.exit_json();
module.exit_json('nothing to do on this host');
module.fail_json(`failed to install ${pkg}`);
module.fail_json({ msg: 'could not parse the package metadata', rc: res.rc });
Neither of them returns, so nothing after the call is reached.
Declaring parameters
Declare every parameter the module accepts in the argument_spec. A parameter that is neither declared nor
an alias of something declared fails the module, so the spec is also what keeps a typo in a playbook from
being silently ignored.
Each parameter takes:
type-str(the default),bool,int,float,list,dictorraw. Values are converted to it:yes,onand1all reach aboolparameter as true, a comma-separated string reaches alistparameter as an array, a JSON string reaches adictparameter as an object, andrawtakes the value as it comes. A value that cannot be converted fails the module.elements- the type of the items of alistparameter, converted the same way.required- the module fails when the parameter is not supplied.default- the value to use when the parameter is not supplied. Without one, the parameter isnull.choices- the values the parameter may take; for a list, every item must be one of them.aliases- other names the user may write. Read the parameter by its declared name, whichever name the user wrote.
Checks across parameters
Relations between parameters are declared next to the argument_spec, not coded by hand:
const module = AnsibleModule({
argument_spec: {
state: { type: 'str', default: 'present', choices: [ 'present', 'absent' ] },
src: { type: 'str' },
content: { type: 'str' },
owner: { type: 'str' },
group: { type: 'str' },
},
mutually_exclusive: [ [ 'src', 'content' ] ],
required_if: [ [ 'state', 'present', [ 'src', 'content' ], true ] ],
required_together: [ [ 'owner', 'group' ] ],
supports_check_mode: true,
});
mutually_exclusive- groups of parameters of which at most one may be given.required_together- groups in which giving one parameter requires all the others.required_one_of- groups of which at least one parameter is required.required_if- conditions of the form[ name, value, [ required... ] ], requiring those parameters whennameholdsvalue. Addtrueas a fourth element to require only one of them.required_by- parameters that another parameter requires as soon as it is given.
mutually_exclusive is checked against what the user actually wrote, before defaults are applied, so a
parameter that merely declares a default does not count as given. The other checks run afterwards, on the
complete set, where a default does count. This is how ansible-core orders them too.
What the spec does not do
Some of what an argument_spec covers in Python has no counterpart here, and reaching for it silently gets
you nothing:
no_logdoes not redact anything. Keep secrets out of the result and out of anything you log.Sub-options - a nested
optionsspec - are not validated. Adictparameter arrives as it was written.fallback,apply_defaultsand thepath,jsonarg,bytesandbitstypes are not implemented.Parameters cannot be deprecated through the spec. Announce the deprecation with
module.deprecate()instead.
Internal arguments
Beyond the parameters of the task, ansible-core sends every module a few arguments of its own. Three of
them reach you as fields of the module object: module.check_mode, module.diff_mode and
module.verbosity.
Check mode
Declare check mode support twice: supports_check_mode: true in the AnsibleModule() call, and
check_mode: support: full in the attributes of the documentation. Without the first, a check-mode run
ends with a skipped result and your code never runs.
Work out what has to change, guard only the change itself, and report it either way:
if (!is_installed(pkg)) {
if (!module.check_mode)
install(pkg);
result.changed();
}
A check-mode run must report the same changed a real run would.
Diff
Build the diff yourself and put it in the result, only when module.diff_mode is set:
if (module.diff_mode)
result.update({ diff: { before: before_text, after: after_text } });
State what the module supports in the diff_mode attribute of the documentation.
Verbosity
module.verbosity is the number of -v flags the user passed, zero when none. Use it to decide how much
detail to put in the result, never to print.
Idempotency
Aim for a module that can be run again and again and only does something the first time. Read the current
state before touching anything, act only where it differs from what the task asks for, and report changed
only for what you actually changed.
That is what makes a playbook safe to re-run, and it is what the integration tests look for: every target runs its tasks twice and asserts that the second run changes nothing.
Failing
End a module that cannot do its job with module.fail_json(), saying in msg what failed in the user’s
terms - the file that is not there, the package that could not be installed:
let info = stat(path);
if (info == null)
module.fail_json(`${path} does not exist`);
Add whatever lets the user act on the failure. When it comes from a command, that is its rc and its
output:
module.fail_json({ msg: `failed to install ${pkg}`, rc: res.rc,
stdout: res.stdout, stderr: res.stderr });
Check what you can before you change anything, so that a task that is going to fail fails before it has touched the device.
Errors you did not plan for
An error nobody catches ends the module the hard way: ucode prints a stack trace on standard error and
nothing on standard output, and the user gets an unparsable result rather than your message. Put a
try/catch around anything that can raise - parsing the output of a command is the usual one - and turn
it into a fail_json():
let meta;
try {
meta = json(res.stdout);
} catch (e) {
module.fail_json('could not parse the package metadata');
}
Testing
Integration tests are where a ucode module is proven. There is no unit testing for .uc files - unit tests
in this collection cover the Python side - so write the integration target as you write the module.
Put the target in tests/integration/targets/<name>/ and run it with:
$ nox -e test -- <name>
It runs against every OpenWrt version in tests/molecule/openwrt.yml. Test through the module, not around
it: give it parameters, assert what it returns and what it left on the device, run each task a second time and
assert it reports no change, and cover check mode where the module claims to support it. The
Testing Guide has the rest.
Sanity tests reject a module whose file is not .py or .ps1, so every ucode module needs a line in each
tests/sanity/ignore-X.Y.txt:
plugins/modules/<name>.uc validate-modules:invalid-extension
Run the sanity and unit tests with:
$ ansible-test sanity --docker default --python 3.13
$ ansible-test units --docker default --python 3.13
A worked example
apk installs and removes packages, and is small enough to read in one sitting. Its three files are
plugins/modules/apk.uc, its documentation, and plugins/action/apk.py.
It starts by declaring what it accepts, and keeps the two things it uses everywhere at hand:
const module = AnsibleModule({
argument_spec: {
name: { type: 'str', required: true, aliases: [ 'pkg' ] },
state: { type: 'str', default: 'present',
choices: [ 'absent', 'installed', 'present', 'removed' ] },
update_cache: { type: 'bool', default: false },
},
supports_check_mode: true,
});
const params = module.params;
const result = module.result;
The state of the device is read through the package manager itself, by asking a question whose answer is an exit code:
function is_installed(pkg) {
return module.run_command([ 'apk', 'info', '-e', pkg ]).rc == 0;
}
Each operation then follows the same three steps: work out what actually has to be done, do it unless this is check mode, and report the change either way.
function install_packages(pkgs) {
let to_install = [];
for (let pkg in pkgs) {
if (!is_installed(pkg))
push(to_install, pkg);
}
if (length(to_install) == 0)
return;
if (!module.check_mode) {
let res = module.run_command([ 'apk', 'add', ...to_install ]);
result.update({ rc: res.rc, stdout: res.stdout, stderr: res.stderr });
for (let pkg in to_install) {
if (!is_installed(pkg))
module.fail_json(`failed to install ${pkg}: ${res.stdout} ${res.stderr}`);
}
}
result.changed();
}
Nothing to install means an early return and no change, which is what makes the second run of a playbook
quiet. The command itself is built as an array, so a package name is a single argument whatever it contains.
Success is not assumed from an exit code: the module asks again, and fails with the command’s output when the
package is still not there. result.changed() sits outside the check-mode guard, so a check-mode run
reports exactly what a real run would.
What is left is the flow, which reads like the documentation:
if (params.state in ['present', 'installed'])
install_packages(requested_packages());
else
remove_packages(requested_packages());
module.exit_json();