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 +3 -0
- fiofleet/api.py +243 -0
- fiofleet/cli.py +474 -0
- fiofleet/config.py +123 -0
- fiofleet/ssh.py +35 -0
- fiofleet/transport.py +163 -0
- fiofleet/updates.py +236 -0
- fiofleet/wireguard.py +57 -0
- fiofleet-0.5.0.dist-info/METADATA +211 -0
- fiofleet-0.5.0.dist-info/RECORD +14 -0
- fiofleet-0.5.0.dist-info/WHEEL +5 -0
- fiofleet-0.5.0.dist-info/entry_points.txt +2 -0
- fiofleet-0.5.0.dist-info/licenses/LICENSE +17 -0
- fiofleet-0.5.0.dist-info/top_level.txt +1 -0
fiofleet/__init__.py
ADDED
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"
|