fiofleet 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fiofleet/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """fiofleet — bulk fleet operations for Foundries.io devices."""
2
+
3
+ __version__ = "0.5.0"
fiofleet/api.py ADDED
@@ -0,0 +1,243 @@
1
+ import ipaddress
2
+
3
+ import requests
4
+ from datetime import datetime, timezone
5
+
6
+ from .config import DEFAULT_API_BASE
7
+
8
+ ONLINE_THRESHOLD_SECONDS = 600 # a device is "online" if seen in the last 10 min
9
+
10
+ # Per-device config file controlling WireGuard, and the factory-level file the
11
+ # VPN server publishes. These match what fioctl/fioconfig use, so fiofleet and
12
+ # the rest of the Foundries tooling stay interoperable.
13
+ WG_CLIENT_FILE = "wireguard-client"
14
+ WG_SERVER_FILE = "wireguard-server"
15
+ # OnChanged handler an LmP device runs to apply the config (no-op elsewhere).
16
+ WG_VPN_HANDLER = "/usr/share/fioconfig/handlers/factory-config-vpn"
17
+
18
+
19
+ def _parse_ts(value):
20
+ """Parse an ISO-8601 timestamp from the API into an aware datetime, or None."""
21
+ if not value:
22
+ return None
23
+ try:
24
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
25
+ except ValueError:
26
+ return None
27
+
28
+
29
+ def _parse_kv(value):
30
+ """Parse the `key=value` lines of a wireguard config file (tolerates indent)."""
31
+ out = {}
32
+ for line in (value or "").splitlines():
33
+ line = line.strip()
34
+ if "=" in line:
35
+ k, v = line.split("=", 1)
36
+ out[k.strip()] = v.strip()
37
+ return out
38
+
39
+
40
+ class FoundriesAPI:
41
+ """Thin client over the Foundries.io OTA REST API.
42
+
43
+ Auth is the personal API token (https://app.foundries.io/settings/tokens/),
44
+ sent as the OSF-TOKEN header. Everything is scoped to a single factory.
45
+ """
46
+
47
+ def __init__(self, token, factory, api_base=DEFAULT_API_BASE, session=None):
48
+ self.factory = factory
49
+ self.api_base = api_base.rstrip("/")
50
+ self.session = session or requests.Session()
51
+ self.session.headers.update({
52
+ "accept": "application/json",
53
+ "OSF-TOKEN": token,
54
+ })
55
+
56
+ # --- low-level ---
57
+
58
+ def _url(self, path):
59
+ return f"{self.api_base}{path}"
60
+
61
+ def _get(self, path, **params):
62
+ r = self.session.get(self._url(path), params=params, timeout=30)
63
+ r.raise_for_status()
64
+ return r.json()
65
+
66
+ def _patch(self, path, payload):
67
+ r = self.session.patch(self._url(path), json=payload, timeout=30)
68
+ r.raise_for_status()
69
+ return r
70
+
71
+ # --- factories ---
72
+
73
+ def list_factories(self):
74
+ return self._get("/factories/")
75
+
76
+ def factory_config(self):
77
+ data = self._get(f"/factories/{self.factory}/config/")
78
+ return data.get("config", [])
79
+
80
+ # --- devices ---
81
+
82
+ def list_devices(self, tag=None, group=None):
83
+ """List devices in the factory, optionally filtered by tag/group."""
84
+ params = {"factory": self.factory, "page": 1, "limit": 1000}
85
+ if tag:
86
+ params["match_tag"] = tag
87
+ if group:
88
+ params["group"] = group
89
+ data = self._get("/devices/", **params)
90
+ return data.get("devices", [])
91
+
92
+ def get_device(self, name):
93
+ return self._get(f"/devices/{name}/")
94
+
95
+ def get_device_configs(self, name):
96
+ data = self._get(f"/devices/{name}/config/", page=1, limit=1000)
97
+ return data.get("config", [])
98
+
99
+ def is_online(self, device):
100
+ """Take a device dict (from list_devices/get_device) and return a bool."""
101
+ ts = _parse_ts(device.get("last-seen"))
102
+ if ts is None:
103
+ return False
104
+ delta = datetime.now(timezone.utc) - ts
105
+ return delta.total_seconds() <= ONLINE_THRESHOLD_SECONDS
106
+
107
+ # --- ota updates ---
108
+
109
+ # The updates endpoint only accepts an enumerated set of page sizes;
110
+ # anything else (e.g. limit=1) is a 400. We snap up to the smallest valid
111
+ # size that covers the caller's limit, then slice locally.
112
+ UPDATE_PAGE_SIZES = (10, 20, 50, 100)
113
+
114
+ def list_updates(self, name, limit=10):
115
+ """Recent OTA updates for a device, newest first.
116
+
117
+ Each item identifies one update attempt (a `correlation-id`) plus the
118
+ target/version it was moving to. The per-stage events live behind
119
+ `update_events`. Mirrors the data `fioctl devices list-updates` shows.
120
+ """
121
+ page_size = next((s for s in self.UPDATE_PAGE_SIZES if s >= (limit or 0)),
122
+ self.UPDATE_PAGE_SIZES[-1])
123
+ data = self._get(f"/devices/{name}/updates/", limit=page_size)
124
+ updates = data.get("updates", []) if isinstance(data, dict) else (data or [])
125
+ return updates[:limit] if limit else updates
126
+
127
+ def update_events(self, name, correlation_id):
128
+ """The ordered aktualizr event stream for a single update.
129
+
130
+ These are the libaktualizr report events (EcuDownloadStarted,
131
+ EcuInstallationCompleted, ...) the device posted to the device-gateway.
132
+ `updates.summarize` turns them into a staged pass/fail result.
133
+ """
134
+ data = self._get(f"/devices/{name}/updates/{correlation_id}/")
135
+ if isinstance(data, dict):
136
+ return data.get("events", data.get("Events", []))
137
+ return data or []
138
+
139
+ # --- wireguard ---
140
+
141
+ def wireguard_server(self):
142
+ """Parse the factory's wireguard-server config, or None if not enabled.
143
+
144
+ Returns a dict with endpoint, server_address, pubkey, enabled.
145
+ """
146
+ for cfg in self.factory_config():
147
+ for f in cfg.get("files", []):
148
+ if f.get("name") == WG_SERVER_FILE:
149
+ kv = _parse_kv(f.get("value"))
150
+ kv["enabled"] = kv.get("enabled", "1") != "0"
151
+ return kv
152
+ return None
153
+
154
+ def wireguard_ips(self):
155
+ """List of {name, pubkey, ip, enabled} — the live VPN peer view.
156
+
157
+ This is the same endpoint the Factory WireGuard server reads to build its
158
+ peer list, so it's the authoritative 'is this device on the VPN' source.
159
+ """
160
+ return self._get(f"/factories/{self.factory}/wireguard-ips/") or []
161
+
162
+ def wireguard_client(self, name):
163
+ """Parse a device's wireguard-client config into {address, pubkey, enabled}."""
164
+ for cfg in self.get_device_configs(name):
165
+ for f in cfg.get("files", []):
166
+ if f.get("name") == WG_CLIENT_FILE:
167
+ kv = _parse_kv(f.get("value"))
168
+ return {
169
+ "address": kv.get("address", ""),
170
+ "pubkey": kv.get("pubkey", ""),
171
+ # absence of an `enabled` line means enabled (fioctl semantics)
172
+ "enabled": kv.get("enabled", "1") != "0",
173
+ }
174
+ return {"address": "", "pubkey": "", "enabled": False}
175
+
176
+ def find_vpn_address(self):
177
+ """Pick the next free 10.42.42.x address (mirrors fioctl's allocator)."""
178
+ server = self.wireguard_server()
179
+ if not server or not server.get("enabled") or not server.get("server_address"):
180
+ raise RuntimeError("No WireGuard server is configured for this factory")
181
+ base = int(ipaddress.ip_address(server["server_address"]))
182
+ used = {
183
+ int(ipaddress.ip_address(i["ip"]))
184
+ for i in self.wireguard_ips() if i.get("ip")
185
+ }
186
+ for cand in range(base + 1, base + 10000):
187
+ if cand not in used and (cand & 0xFF) != 0:
188
+ return str(ipaddress.ip_address(cand))
189
+ raise RuntimeError("Unable to find a free VPN address")
190
+
191
+ def _write_wireguard_client(self, name, address, pubkey, enabled, reason):
192
+ value = f"address={address}\npubkey={pubkey}"
193
+ if not enabled:
194
+ value += "\nenabled=0"
195
+ payload = {
196
+ "reason": reason,
197
+ "files": [{
198
+ "name": WG_CLIENT_FILE,
199
+ "unencrypted": True,
200
+ "value": value,
201
+ "on-changed": [WG_VPN_HANDLER],
202
+ }],
203
+ }
204
+ return self._patch(f"/devices/{name}/config/", payload)
205
+
206
+ def enable_wireguard(self, name):
207
+ """Enable WireGuard on a device.
208
+
209
+ The device must already have reported a public key (an on-device agent —
210
+ fioconfig on LmP, or fiofleet's harness agent — generates the keypair and
211
+ publishes the pubkey over its mTLS channel). We then allocate a VPN
212
+ address and write the client config, exactly as `fioctl` does.
213
+ """
214
+ wcc = self.wireguard_client(name)
215
+ if not wcc["pubkey"]:
216
+ raise RuntimeError(
217
+ f"{name} has not reported a WireGuard public key yet "
218
+ "(the device generates it on first check-in)"
219
+ )
220
+ address = wcc["address"] or self.find_vpn_address()
221
+ return self._write_wireguard_client(
222
+ name, address, wcc["pubkey"], True, "fiofleet: enable wireguard"
223
+ )
224
+
225
+ def disable_wireguard(self, name):
226
+ wcc = self.wireguard_client(name)
227
+ return self._write_wireguard_client(
228
+ name, wcc["address"], wcc["pubkey"], False, "fiofleet: disable wireguard"
229
+ )
230
+
231
+ def wireguard_status(self, name):
232
+ """Return (enabled: bool, message: str) for a device's WireGuard."""
233
+ for item in self.wireguard_ips():
234
+ if item.get("name") == name:
235
+ if item.get("enabled"):
236
+ return True, f"peer active at {item.get('ip')}"
237
+ return False, "configured but disabled"
238
+ wcc = self.wireguard_client(name)
239
+ if wcc["pubkey"] and wcc["address"] and wcc["enabled"]:
240
+ return True, f"enabled at {wcc['address']} (awaiting server sync)"
241
+ if wcc["pubkey"]:
242
+ return False, "pubkey reported, not enabled"
243
+ return False, "no wireguard config found"