Skip to content

Authoring Jobs

This section is the practical guide to writing Huginn jobs. It is organized by archetype because each archetype has a distinct shape, and writing one well means following that shape closely.

If you have not yet read Concepts › Job Archetypes, do that first - it covers what the four archetypes are and why each one exists. The rest of this section assumes you understand which archetype your job belongs to.

Pages in this section

The remainder of this page covers conventions that apply to every archetype. The per-archetype pages above pick up where this one leaves off and assume you have read it.

Conventions shared across all archetypes

Before reading the per-archetype pages, internalize these conventions. They apply universally and are not repeated on every page.

One job per module

Every job lives in its own Python module containing exactly one job class. The filename and class name follow the archetype's prefix convention:

Archetype Filename prefix Class prefix
Static parameter validation verify_* Verify*
Volatile parameter validation verify_*_increasing (or similar comparison-direction suffix) Verify*Increasing
Change change_* (and change_*_restore for paired restore jobs) Change* / Change*Restore
Gate gate_* (often gate_*_present or gate_*_absent) Gate*

Module structure

Every job module follows the same top-to-bottom layout:

  1. Module docstring (one line).
  2. Standard library imports.
  3. Third-party imports (muninn, huginn).
  4. Local package imports.
  5. Module-level helper objects (typically mn = muninn.Muninn(); mn.load_builtin_parsers()).
  6. Module-level message constants (named in SCREAMING_SNAKE_CASE, with {placeholder} slots).
  7. Module-level tuning constants (private, _LIKE_THIS).
  8. TypedDict definitions for the parameters payload.
  9. The job class.

Volatile jobs are an exception - they do not need message constants or TypedDicts because the framework owns the schema. See Volatile Parameter Validation.

Class metadata

Every job class that inherits from LearningTestCase (all four archetypes do) declares four narrative class attributes, in this order:

  • DESCRIPTION - one or two sentences explaining what the job validates or does.
  • SETUP - bulleted list of preconditions assumed by the job.
  • PROCEDURE - bulleted list of steps the job performs.
  • PASS_FAIL_CRITERIA - bulleted list of pass and fail conditions.

The attributes are defined on LearningTestCase, not on the plain TestCase base. In testing mode, the framework renders each one as a Jinja template, with the loaded parameters payload available as parameters, and adds the result to the report as a per-test documentation section. Use {{ parameters.timeout }}, not {timeout}, to interpolate a value: Python format placeholders print literally. Learning mode does not render them.

Recording results

Use context.results.add_result(ResultStatus.X, message) (positional arguments) for outcomes. Use context.results.add_command_execution(...) for every command executed against the testbed. Both are required for the report to render correctly.

Cache control

context.broker.execute(...) is cached by default: a repeat of the same command against the same device returns the earlier output. The cache is cleared at the start of each phase, unless the phase sets preserve_cache: true (see Test Plan Specification - Phase Fields), so the first read in a phase reflects the current state. Pass use_cache=False if the read must reflect a state change that just happened in the same job or phase (post-action verification, gate poll, volatile observation).

Command Support

Most jobs use check_command_support to skip targets where the relevant show command is not supported. The standard idiom:

async def check_command_support(self, context: Context) -> CommandSupportResult:
    applicable = []
    not_applicable: dict[str, str] = {}
    for device in context.targets:
        result = await context.broker.execute(device, self.command)
        if is_command_unsupported(result.output):
            not_applicable[device.name] = NOT_SUPPORTED_REASON.format(
                command=self.command
            )
            continue
        applicable.append(device)
    return CommandSupportResult(applicable=applicable, not_applicable=not_applicable)

Data-level applicability (e.g., a specific parsed field is absent, or per-item extraction yields nothing for a device) is handled in gather_state, not check_command_support. When gather_state determines a device has no meaningful data, it emits a NOT_APPLICABLE result via context.results.add_result() and omits the device from the returned parameters. See Static Parameter Validation § gather_state for the recommended pattern.

See also

  • Context API Reference - the Context object, Connection Broker API, result recording, and async patterns.