FMG API From Scratch Part 6: Pushing Configuration, and the Jinja Trap

Part 5 was reading. This part starts changing things, in the safest order FortiManager allows. There are three separate steps, and the value of the whole tool is in keeping them separate:

StepWhat it doesWhat it changes
PushCreates or updates a script objectThe ADOM database only. No device is involved
StageRuns that script against one deviceThat device’s Device Database. The live unit is untouched
InstallSends the Device Database difference to the unitThe live device

This part covers the first two. Part 7 is about reading back what staging did, and Part 8 is install. Nothing in this part can change a running device.

The code for this part is in the fmg-api-from-scratch repo at tag part-06.

Why scripts

FortiManager has two ways to apply a block of CLI to a device. The GUI’s newer provisioning templates (System Templates and CLI Templates) are the nicer model, but I could not find a working API schema for that object family, and I did not take the search further. CLI scripts are documented, the API for them worked, and they do what I need: run CLI text against a device. So the tool uses scripts. Whether the template family has a usable API on your version is worth checking before you copy my choice.

What the documentation covers

The documentation for this part is better than for most of the series, and it is worth knowing where it is. The community How-To FortiManager API site documents creating a CLI script object, running it against a device’s database, and the scope shape with vdom: "global". Fortinet’s own FortiManager API Best Practices Guide covers sessions, asynchronous tasks and workspace locking, and says nothing about scripts, install or logs. So the script calls below are documented and I checked them on 7.6.6. What the documents do not tell you is what happens when you get a field wrong, which is most of what follows.

The endpoint moved

Older write-ups, and older versions of the API reference, put scripts under /dvmdb/adom/<adom>/script. From 7.6.5 the script objects live at:

/pm/config/adom/<adom>/obj/fmg/script

You saw the consequence in Part 5. On my 7.6.6 box the old path is a valid URL that returns a valid, empty list, while the new path holds all eight scripts. Nothing errors. If you follow an older example you would conclude that your scripts vanished, or that the create call did nothing.

Note that only the object moved. Running a script, and reading its history, still use the /dvmdb/adom/<adom>/script/... paths. The same word, two trees, as in Part 5.

What a script object is

A script object has five fields that matter:

FieldMeaning
nameUnique within the ADOM. My convention: <template>__<device>
typecli (send this text as CLI) or jinja (FortiManager renders it first)
targetWhat it runs against. See below: this one bites
contentThe script text
descFree text. I use it for traceability

target ignores what it doesn’t recognise

target is an enumeration, and the server handles a value it doesn’t recognise by quietly storing the default. I tested it by writing three scratch scripts with different target values and reading each back, raw and with verbose:

I sentStored (raw)Stored (verbose)
adomdb2adomdb
device_database0(nothing: None)
bogus-value0(nothing: None)

set returned OK for all three. device_database is the value the How-To documents for a script that runs against a device’s database, and it is the one I use. But look at what came back: it was stored as 0, exactly like a nonsense string, and the verbose view had no name for it. Through the API I cannot tell a recognised device_database from a typo, because both end up as 0.

Two consequences:

  • 0 is the default, and the default is what writes to a device’s database. The behaviour I want is what comes back, and I confirmed it by what the runs did: the execution log header says Run on database, and the Device Database changed (Part 7). But that is evidence of the outcome, not of the spelling. I did not confirm in the GUI what it calls a target of 0.
  • A typo in target can never be caught by the API. A successful set tells you the request was tolerated, not what was stored. This is the lesson from Part 3 again: after a write, read it back.

The verbose view doesn’t decode 0 either (it comes back empty), the same kind of gap as vrf-select in Part 3. So compare raw values when they matter.

The Jinja trap

This is the most expensive thing in the series to get wrong, because it doesn’t fail.

The tempting design is: write a Jinja template, push it as a jinja script, and let FortiManager fill in the variables when it runs. It looks right. It even appears to work.

It doesn’t, for a reason that is stated plainly in FortiManager’s Administration Guide and easy to miss: Jinja scripts can only run against the ADOM database, never against a device’s Device Database. And install works by comparing the Device Database with the live unit. So the sequence goes:

  1. You push a jinja script with an ADOM-database target.
  2. You run it against a device. FortiManager accepts the request.
  3. A task completes. There is an execution log that looks genuine.
  4. Nothing was written to the Device Database, so there is no difference for install to send.

Every step reports success. The only thing that is wrong is the outcome. I confirmed it by isolation: a plain cli script aimed at a device’s database wrote to it and installed correctly, while the same device-scoped run of the jinja script produced a real-looking log and no change. Forcing a plain CLI script onto the ADOM-database target failed outright with a -8.

The fix is to change where the templating happens. Jinja is still the language, for its variables, loops and conditionals, but it is rendered locally, by your code, and FortiManager only ever receives finished CLI text, pushed as type: cli.

Two things worth taking from the story that are not specific to Jinja:

  • A green task is not a result. A staged script has to be checked by what it changed, not by whether it ran. Part 7 does that, and Part 8’s install preview is the final proof.
  • Read the platform’s statement of what a feature cannot do, not only the one that says what it can.

Render locally, and refuse to guess

The renderer is short. The one decision that matters is StrictUndefined:

def render(template_text: str, variables: dict[str, Any]) -> str:
    """Render a Jinja template locally. A missing variable is an error, never an empty string."""
    env = jinja2.Environment(undefined=jinja2.StrictUndefined,
                             trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
    return env.from_string(template_text).render(**variables)

Jinja’s default quietly turns an undefined variable into an empty string. In a configuration push that is dangerous in a specific way: set primary {{ dns_primary }} becomes set primary and the failure appears on the device, not in your code. With StrictUndefined the same mistake is an exception on your laptop, before anything is sent. trim_blocks and lstrip_blocks stop the template tags from leaving stray blank lines in the CLI.

There is a test for it, because it’s exactly the kind of setting that gets “tidied” away:

def test_missing_variable_is_an_error_not_blank(self):
    with self.assertRaises(jinja2.UndefinedError):
        render("set domain {{ d }}", {})

Push

The script object is plain data, and the push verifies what the server stored:

def script_object(name: str, content: str, desc: str = "") -> dict:
    """The body of a script object that can be run against a Device Database."""
    return {"name": name, "type": "cli", "target": "device_database",
            "content": content, "desc": desc}


def push(fmg, adom: str, name: str, content: str, desc: str = "") -> None:
    url = SCRIPT_URL.format(adom=adom)
    fmg.set(url, script_object(name, content, desc))
    rows = fmg.get(url, verbose=True, filter=["name", "==", name])
    if not rows:
        raise RuntimeError(f"script {name!r} not found after set")
    row = rows[0]
    if row.get("type") != "cli" or row.get("target") in ("adomdb", 2):
        raise RuntimeError(
            f"script {name!r} was stored as type={row.get('type')!r}, "
            f"target={row.get('target')!r}; expected a CLI script for a device database")

The read-back exists because of the target finding above. It refuses if the object came back as anything other than a CLI script, or as an ADOM-database script. It costs one extra call and closes the gap between “the request was accepted” and “the object is what I meant”.

Three more points:

set, and what it really does. set creates the object if it is missing and updates it if it exists. On this endpoint it behaves like a merge rather than a replace, and I re-confirmed that on the finished tool’s path: after creating a script with a description, a second set carrying only new content left the description untouched and grew the content from 17 characters to 37. That is convenient here. It is one object type on one version, so read the object back if you depend on a whole replacement.

The description is your audit trail. I put the template name, the short hash of the content, a UTC timestamp and the target device into desc. Six weeks from now, when someone asks where a script came from, the answer is on the object itself.

The content is a secret. Rendering happens on your machine, so the pushed text has every variable already substituted, including any pre-shared keys or passwords that the template uses. Two consequences. The script object and its execution logs now hold credentials in plain text on FortiManager, so treat those the way you would treat a configuration backup. And the tool must never print the rendered content. It prints the script name and the fact that it was pushed, nothing more. (Part 4’s redaction helper covers responses; this is the mirror problem for what you send.)

A word on where secrets come from. My tool reads variables from local files, which keeps them out of the repo only if you keep those files out of it too. FortiManager also has secret-typed metadata variables, which the tool lists with the value masked, but I have not built the push around them. If your templates carry keys or passwords, decide deliberately where those values live before you automate this, because the answer is not in the tool.

Stage

Staging runs the script object against one device:

def stage(fmg, adom: str, script: str, device: str) -> int:
    body = {"adom": adom, "script": script, "scope": [{"name": device, "vdom": "global"}]}
    result = fmg.execute(EXECUTE_URL.format(adom=adom), body)
    task = (result.get("data") or {}).get("task")
    if task is None:
        raise RuntimeError("script execute returned no task id; nothing was staged")
    return task
  • vdom must be "global". This is easy to get wrong because leaving it out looks like “the default VDOM”. It isn’t: if vdom is absent, FortiManager reads name as a device group. The documentation states this in the CLI Script section.
  • It returns a task id, not a result. The call only starts the work. The task is polled at /task/task/<id>, and that is where Part 7 begins.
  • No task id is an error. The client already rejects a response without data for reads, but exec responses don’t always carry data, so this check is explicit. Better to stop than to report a stage that never began.

The tests check the request as sent, since the request is the whole contract. The scope is asserted exactly: the device name, with vdom global.

Try it without touching anything real

I would not run the first push against a device you care about. The safe order:

  1. Make a script with a harmless, obviously fake name and content that is a comment.
  2. push it and read the object back.
  3. set it again with only new content, and read it back, to see whether set merged or replaced on your version.
  4. Delete it.

The account from Part 4 can do all of that, since script objects are the one thing it was left able to write. examples/script_demo.py in the repo does exactly this and always deletes what it creates. Its real output from my lab:

after push 1:        {'type': 'cli', 'target': None, 'desc': 'first description', 'content_chars': 17}
after set (no desc): {'type': 'cli', 'target': None, 'desc': 'first description', 'content_chars': 37}
after delete:        None

The description survived the second set, the content changed, and the object was gone at the end. That is the merge behaviour from Part 3 confirmed on the path the tool uses.

Then a real stage, against a lab spoke whose Device Database I could check in the GUI afterwards:

$ python fmg_push.py stage --script dns__<spoke-1> --device <spoke-1> --apply
Task 138 finished, state 4 (success).

A task id and a success state, and nothing more yet. Whether that run wrote anything is a separate question, and it is the one Part 7 answers.

Checklist

  • Push and stage are separate steps. Stage never touches the live device
  • Script objects live at /pm/config/adom/<adom>/obj/fmg/script from 7.6.5. The old path returns an empty list, not an error
  • Never push a jinja script to run against a device. Render locally and push cli
  • Use StrictUndefined, and keep the test that proves it
  • A green task is not a result. Check what changed
  • Enumerated fields quietly accept nonsense and store the default. Read back the raw value
  • set on this endpoint merges. Read back after writing
  • Put the template, content hash, time and device in desc
  • Rendered content is a secret. Never print it, and treat stored scripts and logs as sensitive
  • vdom must be "global" in the scope, or the name is read as a device group

What comes next

We can push a script and start a stage. Part 7 reads back what the stage did: the task, the execution log found by the id the task gives you, and how to tell that a “success” wrote something.