Bulk fleet operations for Foundries.io devices.
fioctl is great for single-device work. fiofleet is designed for when you have a large fleet of
devices, and want to enable/disable wireguard vpn and run ssh commands remotely en masse.
It's a thin, scriptable layer over the Foundries OTA API (and, optionally, fioctl).
- Device inventory — list/show devices, filter by tag or group, with online/offline detection.
- OTA update reports — for a tag/group, show each device's last update
broken down by stage (download → install), exactly which stage failed and
the error the device reported, plus a fleet-level pass/fail summary. Drill
into a single device's full update timeline with
ota stages. - WireGuard fleet management — enable/disable/status across many devices at
once, and wait until the platform confirms each device is a live VPN peer.
Works through the config API directly, so
fioctlis not required. - Fan-out SSH/exec — run a command (or open a shell) across a tag/group in
parallel, and collect the results as JSON (
--json) so you can drive scripts off them. Runs from any machine: fiofleet hops through your Factory WireGuard server (a bastion) and SSHes to the devices from there, so the operator doesn't need to be on the VPN.
pip install fiofleet
Requires Python 3.9+. fioctl is optional — only needed if you pass
--via-fioctl to the WireGuard commands.
fiofleet config set
# prompts for API token, factory name (and optionally an API base URL)
Or via env vars (these override the saved config):
export FOUNDRIES_API_TOKEN=...
export FOUNDRIES_FACTORY=my-factory
Get your API token at https://app.foundries.io/settings/tokens/.
To use ssh/exec from a machine that isn't on the device VPN, point fiofleet
at your Factory WireGuard server (the bastion it hops through):
fiofleet config set-server --server vpn.example.com --server-user ops
# add --device-password ... if your devices use password (sshpass) auth
Connecting to the server with an OpenSSH key (e.g. an Azure VM running the
Factory WireGuard server — the same .pem/OpenSSH key you'd use for ssh -i):
fiofleet config set-server \
--server my-fio-vpn.eastus.cloudapp.azure.com \
--server-user azureuser \
--server-key ~/.ssh/azure-fio-vpn.pem \
--device-user fio
# device auth happens on the server: add --device-password ... if devices need
# sshpass, otherwise omit (the server's own key reaches the devices).
--server-key is passed through to paramiko — anything ssh -i would accept
works (.pem, ~/.ssh/id_ed25519, …). Omit it to fall back to your SSH agent
/ default keys, then password.
# Factories your token can see
fiofleet factories
# Devices
fiofleet devices list
fiofleet devices list --tag prod-eu --online-only
fiofleet devices show my-device-01
# OTA update reports
fiofleet ota report --tag prod-eu # last update per device + fleet summary
fiofleet ota report --tag prod-eu --failed-only # just the devices that failed
fiofleet ota report --tag prod-eu --json # structured, for dashboards/CI
fiofleet ota report --tag prod-eu --target lmp-124 # every device that attempted target lmp-124
fiofleet ota report --tag prod-eu --target lmp-124 --failed-only # …and which of them failed
fiofleet ota stages my-device-01 # full stage timeline for one device
# WireGuard
fiofleet wg enable my-device-01
fiofleet wg enable --tag prod-eu --parallel 20 # enable + wait until applied
fiofleet wg status --tag prod-eu
fiofleet wg disable --tag prod-eu
fiofleet wg enable my-device-01 --via-fioctl # delegate to fioctl instead
# SSH / exec (hops through the configured WireGuard-server bastion by default)
fiofleet ssh my-device-01
fiofleet exec "uptime" --tag prod-eu
fiofleet exec "systemctl is-active aktualizr-lite" --tag prod-eu
fiofleet exec "fiotest" --tag prod-eu --json # collect results as JSON
fiofleet exec "reboot" --tag prod-eu --strict # non-zero exit if any device fails
fiofleet exec "uptime" --tag prod-eu --server vpn.example.com # ad-hoc bastion
fiofleet exec "uptime" --name dev-01 --direct # already on the VPN; skip the hop
A typical ota report looks like:
DEVICE RESULT FAILED@ TARGET WHEN
dev-us-01 FAILED install raspberrypi4-64-lmp-124 2026-05-21T08:14:02Z
-> install: Installation failed: ostree pull error: Server returned HTTP 500
dev-eu-02 IN_PROGRESS - raspberrypi4-64-lmp-124 2026-05-21T08:13:55Z
dev-eu-01 SUCCESS - raspberrypi4-64-lmp-124 2026-05-20T22:01:10Z
Fleet summary (3 device(s)):
FAILED 1 (install: 1)
IN_PROGRESS 1
SUCCESS 1
Each device posts a stream of libaktualizr
report events to the device-gateway as it updates
(EcuDownloadStarted/Completed, EcuInstallationStarted/Applied/Completed).
fiofleet reads that stream from the OTA API's per-device updates view — the
same history fioctl shows — and collapses it into two operator-facing stages,
download and install. A stage that reports success=false marks the
update FAILED at that stage and surfaces the details the device attached;
an update that reached EcuInstallationApplied but not …Completed is
IN_PROGRESS (applied, awaiting the post-reboot confirmation). No agent on the
device is required — it's all read from the API.
Pass --target X to scope the report to one rollout: each device's update
history is searched (newest first) for an update whose target/version contains
X, and only devices that actually attempted it appear in the output —
their most recent attempt, with the same SUCCESS/FAILED/IN_PROGRESS verdict
and failing-stage detail.
Enabling WireGuard on a device writes a wireguard-client config entry (the same
one fioctl devices config wireguard enable writes). The platform assigns the
device a 10.42.42.x address; the device applies the change on its next check-in.
fiofleet wg status / --wait poll the Foundries
wireguard-ips
view — the same one the Factory WireGuard server
reads to learn its peers — so "applied" means the platform actually considers the
device a live VPN peer, not just that a config was queued.
A route to a device only exists on the Factory WireGuard server — it's peered
into the VPN and keeps /etc/hosts in sync with device VPN IPs. Rather than
require you to be on that box, fiofleet treats it as a bastion: it opens an
SSH connection to the server (via paramiko) and
runs the device ssh there. So:
your laptop ──SSH──► WireGuard server ──SSH──► device (10.42.42.x)
(anywhere) (on the VPN) (fio@…)
Device authentication therefore happens on the server — using the server's key,
or a password via sshpass (--device-password) — exactly as an admin SSHing
into the box by hand would. Configure the bastion once with
fiofleet config set-server (or pass --server ad hoc); pass --direct to
skip it when you're already on the VPN. fiofleet runs ssh; it doesn't manage
the tunnel itself.
pip install -e ".[dev]"
pytest
Unit tests (50 tests, ~0.5s) cover the pieces that are easy to get wrong:
test_api.py— Foundries OTA API wrapper: auth header, factory scoping, pagination, the page-size snap for theupdates/endpoint.test_updates.py— the OTA event → stage collapse logic (download/install, SUCCESS / FAILED / IN_PROGRESS / UNKNOWN), the failing-stage + error detail surfacing, and--targethistory search.test_wireguard.py— enable/disable, thewireguard-ips"is the device a live peer yet" wait loop and timeout.test_transport.py— local vs. paramiko bastion transport selection, command quoting,sshpasswiring.test_cli.py— end-to-end CLI wiring of the above:exec --json/--strictexit codes,ota report --json,--failed-only,--targetfiltering.test_config.py— token/factory + server bastion persistence.
Verified live against a real factory (Foundries fioup/arm64-linux factory
shubhs-chocolate-factory, two device containers behind a Pi WireGuard server —
setup in harness/):
fiofleet factories,devices list,devices showfiofleet wg enable / status / disableend-to-end (config write → platform allocates10.42.42.x→wireguard-ipsconfirms live peer)fiofleet sshandfiofleet exec --json / --strictover the paramiko bastion from a non-VPN Windows host (laptop → Pi → device container)
Not yet live-verified: the OTA event-stage parser is exercised heavily by
unit tests, but the field names (EcuDownloadCompleted, success, details)
come from the libaktualizr docs — both harness containers still have empty
update history, so the parser has not been run against a real fioup event
stream. A real OTA rollout is the next thing to drive through it.
A local end-to-end harness (real Pi WireGuard server + containerised devices)
lives in harness/.
Apache 2.0