Metadata-Version: 2.4
Name: napalm-fortigate
Version: 0.2.1
Summary: NAPALM driver for Fortinet FortiOS, over the FortiOS REST API
Author-email: "Gary T. Giesen" <ggiesen@giesen.me>
License-Expression: MPL-2.0
Project-URL: Homepage, https://gitlab.com/ggiesen/napalm-fortigate
Project-URL: Repository, https://gitlab.com/ggiesen/napalm-fortigate
Project-URL: Changelog, https://gitlab.com/ggiesen/napalm-fortigate/-/blob/master/CHANGELOG.md
Project-URL: Issues, https://gitlab.com/ggiesen/napalm-fortigate/-/issues
Keywords: napalm,fortinet,fortios,fortigate,rest
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Networking
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: napalm>=5.0.0
Requires-Dist: requests>=2.28
Dynamic: license-file

# napalm-fortigate

A [NAPALM](https://github.com/napalm-automation/napalm) driver for Fortinet
FortiOS, built on the **FortiOS REST API** rather than SSH screen-scraping.

Status: **beta**. All twenty-six NAPALM getters are accounted for -- twenty
implemented against real hardware, six refused with a documented reason -- and
the configuration lifecycle is complete: merge, replace, commit, commit-confirm
and rollback. Nothing here reports data it has not actually seen.

## Why this exists

There are three prior attempts at a NAPALM FortiOS driver. All are abandoned and
none installs on a current Python:

| project | last commit | problem |
| --- | --- | --- |
| `napalm-automation-community/napalm-fortios` | Apr 2022 | last PyPI release 2018; depends on `pyFG`, which needs `pip.req` (removed in pip 10, 2018); its tests still import `napalm_base`, the pre-2.0 package name, so the suite has not run in years |
| `n3s0/napalm-fortios` + `n3s0/pyfg` | Apr 2025 | packaging fixed and it installs, but `pyfg` assigns `self.host = host` where the parameter is `hostname`, so constructing the client raises `NameError` immediately |
| `glennake/napalm-fortinet` | Aug 2022 | best foundation of the three (netmiko, no pyFG, correct imports) but abandoned mid-build; its test harness has never worked, since `open()` overwrites the test double with a real SSH connection |

The common thread is SSH. Every one of them screen-scrapes a CLI, which means a
parser per command, a dependency on terminal pagination behaviour, and -- on any
unit with FortiToken enforced -- an authentication problem NAPALM cannot solve,
because it has nowhere to get a TOTP code from.

REST avoids all of that. An API token is not subject to 2FA, responses are JSON,
and there is no pager to disable. It also removes the need for the
`config system console` / `set output standard` write that netmiko performs on
every connect, which is a configuration change on a production firewall.

## Why "fortigate" and not "fortios"

NAPALM names drivers after the operating system -- `ios`, `junos`, `eos`,
`nxos`, `panos` -- so `fortios` would be the conventional choice, and it is what
most people would guess.

It was not used, for two reasons. The name is claimed on PyPI by the abandoned
2018 package and used by three GitHub repositories, none of them maintained, so
it carries confusion without carrying any compatibility worth having: the old
driver has not been installable since 2018, so nobody has a working
`get_network_driver("fortios")` to stay compatible with. And `fortigate` is more
honest about scope, since this driver targets FortiGate units and will not work
against FortiSwitch or FortiAnalyzer, which have different APIs.

The name to use is therefore `fortigate`:

```python
get_network_driver("fortigate")
```

Inside `napalm_fortigate/_rest.py` the client is still called `FortiOSClient`,
because the API it speaks to is genuinely named the FortiOS REST API, and
because that module is the piece most likely to be extracted one day.

## Requirements

- Python 3.10+
- `napalm >= 5.0.0`
- `requests` (already a NAPALM dependency, so this driver adds nothing new)

Developed against FortiOS 7.4.x. Commit-confirm (`revert_in`) needs FortiOS
7.2.1 or later and a `super_admin` token; see [Commit confirm](#commit-confirm)
for what has been measured on which firmware.

## Install

```
pip install napalm-fortigate
```

To pin an exact release from the repository instead:

```
pip install "napalm-fortigate @ git+https://gitlab.com/ggiesen/napalm-fortigate.git@v0.2.1"
```

## Setting up an API token

The driver authenticates with an API token, not a password. On the FortiGate,
create a read-only access profile and an API user bound to it. Note that the
built-in profiles are not read-only -- `prof_admin` grants read-write across
every group -- so the profile has to be created:

```
config system accprofile
    edit "napalm-ro"
        set secfabgrp read
        set ftviewgrp read
        set authgrp   read
        set sysgrp    read
        set netgrp    read
        set loggrp    read
        set fwgrp     read
        set vpngrp    read
        set utmgrp    read
        set wifi      read
    next
end

config system api-user
    edit "napalm-ro"
        set accprofile "napalm-ro"
        set vdom "root"
        config trusthost
            edit 1
                set ipv4-trusthost <your-source-address>/32
            next
        end
    next
end

execute api-user generate-key napalm-ro
```

`generate-key` prints the token once. A trusthost is not optional in practice:
without one the token is usable from anywhere that can reach the admin port. Be
aware that pinning to a single dynamic address will break the driver the day it
changes.

For configuration management the profile needs write access, so create a second
API user rather than loosening the read-only one.

## Usage

```python
from napalm import get_network_driver

driver = get_network_driver("fortigate")
device = driver(
    hostname="192.0.2.1",
    username="",              # unused; the token identifies the caller
    password="",
    optional_args={
        "api_token": "<token>",
        "port": 8443,         # check admin-sport, it is often moved
        "ssl_verify": False,  # FortiGates ship a self-signed certificate
    },
)

device.open()
print(device.get_facts())
device.close()
```

`ssl_verify` defaults to `True`. Turning it off should be a deliberate choice;
pointing it at a CA bundle path is better where you can.

## Coverage

Verified against a FortiGate 60F on FortiOS 7.4.12.

| getter | source |
| --- | --- |
| `get_facts` | `monitor/system/status`, `monitor/system/interface`, `cmdb/system/dns` |
| `get_interfaces` | `monitor/system/interface` + `cmdb/system/interface` |
| `get_interfaces_ip` | `monitor/system/interface` + `cmdb/system/interface` |
| `get_interfaces_counters` | `monitor/system/interface` |
| `get_arp_table` | `monitor/network/arp` |
| `get_config` | `monitor/system/config/backup` -- see the privilege note below |
| `get_environment` | `monitor/system/resource/usage`, `monitor/system/sensor-info` |
| `get_optics` | `monitor/system/interface/transceivers` |
| `get_lldp_neighbors` | `monitor/network/lldp/neighbors` |
| `get_lldp_neighbors_detail` | `monitor/network/lldp/neighbors` |
| `get_bgp_neighbors` | `monitor/router/bgp/neighbors` + `cmdb/router/bgp` |
| `get_route_to` | `monitor/router/ipv4`, `monitor/router/ipv6` |
| `get_ntp_servers` | `cmdb/system/ntp` |
| `get_ntp_stats` | `monitor/system/ntp/status` |
| `get_firewall_policies` | `monitor/firewall/policy` + `cmdb/firewall/policy` |
| `get_bgp_config` | `cmdb/router/bgp` |
| `get_bgp_neighbors_detail` | `monitor/router/bgp/neighbors` + `paths` + `cmdb/router/bgp` |
| `get_network_instances` | `cmdb/system/vdom` + `cmdb/system/interface` |
| `get_snmp_information` | `cmdb/system.snmp/sysinfo` + `community` |
| `get_vlans` | `cmdb/system/interface` |

All twenty-six getters are now either implemented or refused with a reason.

Two API quirks worth knowing if you extend this. SNMP is namespaced under
`system.snmp`, with a dot -- `cmdb/system/snmp/sysinfo` returns HTTP 400 and
`cmdb/system.snmp/sysinfo` works. And `router/bgp/paths` names the neighbour
each path was `learned_from`, which is the only way to get a per-peer prefix
count: the `neighbors-statistics` and `paths-statistics` endpoints return
device-wide totals only.

### Where FortiOS cannot answer

Several fields are -1 or empty by necessity, not by omission. They are worth
knowing before building anything on top:

| field | why |
| --- | --- |
| `get_facts` -> `uptime` | not reported anywhere in the REST tree |
| `get_environment` -> `memory` | given as a percentage, with no total published to convert against |
| `get_bgp_neighbors` -> `sent_prefixes`, `uptime` | not published at all; received/accepted counts *are* derived from `router/bgp/paths` |
| `get_bgp_neighbors_detail` -> message counts, negotiated timers, flap count, neighbour router ID | not published; NAPALM's model describes far more than FortiOS reports |
| `get_network_instances` -> `route_distinguisher` | FortiOS attaches no RD to a VDOM |
| `get_route_to` -> `age` | routes carry an install date, not an age, and no reference clock is exposed |
| `get_interfaces` -> `last_flapped` | FortiOS records no interface flap timestamp |
| `get_interfaces_counters` -> discards, multicast, broadcast | totals only; no per-cast breakdown |
| `get_lldp_neighbors_detail` -> capabilities | not advertised in the LLDP response |
| `get_ntp_stats` -> `referenceid`, `when`, `hostpoll` | no FortiOS equivalent |

Four things worth knowing, all of which only became apparent against real
hardware:

**`uptime` is always -1.** FortiOS 7.4 does not report system uptime anywhere in
the REST monitor tree. Not `system/status`, not `system/resource/usage`, not
`system/performance/status`; the only `uptime` field in Fortinet's entire
Ansible module catalogue belongs to HA configuration. -1 is NAPALM's convention
for a fact the platform cannot supply, and is better than deriving a plausible
number from something else.

**`get_config` needs more than a read-only token.** `monitor/system/config/backup`
answers 403 to an access profile with `read` on every group, because a config
backup contains secrets. It needs write access to the system group. The rest of
the getters are happy read-only.

**Some endpoints simply do not exist on smaller units.**
`monitor/system/sensor-info` and `monitor/system/interface/transceivers` both
return 404 on a 60F, which is fanless and has no optical ports. The driver
treats those as "no sensors" and "no optics" rather than as errors, but code
that assumes a documented endpoint is present will break on the low end of the
range.

**`monitor/system/resource/usage` is enormous by default.** It returns every
historical sample and ran to 168KB on an idle unit. Always pass an `interval`.

Endpoint paths come from Fortinet's own `fortinet.fortios` Ansible collection,
which ships a machine-readable catalogue of the REST API. Parsing is written
against captured fixtures, not against those docs, so a getter only lands once
its response has been seen.

### Deliberately not implemented

These raise `NotImplementedError` with an explanation, and that is not expected
to change.

| getter | why |
| --- | --- |
| `get_mac_address_table` | The only MAC-table endpoints are under `switch-controller`, which covers managed FortiSwitches. A FortiGate's own internal switch is not exposed. Would need `diagnose netlink brctl` over SSH. |
| `get_ipv6_neighbors_table` | No NDP endpoint exists. `monitor/router/ipv6` returns routes, not neighbours. Would need `diagnose ipv6 neighbor-cache list` over SSH. |
| `get_ntp_peers` | A FortiGate is an NTP client. Reporting its configured servers as peers would be wrong. |
| `get_probes_config` | `system/link-monitor` is a failover health check, not an RPM/IP SLA probe. |
| `get_probes_results` | As above. |
| `ping`, `traceroute`, `cli` | The REST API has no endpoint that runs a command and returns its output. All 580 selectors in Fortinet's own module catalogue contain nothing of the sort, and `monitor/network/ping`, `monitor/system/ping`, the traceroute equivalents and `monitor/system/exec` all answer 404 on 7.4.12. `monitor/system/config-script/upload` does run CLI, but replies with a status envelope only -- a script of `get system status` returns success and no output -- so it is a configuration applicator, not a command runner. |
| `get_users` | `cmdb/system/admin` returns an empty list to an API-token caller no matter what the access profile grants -- verified with read-write on every group, and with the vdom and global scopes tried explicitly, while the same device lists two administrators over SSH. Returning `{}` would be reporting something known to be false, since every FortiGate has at least one administrator. |

Reaching the first two would mean a second credential -- an API token cannot open
an SSH session -- plus an admin account exempt from FortiToken. That is a
meaningful security cost for two getters, so the driver says no clearly instead
of half-doing it.

## VDOMs

Pass `optional_args={"vdom": "..."}` and every request carries that VDOM as a
query parameter. Verified on a FortiGate-VM running 7.4.12 with `vdom-mode`
set to `multi-vdom`:

- the parameter is attached to every `cmdb` and `monitor` request
- all eighteen getters that unit supports return correctly when scoped to a
  named VDOM
- a VDOM that does not exist is **rejected** with HTTP 424 rather than silently
  ignored, so a typo fails loudly instead of quietly returning another VDOM's
  data

**What has not been tested is separation between two VDOMs.** The permanent
evaluation licence will not allow a second one -- creating it fails with
`Command fail. Return code -4 (reached the maximum number of entries)`, despite
`get system status` advertising "Max number of virtual domains: 2", which
evidently counts internal VDOMs. Confirming that a getter scoped to VDOM A
cannot see VDOM B's data needs a licensed multi-VDOM device.

Commit-confirm works on single- and multi-VDOM units; see
[the commit-confirm notes on VDOMs](#vdoms-1) for the one difference.

## Configuration management

FortiOS has no candidate configuration. Neither does Cisco IOS, and NAPALM's IOS
driver has supported the full lifecycle for years by staging the candidate and
applying it at commit. This driver does the same, holding the candidate in the
driver rather than on the device.

| NAPALM method | FortiOS |
| --- | --- |
| `load_merge_candidate` | held in the driver; nothing is sent yet |
| `compare_config` | returns the staged commands |
| `commit_config` | `config-revision/save`, then `config-script/upload`, then `config/save` |
| `discard_config` | drops the staged text |
| `load_replace_candidate` | held in the driver; applied by `commit_config` via `config/restore` |
| `rollback` | `config/restore` of the revision `commit_config` took |
| `commit_config(revert_in=N)` | a revision, then a one-time automation stitch that restores it in N seconds, then `config-script/upload`; not saved -- see [Commit confirm](#commit-confirm) |
| `confirm_commit` | deletes the stitch, which cancels it, then `config/save` |
| `has_pending_commit` | whether the stitch exists on the unit and has not fired |

`config-script/upload` **executes the script as it uploads it**. There is no
upload-now-run-later for local scripts; the separate `config-script/run`
endpoint is for scripts pulled from a remote script server. So the upload
happens in `commit_config`, not in `load_merge_candidate`.

**`commit_config` does not lie.** This was the question worth answering before
trusting any of it, and it was tested against hardware: a script that fails
partway comes back as HTTP 500 with `status: error` and a `cli_error` field
carrying the full CLI transcript, naming the command that broke. That transcript
is included in the `MergeConfigException`. For example:

```
MergeConfigException: Config script failed; the device may be partially
configured. POST .../system/config-script/upload returned HTTP 500:
fortigate  config system global
fortigate (global)  set hostname "..."
fortigate (global)  end
fortigate  bogus-command
Unknown action 0
```

Note what that transcript shows: the valid commands ran before the bad one
failed. **A failed commit can leave the device partially configured.** The
revision taken at the start of `commit_config` is how you get back, and that
revision is taken before anything is applied -- if it cannot be taken, the
commit aborts rather than proceeding with no way back.

`compare_config` returns the commands to be applied, not a diff. FortiOS has no
server-side comparison, and the candidate is a fragment of CLI rather than a
full configuration, so diffing it against the running config would be
misleading. This is the same position
[librouteros PR #383](https://github.com/luqasz/librouteros/pull/383) takes, for
the same reason.

`load_replace_candidate` and `rollback` both go through
`monitor/system/config/restore`, which **reboots the unit**. Two things about
them are worth knowing before use.

**They need a `super_admin` token.** An access profile with read-write on every
group is not enough -- `config/restore` answers 403 to one. This is the same
shape of surprise as `config/backup` refusing a read-only token, one level up.
The driver says so in the exception rather than passing the bare 403 along.

**The reboot is measured, not assumed.** On a FortiGate-VM running 7.4.12 the
endpoint returns immediately with `{"restore_started": true, "config_restored":
true}`, the unit stops answering about eleven seconds later, and is back roughly
forty-three seconds after the call. Hardware will be slower. Validated by
setting a hostname, taking a revision, changing it, restoring, and confirming it
came back.

`rollback()` restores the revision `commit_config` recorded, and refuses if no
commit has run on that connection. Rolling back to whatever revision happens to
be newest would be guessing at intent; list them with
`monitor/system/config-revision` and restore one by hand if that is what you
want.

### Commit confirm

`commit_config(revert_in=N)` schedules its own undo on the unit before applying
anything:

1. It takes a configuration revision, found again by a nonce in its comment.
2. It creates a one-time automation stitch, `napalm-commit-confirm`, whose CLI
   script runs `execute restore config flash <revision>` N seconds from now.
3. It applies the change through `config-script/upload` and does **not** save.

`confirm_commit()` deletes the stitch, which cancels it, and saves. If nobody
confirms in time, the stitch fires and the unit restores the revision, **which
reboots it**. The revision predates the stitch, so nothing is left behind.

The stitch is the pending marker. `has_pending_commit()`, `confirm_commit()` and
`rollback()` all read it from the unit, so they give the right answer from a
fresh connection, a different host, or minutes later. `cfg-save` is never
touched.

#### Requirements

| requirement | why | if not met |
| --- | --- | --- |
| FortiOS 7.2.1 or later | `trigger-frequency once` arrived in 7.2.1 | `CommitConfirmException`, nothing changed |
| `super_admin` token | `execute restore`, and `rollback` | FortiOS refuses the request |
| `reboot-upon-config-restore enable` (the default) | with it disabled a restore does not take effect until the next reboot | `CommitConfirmException`, nothing changed |
| `monitor/system/automation-stitch/stats` | reports the trigger time, used to read the unit's clock and check the schedule | `CommitConfirmException`, nothing applied |
| `revert_in` between 60 and 86400 | triggers fire up to 30 seconds late; a day is an arbitrary sane limit | `CommitConfirmException`, nothing changed |

**Time is the unit's, not the caller's.** A one-time trigger is written in the
unit's local time. The driver reads the unit's UTC time from the HTTP `Date`
header, reads its UTC offset back through a harmless throwaway stitch
(`napalm-commit-confirm-clock`), then checks the real schedule against what the
unit reports before applying anything. Skew between the automation host and the
unit does not matter, and a timezone or DST surprise refuses the commit instead
of arming the wrong time. The window is still measured on the unit's clock, so
an NTP step during it moves the restore; keep units on NTP.

#### What has been measured, and where

Measured with `tools/probe_scheduled_restore.py` on a FortiGate-VM and by hand
on a FortiGate 40F. 7.6.x has not been tested at all.

| behaviour | 40F, 7.2.10 | VM, 7.4.12 |
| --- | --- | --- |
| a one-time trigger fires once, at its time, not at creation | yes (CLI) | yes (REST) |
| deleting the stitch before its time cancels it | yes (CLI) | yes (REST) |
| a trigger time already past is refused | yes | not tested |
| the stitch, trigger and action can be created over cmdb REST | not tested | yes |
| the stitch restores the revision, then is gone | **not tested** | yes, single and multi VDOM |
| someone saves during the window: the restore still runs and cleans up | not tested | yes |
| the stats endpoint exists and gives the offset away | not tested | yes |
| a revision captures running, not saved, configuration | not tested | yes |

Triggers fired between 2 and 28 seconds late across the VM runs, and 8 seconds
late on the 40F. On the VM the unit stopped answering about ten seconds after
the stitch fired and was back about a minute after that. Hardware will be
slower.

`execute restore config flash` asks for no confirmation when run from a script:
the transcript goes straight to `Get config from local disk OK. File check OK.`
and reboots. Fortinet's own
[auto-restore KB article](https://community.fortinet.com/fortigate-3/technical-tip-auto-restore-a-previous-config-if-the-change-fails-106670)
uses the same primitive, a stitch running a CLI restore, for the same purpose.

#### cfg-save modes

The mechanism does not depend on `cfg-save`, but what "saved" means does, and so
do the side effects.

| `cfg-save` | during the window | not confirmed | `confirm_commit` |
| --- | --- | --- | --- |
| `manual` | change and stitch applied, unsaved | restore, reboot | cancels, then saves everything staged on the unit |
| `automatic` | change and stitch saved as they are applied | restore, reboot | cancels; the save is a formality |
| `revert` | change and stitch unsaved, so the unit's own revert timer runs as well | restore, reboot -- or, if the unit's own `cfg-revert-timeout` is shorter than `revert_in`, its own revert gets there first, which also undoes the change (expected, not measured) | cancels, then saves, which also ends the unit's own revert window |

All three are exercised by the integration suite on the VM. The scheduled
restore itself was probed from `manual`.

Things to know, most of them about `manual`:

- **A restore is the whole revision.** Anything changed on the unit after the
  commit took its revision is rolled back too -- by anyone, including a save made
  during the window.
- **Revisions capture running configuration, not saved.** On a `manual` unit,
  changes that were staged but unsaved when the commit started are in the
  revision, so an unconfirmed restore makes them permanent.
- **Confirming saves.** On a `manual` unit that includes anything else staged,
  such as the paging change SSH backup tools make on every login. A plain
  `commit_config` has always done the same.
- **Someone saving during the window saves the stitch too.** It still fires,
  restores, and is gone afterwards, because the revision predates it. Nothing
  fired again in the following five minutes.
- With `revision-backup-on-logout` enabled, FortiOS saves a revision of its own
  whenever an admin session that changed something logs out, backup tools
  included. That does not confuse the commit, which finds its own revision by
  its nonce rather than by being newest, and it neither confirms nor cancels
  anything: a revision is a snapshot, not a save. Changes made over REST with an
  API token produce no such revision -- measured with the setting enabled and
  `admintimeout` at a minute -- so the driver's own traffic does not consume
  revision slots. Two things follow for the ones other sessions do write.
- **A revision taken during the window holds the pending change, and the
  schedule.** Revisions capture running configuration, so one written while a
  commit is pending contains the unconfirmed change *and*
  `napalm-commit-confirm`. Restoring that revision by hand later brings the
  change back with it. The schedule comes back inert: restored with a trigger
  time already in the past, the stitch was present but never fired, and the unit
  reported no next trigger for it.
- **A rotated-away revision fails loudly.** The VM kept 20 revisions, dropping
  the oldest as new ones arrived; a 40F has less flash, so check yours. The
  commit's revision is the newest when it is taken, so losing it inside the
  window would take a burst of other revisions. If it does happen the restore
  fails, the stitch stays in place, and `has_pending_commit` raises rather than
  reporting the change as confirmed.
- If the stitch has fired but is still present, the restore did not take effect.
  `has_pending_commit` and `confirm_commit` raise rather than answer, and
  `rollback()` restores the revision.
- `confirm_commit` raises when nothing is pending, as NAPALM's EOS and IOS
  drivers do. After a restore nothing is pending either, and returning quietly
  would report a rolled-back change as confirmed. Check `has_pending_commit`
  first, and verify the change when it says False.
- A second `commit_config` while one is pending raises `CommitError`.
- `revert_in` cannot be combined with `load_replace_candidate`: `config/restore`
  saves and reboots, so there is nothing to undo.
- Don't change the driver's own API key in a push with `revert_in`. A restore
  brings the old key back.
- HA is untested.

#### VDOMs

`execute restore` only exists in the global context. On a multi-VDOM unit the
script is wrapped in `config global` ... `end`; on a single-VDOM unit it runs as
is. Both restored correctly on the VM. Without the wrapper, a multi-VDOM unit
ran the script and silently did nothing, which is how this was found. The
automation objects are global either way. Commit-confirm together with
`optional_args["vdom"]` has not been tested.

#### Why not cfg-save revert, auto-script, or transactions

**`cfg-save revert`** is FortiOS's own safety net, and the first implementation
used it: switch to revert mode without saving, apply, and let the unit reboot to
its saved configuration unless confirmed. `tools/probe_cfg_revert.py` measured it
on 7.4.12. It worked from `manual` and `revert`, and from `automatic` the timer
never armed at all. It was dropped because revert mode turns *any* unsaved change
into a reboot. SSH tools that disable paging do it with `config system console`
/ `set output standard`, which is a configuration change, so a backup run against
a unit in revert mode reboots it -- every night, if backups are nightly. And a
unit would be left in revert mode if someone else saved during the window, since
that saves the switch too, which the driver cannot prevent.

**`system auto-script`** cannot delay anything. With `start auto` it runs the
script the moment it is created, and `repeat 1` means one run in total. Measured
on a 40F running 7.2.10. A scheduled restore would restore immediately.

**Workspace mode and REST transactions** stage changes until commit and discard
them on timeout. Nothing is live during the window, so there is nothing for a
post-check to verify. That is atomicity, not commit-confirm -- worth having
separately, since a config script that fails partway leaves earlier commands
applied.

## Development

The project uses [uv](https://github.com/astral-sh/uv).

```bash
uv sync
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv run mypy napalm_fortigate
```

The unit tests run entirely against captured fixtures under
`test/unit/mocked_data/`. No test touches a device.

See [CONTRIBUTING.md](CONTRIBUTING.md) for the conventions this project
follows, particularly around capturing and sanitising fixtures.

### Layout

```
napalm_fortigate/
    _rest.py     FortiOS REST client. Imports nothing from napalm.
    fortigate.py The driver. All NAPALM coupling lives here.
tools/
    boot-fortigate.sh           boots a disposable FortiGate for the integration job
    probe_scheduled_restore.py  measures the scheduled restore commit-confirm uses
    probe_revisions.py          measures how stored revisions behave around it
    probe_cfg_revert.py         measures cfg-save revert, the design it replaced
    _probe.py                   the probes' shared, scrubbing log output
```

`_rest.py` is kept free of NAPALM imports on purpose. If a standalone FortiOS
REST library ever turns out to be worth having, it can be lifted out without
modification. Until something other than this driver wants it, splitting the
repository would only buy release coordination overhead -- which is roughly how
`pyFG` came to strand the original driver.

### Branching

- `master` is the production branch and is protected.
- Work happens on feature branches, merged via merge request.
- The pipeline runs formatting, linting, type checking and unit tests on every
  push and merge request; all four must pass before merge.
- Releases are cut from `master` as a `vMAJOR.MINOR.PATCH` tag. The `publish`
  job builds and uploads to PyPI with Trusted Publishing (OIDC), so no API token
  is stored anywhere, and it is `when: manual`: tagging never publishes on its
  own.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## Licence

Mozilla Public License 2.0. See [LICENSE](LICENSE).
