Configuration Export & Import

FortiSOAR’s Configuration Export/Import Wizard bundles pieces of an appliance — module schema and records, picklists, connectors and their configs, playbook collections, roles, teams, dashboards, and more — into a portable .zip, and re-applies that bundle to another (or the same) box. pyfsr wraps both halves:

Direction

Surface

Class

Export

client.export_config

ExportConfigAPI

Import

client.import_config

ImportConfigAPI

An export is driven by an export template — the wizard’s saved selection of what to include. pyfsr gives you a typed builder, ExportTemplate (re-exported as pyfsr.ExportTemplate), plus one-call convenience methods that build a throwaway template, run the export, and clean up after themselves.

Note

Configuration export/import requires username/password auth — the operation is not available with an API-key token. pyfsr raises UnsupportedAuthOperationError up front if the current auth method can’t perform it.

Quickstart: round-trip a module’s records

The most common task is backing up (and restoring) records from a single module. export_record_data() does the whole export in one call; import_file() does the whole import.

from pyfsr import FortiSOAR, Query

client = FortiSOAR("soar.example.com", username="csadmin", password="<your-password>")

# Export every Open alert to a .zip (throwaway template, auto-cleaned).
path = client.export_config.export_record_data(
    "alerts",
    query=Query(module="alerts").eq("status", "Open"),
    limit=5000,
    output_path="open_alerts.zip",
)

# ...later, on this or another appliance, restore it end-to-end.
result = client.import_config.import_file("open_alerts.zip", wait=True)
print(result.status)   # "Import Complete"

A runnable version that also proves the data lands (it deletes the record between export and import, then confirms it comes back) ships as examples/export_import_records.py.

Exporting

The limit trigger for record sets

The single most surprising thing about the export engine: a record set emits rows only when its query carries a limit. A record set with no limit exports an empty data file — silently. There is no “export everything unbounded” option. pyfsr injects a limit for you (default in add_record_set()), so you rarely set it by hand, but you do need to raise it above the number of matching records or the export truncates:

# Count first, then export all matches.
n = client.records("alerts").count(Query(module="alerts").eq("status", "Open"))
path = client.export_config.export_record_data(
    "alerts",
    query=Query(module="alerts").eq("status", "Open"),
    limit=n,
)

On a template’s add_record_set, pass limit="all" to skip the manual count — create_template counts the matching records live and sets the limit to that count for you:

tmpl = ExportTemplate("Every open alert").add_record_set(
    "alerts", query=Query(module="alerts").eq("status", "Open"), limit="all",
)

Record data vs. module schema

export_record_data exports rows. It does not carry the module’s schema — the import side assumes the target already has an alerts module. To move the schema (fields, picklists it references, view templates), add those categories to a template explicitly (next section).

Building a full template

For anything beyond a single record set, compose a ExportTemplate and hand it to create_template(), then export by its uuid. The builder is fluent, and name-based categories (picklists, connectors, playbook collections, roles, teams, …) are resolved to IRIs for you at create_template time — you work in friendly names.

from pyfsr import ExportTemplate, Query

tmpl = (
    ExportTemplate("Alert backup")
    .add_module("alerts")                       # schema for the alerts module
    .add_record_set("alerts", query=Query(module="alerts").eq("status", "Open"))
    .add_picklist("AlertStatus")
    .add_connector("OpenAI")                     # with its saved configurations
    .add_playbook_collection("Incident Response")
    .add_role("SOC Analyst")
    .add_team("Tier 1")
)

created = client.export_config.create_template(tmpl)
uuid = created["@id"].split("/")[-1]
client.export_config.export_by_template_uuid(uuid, output_path="alert_backup.zip")

Available add_* categories on the builder include: add_module, add_record_set, add_view_templates, add_picklist, add_connector, add_playbook_collection, add_global_variable, add_playbook_block, add_app_setting, add_role, add_team, add_actor, add_navigation, add_report, add_rule, add_rule_channel, add_preprocessing_rule, add_dashboard, add_widget, add_ai_agent, add_mcp_configuration, and add_export_template. That is the complete set of export-wizard categories.

Note

add_view_templates(module, *, list_view=, detail=, form=) takes a module and which layouts — not a template id. The export engine resolves the real system_view_template rows for that module/layout combination. add_global_variable and add_playbook_block validate their name/uuid against the live appliance; add_app_setting accepts the fixed set systemSettings, LDAP, RADIUS, TOKEN, HA, sso, syslog, proxy.

Note

add_ai_agent and add_mcp_configuration are 8.0.0+ categories and are version-gated — exporting them against an older appliance raises.

To have the engine also pull each selected item’s dependencies for a category, enable it with auto_select_deps — this sets the template’s metadata.autoSelectDeps {<category>: bool} map (the category key matches the add_* category, e.g. "ai_agents"):

tmpl = ExportTemplate("Agent + deps").add_ai_agent("Phishing Triage").auto_select_deps("ai_agents")

Exporting a single connector (with configs)

Backing up an installed connector and its saved configurations — including the encrypted secrets — is common enough to have its own one-call helper:

path = client.export_config.export_connector("code-snippet", output_path="code_snippet.zip")

The archive’s connectors/data.json preserves each config_id and carries secrets in the appliance’s encrypted form, so feeding it straight back to import_file restores the connector configs intact. Set include_configurations=False to export just the connector.

Importing

import_file() runs the full wizard lifecycle for you: upload → create job → generate options → resolve conflicts → trigger → wait → verify → settle. With wait=True (the default) it blocks until the job reaches a terminal status and returns the final ImportJobResult.

result = client.import_config.import_file("alert_backup.zip", wait=True)
assert result.status == "Import Complete"

The conflict step, and refuse-by-default safety

The wizard’s “Choose Modules and Views to Import” screen is where the appliance diffs your bundle against what’s live and reports, per field, what would change and how to merge it (overwrite the live value vs. keep the existing one).

Some of those changes drive a destructive, appliance-wide schema migrate — a tableName rename, a field type change, or a change to a unique-constraint field. These can fail outright or wedge the box (e.g. a rename whose CREATE INDEX collides with the old table’s index — Postgres 42P07). Because that blast radius is appliance-wide (exactly like publish()), import_file refuses by default: if the generated options contain any risky change and you haven’t said how to handle it, it raises ValueError before triggering.

You pick how to proceed with the resolve= one-shot flag:

resolve=

Effect

"overwrite"

Apply every field change from the bundle.

"keep_existing"

Keep every existing field; add only genuinely new ones.

"skip_schema"

Import records/views but do not apply schema changes — the safe way past a risky rename.

# Restore records and views without touching live schema.
result = client.import_config.import_file("alert_backup.zip", resolve="skip_schema")

To inspect the risks before committing, generate the options yourself and read them with inspect_changes(), or drop to the step-by-step methods (create_job, generate_options, wait_for_options, set_options, trigger, wait_for_import). For full control over the merge, pass modify_options= — a callback that receives the options dict and returns the mutated dict; the module-level helpers connectors_only(), overwrite_all(), keep_existing(), and skip_schema_changes() are ready-made callbacks.

For connector bundles specifically, connector_flags() sets the two per-connector toggles — includeInstall (reinstall the connector) and includeConfigurations (restore its saved configs) — without disturbing the rest of the bundle:

from pyfsr.api.import_config import connector_flags

# Restore connector configs but do not reinstall the connectors themselves.
client.import_config.import_file(
    "bundle.zip",
    modify_options=lambda o: connector_flags(o, include_install=False, include_configurations=True),
)

Merge behavior for existing records and picklists

When a bundle’s records or picklists already exist on the target, the porter engine gives each category a whenExists merge mode. merge_mode() sets them:

  • record sets"replace" (the default) overwrites matching records with the bundle’s; "append" keeps the existing records and adds the bundle’s alongside.

  • picklists"keep" (the default) keeps the picklist list: items you added locally survive and nothing is deleted. It does not protect the individual items the bundle ships — those are still upserted by uuid, so a local edit to a bundle-shipped item is overwritten (live-verified on 8.0.0: a recoloured item reverted to the bundle’s colour under "keep"). "overwrite" replaces the picklist with the bundle’s version wholesale.

from pyfsr.api.import_config import merge_mode

# Add the bundle's records without clobbering existing rows, and refresh picklists.
client.import_config.import_file(
    "bundle.zip",
    modify_options=lambda o: merge_mode(o, record_sets="append", picklists="overwrite"),
)

Module settings and schema merge separately, per module. On the review screen each existing module defaults to an additive merge: new fields from the import are added and non-conflicting setting changes (e.g. default sort, labels) are applied, while existing fields — and system / unique-constraint fields — are kept. The module-level helpers set the equivalent of the wizard’s per-module dropdown: overwrite_all() applies every incoming field change (replace), keep_existing() keeps every existing field and adds only new ones (append), and skip_schema_changes() imports records/views but runs no schema migration at all.

Default behavior when installing or upgrading a solution pack

A solution pack is an export-configuration template (type: "SolutionPack Export"), and installing one runs the same porter engine as import_file(), via POST /api/3/solutionpacks/install?$type=<type>[&$replace=true]. So the defaults above apply — which matters most when you upgrade a pack that is already installed, because the upgrade can change live data and schema in place. With $replace off (the default) the install merges:

Category

Default on install/upgrade

What it can change

Records (record sets)

whenExists = "replace"

Existing records that match the pack’s (by uuid) are overwritten with the pack’s version; non-matching rows — including records from other packs and your own — are left alone. Local edits to a matched record are lost.

Picklists

whenExists = "keep"

Keeps the picklist list — items you added survive, nothing is deleted — but the pack’s own items are still upserted by uuid, so local edits to a pack-shipped item (colour, and by the same path display/order) are overwritten. "keep" is not “left untouched”.

Module settings / schema

additive merge, schema migration runs

New fields and non-conflicting module settings are applied; existing fields — including fields you added — are kept. A field type change, a tableName change, or a unique-constraint change drives a destructive migrate.

Note

The three rows above are live-verified on 8.0.0 by running the porter engine over a real solution pack’s own archive against deliberately-mutated state: a pack-owned record reverted to the pack’s value; a locally-added picklist item survived while a recoloured pack-shipped item reverted; a locally-added module field survived. Two controls held — a record belonging to a different pack and a record in no pack both kept their local edits, confirming the match is scoped to the pack’s own record set.

They are also confirmed in the appliance source (8.0.0), which is why they apply to a pack install and not just a hand-rolled import: SolutionPackController::install builds an ordinary ImportJob (setType('SolutionPack Import')) and delegates to the same Service/ConfigExportImport/* porter the import wizard uses, where the defaults are hardcoded — RecordSetConfig sets whenExists = 'replace', PicklistNameConfig sets whenExists = 'keep'. $replace is read with Symfony’s $request->query->getBoolean('$replace'), which is false when the flag is absent — so an install/upgrade merges unless you ask otherwise.

PicklistNameConfig::import is also where the "keep" subtlety comes from: it snapshots the existing items, then walks the bundle’s items and points each one at the matching existing row’s @id (matching on uuid or itemValue) — so the bundle’s values win — and only the leftover items the bundle doesn’t ship are re-appended untouched. "keep" preserves your additions, not your edits.

Checking the wizard’s Replace Existing box (or passing $replace=true) flips this toward a wholesale overwrite of existing content with the pack’s version.

Warning

A solution-pack upgrade is not read-only. By default it can overwrite records that match the pack’s record sets and run a schema migration on its modules. Before upgrading a pack in production, export the affected modules/records first (see the Exporting section above) so you have a restore point, and — if you drive the import yourself — inspect the generated options with inspect_changes() and steer the merge with merge_mode() / keep_existing() / skip_schema_changes().

Warning

allow_schema_changes=True bypasses the precheck entirely and triggers with the server-default options even when risky changes are present. Reach for a resolve= strategy first; only bypass when you understand the migrate.

Waiting, verifying, and settling

  • verify=True (default) raises FortiSOARException if the job finishes in a failure state, surfacing the appliance’s own errorMessage (including the 42P07 wedge). With verify=False the failed job is returned for you to inspect instead.

  • settle=True (default) blocks after a successful import until the schema cache is responsive again, so a follow-on list_modules() or query doesn’t hit a “Clearing Cache” / “Schema Update” 503.

  • wait=False returns right after triggering (the job carries jobUuid); poll later with wait_for_import().