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 |
|
|
Import |
|
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:
|
Effect |
|---|---|
|
Apply every field change from the bundle. |
|
Keep every existing field; add only genuinely new ones. |
|
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) |
|
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 |
|
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. |
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 |
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) raisesFortiSOARExceptionif the job finishes in a failure state, surfacing the appliance’s ownerrorMessage(including the42P07wedge). Withverify=Falsethe 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-onlist_modules()or query doesn’t hit a “Clearing Cache” / “Schema Update” 503.wait=Falsereturns right after triggering (the job carriesjobUuid); poll later withwait_for_import().