goosebit 0.1.2__py3-none-any.whl → 0.2.1__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.
- goosebit/__init__.py +50 -19
- goosebit/__main__.py +7 -0
- goosebit/api/responses.py +5 -0
- goosebit/api/routes.py +5 -15
- goosebit/api/telemetry/__init__.py +1 -0
- goosebit/{telemetry/__init__.py → api/telemetry/metrics.py} +9 -3
- goosebit/api/telemetry/prometheus/__init__.py +2 -0
- goosebit/api/telemetry/prometheus/readers.py +3 -0
- goosebit/api/telemetry/prometheus/routes.py +18 -0
- goosebit/api/telemetry/routes.py +9 -0
- goosebit/api/v1/__init__.py +1 -0
- goosebit/api/v1/devices/__init__.py +1 -0
- goosebit/api/v1/devices/device/__init__.py +1 -0
- goosebit/api/v1/devices/device/responses.py +13 -0
- goosebit/api/v1/devices/device/routes.py +27 -0
- goosebit/api/v1/devices/requests.py +7 -0
- goosebit/api/v1/devices/responses.py +16 -0
- goosebit/api/v1/devices/routes.py +35 -0
- goosebit/api/v1/download/__init__.py +1 -0
- goosebit/api/v1/download/routes.py +22 -0
- goosebit/api/v1/rollouts/__init__.py +1 -0
- goosebit/api/v1/rollouts/requests.py +16 -0
- goosebit/api/v1/rollouts/responses.py +19 -0
- goosebit/api/v1/rollouts/routes.py +50 -0
- goosebit/api/v1/routes.py +9 -0
- goosebit/api/v1/software/__init__.py +1 -0
- goosebit/api/v1/software/requests.py +5 -0
- goosebit/api/v1/software/responses.py +16 -0
- goosebit/api/v1/software/routes.py +77 -0
- goosebit/auth/__init__.py +101 -101
- goosebit/db/__init__.py +11 -0
- goosebit/db/config.py +10 -0
- goosebit/db/migrations/models/0_20240830054046_init.py +136 -0
- goosebit/{models.py → db/models.py} +17 -10
- goosebit/realtime/logs.py +4 -3
- goosebit/realtime/routes.py +2 -2
- goosebit/schema/__init__.py +0 -0
- goosebit/schema/devices.py +73 -0
- goosebit/schema/rollouts.py +31 -0
- goosebit/schema/software.py +37 -0
- goosebit/settings/__init__.py +17 -0
- goosebit/settings/const.py +21 -0
- goosebit/settings/schema.py +86 -0
- goosebit/ui/bff/__init__.py +1 -0
- goosebit/ui/bff/devices/__init__.py +1 -0
- goosebit/ui/bff/devices/requests.py +12 -0
- goosebit/ui/bff/devices/responses.py +39 -0
- goosebit/ui/bff/devices/routes.py +72 -0
- goosebit/ui/bff/download/__init__.py +1 -0
- goosebit/ui/bff/download/routes.py +22 -0
- goosebit/ui/bff/rollouts/__init__.py +1 -0
- goosebit/ui/bff/rollouts/responses.py +37 -0
- goosebit/ui/bff/rollouts/routes.py +52 -0
- goosebit/ui/bff/routes.py +11 -0
- goosebit/ui/bff/software/__init__.py +1 -0
- goosebit/ui/bff/software/responses.py +37 -0
- goosebit/ui/bff/software/routes.py +83 -0
- goosebit/ui/nav.py +16 -0
- goosebit/ui/routes.py +29 -66
- goosebit/ui/static/favicon.ico +0 -0
- goosebit/ui/static/favicon.svg +1 -1
- goosebit/ui/static/js/devices.js +47 -71
- goosebit/ui/static/js/index.js +4 -9
- goosebit/ui/static/js/login.js +23 -0
- goosebit/ui/static/js/logs.js +1 -1
- goosebit/ui/static/js/rollouts.js +33 -19
- goosebit/ui/static/js/{firmware.js → software.js} +87 -86
- goosebit/ui/static/js/util.js +60 -6
- goosebit/ui/static/svg/goosebit-logo.svg +1 -1
- goosebit/ui/templates/__init__.py +9 -1
- goosebit/ui/templates/devices.html.jinja +75 -0
- goosebit/ui/templates/index.html.jinja +25 -0
- goosebit/ui/templates/login.html.jinja +57 -0
- goosebit/ui/templates/logs.html.jinja +31 -0
- goosebit/ui/templates/nav.html.jinja +84 -0
- goosebit/ui/templates/rollouts.html.jinja +93 -0
- goosebit/ui/templates/software.html.jinja +139 -0
- goosebit/updater/controller/v1/routes.py +101 -96
- goosebit/updater/controller/v1/schema.py +56 -0
- goosebit/updater/manager.py +65 -65
- goosebit/updater/routes.py +3 -11
- goosebit/updates/__init__.py +91 -32
- goosebit/updates/swdesc.py +2 -7
- goosebit-0.2.1.dist-info/METADATA +173 -0
- goosebit-0.2.1.dist-info/RECORD +95 -0
- goosebit/api/devices.py +0 -136
- goosebit/api/download.py +0 -34
- goosebit/api/firmware.py +0 -57
- goosebit/api/helper.py +0 -30
- goosebit/api/rollouts.py +0 -87
- goosebit/db.py +0 -37
- goosebit/permissions.py +0 -75
- goosebit/settings.py +0 -64
- goosebit/telemetry/prometheus.py +0 -10
- goosebit/ui/templates/devices.html +0 -115
- goosebit/ui/templates/firmware.html +0 -163
- goosebit/ui/templates/index.html +0 -23
- goosebit/ui/templates/login.html +0 -65
- goosebit/ui/templates/logs.html +0 -36
- goosebit/ui/templates/nav.html +0 -117
- goosebit/ui/templates/rollouts.html +0 -76
- goosebit-0.1.2.dist-info/METADATA +0 -123
- goosebit-0.1.2.dist-info/RECORD +0 -51
- {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/LICENSE +0 -0
- {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/WHEEL +0 -0
goosebit/updater/manager.py
CHANGED
@@ -6,20 +6,29 @@ from abc import ABC, abstractmethod
|
|
6
6
|
from contextlib import asynccontextmanager
|
7
7
|
from datetime import datetime
|
8
8
|
from enum import StrEnum
|
9
|
-
from typing import Callable
|
9
|
+
from typing import Callable
|
10
10
|
|
11
|
-
from aiocache import
|
12
|
-
from aiocache.serializers import PickleSerializer
|
11
|
+
from aiocache import cached, caches
|
13
12
|
|
14
|
-
from goosebit.models import (
|
13
|
+
from goosebit.db.models import (
|
15
14
|
Device,
|
16
|
-
Firmware,
|
17
15
|
Hardware,
|
18
16
|
Rollout,
|
17
|
+
Software,
|
19
18
|
UpdateModeEnum,
|
20
19
|
UpdateStateEnum,
|
21
20
|
)
|
22
|
-
from goosebit.settings import
|
21
|
+
from goosebit.settings import config
|
22
|
+
|
23
|
+
caches.set_config(
|
24
|
+
{
|
25
|
+
"default": {
|
26
|
+
"cache": "aiocache.SimpleMemoryCache",
|
27
|
+
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
28
|
+
"ttl": 600,
|
29
|
+
},
|
30
|
+
}
|
31
|
+
)
|
23
32
|
|
24
33
|
|
25
34
|
class HandlingType(StrEnum):
|
@@ -41,7 +50,7 @@ class UpdateManager(ABC):
|
|
41
50
|
async def update_force_update(self, force_update: bool) -> None:
|
42
51
|
return
|
43
52
|
|
44
|
-
async def
|
53
|
+
async def update_sw_version(self, version: str) -> None:
|
45
54
|
return
|
46
55
|
|
47
56
|
async def update_hardware(self, hardware: Hardware) -> None:
|
@@ -53,7 +62,7 @@ class UpdateManager(ABC):
|
|
53
62
|
async def update_last_connection(self, last_seen: int, last_ip: str) -> None:
|
54
63
|
return
|
55
64
|
|
56
|
-
async def update_update(self, update_mode: UpdateModeEnum,
|
65
|
+
async def update_update(self, update_mode: UpdateModeEnum, software: Software | None):
|
57
66
|
return
|
58
67
|
|
59
68
|
async def update_name(self, name: str):
|
@@ -62,16 +71,13 @@ class UpdateManager(ABC):
|
|
62
71
|
async def update_feed(self, feed: str):
|
63
72
|
return
|
64
73
|
|
65
|
-
async def update_flavor(self, flavor: str):
|
66
|
-
return
|
67
|
-
|
68
74
|
async def update_config_data(self, **kwargs):
|
69
75
|
return
|
70
76
|
|
71
77
|
async def update_log_complete(self, log_complete: bool):
|
72
78
|
return
|
73
79
|
|
74
|
-
async def get_rollout(self) ->
|
80
|
+
async def get_rollout(self) -> Rollout | None:
|
75
81
|
return None
|
76
82
|
|
77
83
|
@asynccontextmanager
|
@@ -105,11 +111,11 @@ class UpdateManager(ABC):
|
|
105
111
|
|
106
112
|
@property
|
107
113
|
def poll_time(self):
|
108
|
-
return UpdateManager.device_poll_time.get(self.dev_id,
|
114
|
+
return UpdateManager.device_poll_time.get(self.dev_id, config.poll_time_default)
|
109
115
|
|
110
116
|
@poll_time.setter
|
111
117
|
def poll_time(self, value: str):
|
112
|
-
if not value ==
|
118
|
+
if not value == config.poll_time_default:
|
113
119
|
UpdateManager.device_poll_time[self.dev_id] = value
|
114
120
|
return
|
115
121
|
if self.dev_id in UpdateManager.device_poll_time:
|
@@ -120,7 +126,7 @@ class UpdateManager(ABC):
|
|
120
126
|
await cb(log_data)
|
121
127
|
|
122
128
|
@abstractmethod
|
123
|
-
async def get_update(self) -> tuple[HandlingType,
|
129
|
+
async def get_update(self) -> tuple[HandlingType, Software]: ...
|
124
130
|
|
125
131
|
@abstractmethod
|
126
132
|
async def update_log(self, log_data: str) -> None: ...
|
@@ -129,14 +135,14 @@ class UpdateManager(ABC):
|
|
129
135
|
class UnknownUpdateManager(UpdateManager):
|
130
136
|
def __init__(self, dev_id: str):
|
131
137
|
super().__init__(dev_id)
|
132
|
-
self.poll_time =
|
138
|
+
self.poll_time = config.poll_time_updating
|
133
139
|
|
134
|
-
async def
|
135
|
-
return await
|
140
|
+
async def _get_software(self) -> Software:
|
141
|
+
return await Software.latest(await self.get_device())
|
136
142
|
|
137
|
-
async def get_update(self) -> tuple[HandlingType,
|
138
|
-
|
139
|
-
return HandlingType.FORCED,
|
143
|
+
async def get_update(self) -> tuple[HandlingType, Software]:
|
144
|
+
software = await self._get_software()
|
145
|
+
return HandlingType.FORCED, software
|
140
146
|
|
141
147
|
async def update_log(self, log_data: str) -> None:
|
142
148
|
return
|
@@ -145,13 +151,7 @@ class UnknownUpdateManager(UpdateManager):
|
|
145
151
|
class DeviceUpdateManager(UpdateManager):
|
146
152
|
hardware_default = None
|
147
153
|
|
148
|
-
@cached(
|
149
|
-
ttl=600,
|
150
|
-
key_builder=lambda fn, self: self.dev_id,
|
151
|
-
cache=Cache.MEMORY,
|
152
|
-
serializer=PickleSerializer(),
|
153
|
-
namespace="main",
|
154
|
-
)
|
154
|
+
@cached(key_builder=lambda fn, self: self.dev_id, alias="default")
|
155
155
|
async def get_device(self) -> Device:
|
156
156
|
hardware = DeviceUpdateManager.hardware_default
|
157
157
|
if hardware is None:
|
@@ -161,8 +161,8 @@ class DeviceUpdateManager(UpdateManager):
|
|
161
161
|
return (await Device.get_or_create(uuid=self.dev_id, defaults={"hardware": hardware}))[0]
|
162
162
|
|
163
163
|
async def save_device(self, device: Device, update_fields: list[str]):
|
164
|
-
|
165
|
-
|
164
|
+
result = await caches.get("default").set(self.dev_id, device, ttl=600)
|
165
|
+
assert result, "device being cached"
|
166
166
|
await device.save(update_fields=update_fields)
|
167
167
|
|
168
168
|
async def update_force_update(self, force_update: bool) -> None:
|
@@ -170,10 +170,10 @@ class DeviceUpdateManager(UpdateManager):
|
|
170
170
|
device.force_update = force_update
|
171
171
|
await self.save_device(device, update_fields=["force_update"])
|
172
172
|
|
173
|
-
async def
|
173
|
+
async def update_sw_version(self, version: str) -> None:
|
174
174
|
device = await self.get_device()
|
175
|
-
device.
|
176
|
-
await self.save_device(device, update_fields=["
|
175
|
+
device.sw_version = version
|
176
|
+
await self.save_device(device, update_fields=["sw_version"])
|
177
177
|
|
178
178
|
async def update_hardware(self, hardware: Hardware) -> None:
|
179
179
|
device = await self.get_device()
|
@@ -195,11 +195,11 @@ class DeviceUpdateManager(UpdateManager):
|
|
195
195
|
device.last_ip = last_ip
|
196
196
|
await self.save_device(device, update_fields=["last_seen", "last_ip"])
|
197
197
|
|
198
|
-
async def update_update(self, update_mode: UpdateModeEnum,
|
198
|
+
async def update_update(self, update_mode: UpdateModeEnum, software: Software | None):
|
199
199
|
device = await self.get_device()
|
200
|
-
device.
|
200
|
+
device.assigned_software = software
|
201
201
|
device.update_mode = update_mode
|
202
|
-
await self.save_device(device, update_fields=["
|
202
|
+
await self.save_device(device, update_fields=["assigned_software_id", "update_mode"])
|
203
203
|
|
204
204
|
async def update_name(self, name: str):
|
205
205
|
device = await self.get_device()
|
@@ -211,14 +211,11 @@ class DeviceUpdateManager(UpdateManager):
|
|
211
211
|
device.feed = feed
|
212
212
|
await self.save_device(device, update_fields=["feed"])
|
213
213
|
|
214
|
-
async def update_flavor(self, flavor: str):
|
215
|
-
device = await self.get_device()
|
216
|
-
device.flavor = flavor
|
217
|
-
await self.save_device(device, update_fields=["flavor"])
|
218
|
-
|
219
214
|
async def update_config_data(self, **kwargs):
|
220
|
-
model = kwargs.get("
|
215
|
+
model = kwargs.get("hw_boardname") or "default"
|
221
216
|
revision = kwargs.get("hw_revision") or "default"
|
217
|
+
sw_version = kwargs.get("sw_version")
|
218
|
+
|
222
219
|
hardware = (await Hardware.get_or_create(model=model, revision=revision))[0]
|
223
220
|
device = await self.get_device()
|
224
221
|
modified = False
|
@@ -231,76 +228,79 @@ class DeviceUpdateManager(UpdateManager):
|
|
231
228
|
device.last_state = UpdateStateEnum.REGISTERED
|
232
229
|
modified = True
|
233
230
|
|
231
|
+
if device.sw_version != sw_version:
|
232
|
+
device.sw_version = sw_version
|
233
|
+
modified = True
|
234
|
+
|
234
235
|
if modified:
|
235
|
-
await self.save_device(device, update_fields=["hardware_id", "last_state"])
|
236
|
+
await self.save_device(device, update_fields=["hardware_id", "last_state", "sw_version"])
|
236
237
|
|
237
238
|
async def update_log_complete(self, log_complete: bool):
|
238
239
|
device = await self.get_device()
|
239
240
|
device.log_complete = log_complete
|
240
241
|
await self.save_device(device, update_fields=["log_complete"])
|
241
242
|
|
242
|
-
async def get_rollout(self) ->
|
243
|
+
async def get_rollout(self) -> Rollout | None:
|
243
244
|
device = await self.get_device()
|
244
245
|
|
245
246
|
if device.update_mode == UpdateModeEnum.ROLLOUT:
|
246
247
|
return (
|
247
248
|
await Rollout.filter(
|
248
249
|
feed=device.feed,
|
249
|
-
flavor=device.flavor,
|
250
250
|
paused=False,
|
251
|
-
|
251
|
+
software__compatibility__devices__uuid=device.uuid,
|
252
252
|
)
|
253
253
|
.order_by("-created_at")
|
254
254
|
.first()
|
255
|
-
.prefetch_related("
|
255
|
+
.prefetch_related("software")
|
256
256
|
)
|
257
257
|
|
258
258
|
return None
|
259
259
|
|
260
|
-
async def
|
260
|
+
async def _get_software(self) -> Software | None:
|
261
261
|
device = await self.get_device()
|
262
262
|
|
263
263
|
if device.update_mode == UpdateModeEnum.ROLLOUT:
|
264
264
|
rollout = await self.get_rollout()
|
265
265
|
if not rollout or rollout.paused:
|
266
266
|
return None
|
267
|
-
await rollout.fetch_related("
|
268
|
-
return rollout.
|
267
|
+
await rollout.fetch_related("software")
|
268
|
+
return rollout.software
|
269
269
|
if device.update_mode == UpdateModeEnum.ASSIGNED:
|
270
|
-
await device.fetch_related("
|
271
|
-
return device.
|
270
|
+
await device.fetch_related("assigned_software")
|
271
|
+
return device.assigned_software
|
272
272
|
|
273
273
|
if device.update_mode == UpdateModeEnum.LATEST:
|
274
|
-
return await
|
274
|
+
return await Software.latest(device)
|
275
275
|
|
276
276
|
assert device.update_mode == UpdateModeEnum.PINNED
|
277
277
|
return None
|
278
278
|
|
279
|
-
async def get_update(self) -> tuple[HandlingType,
|
279
|
+
async def get_update(self) -> tuple[HandlingType, Software]:
|
280
280
|
device = await self.get_device()
|
281
|
-
|
281
|
+
software = await self._get_software()
|
282
282
|
|
283
|
-
if
|
283
|
+
if software is None:
|
284
284
|
handling_type = HandlingType.SKIP
|
285
|
-
self.poll_time =
|
285
|
+
self.poll_time = config.poll_time_default
|
286
286
|
|
287
|
-
elif
|
287
|
+
elif software.version == device.sw_version and not device.force_update:
|
288
288
|
handling_type = HandlingType.SKIP
|
289
|
-
self.poll_time =
|
289
|
+
self.poll_time = config.poll_time_default
|
290
290
|
|
291
291
|
elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
|
292
292
|
handling_type = HandlingType.SKIP
|
293
|
-
self.poll_time =
|
293
|
+
self.poll_time = config.poll_time_default
|
294
294
|
|
295
295
|
else:
|
296
296
|
handling_type = HandlingType.FORCED
|
297
|
-
self.poll_time =
|
297
|
+
self.poll_time = config.poll_time_updating
|
298
298
|
|
299
299
|
if device.log_complete:
|
300
300
|
await self.update_log_complete(False)
|
301
301
|
await self.clear_log()
|
302
302
|
|
303
|
-
return handling_type,
|
303
|
+
return handling_type, software
|
304
304
|
|
305
305
|
async def update_log(self, log_data: str) -> None:
|
306
306
|
if log_data is None:
|
@@ -343,7 +343,7 @@ async def get_update_manager(dev_id: str) -> UpdateManager:
|
|
343
343
|
|
344
344
|
|
345
345
|
async def delete_devices(ids: list[str]):
|
346
|
-
await Device.filter(
|
347
|
-
cache = Cache(namespace="main")
|
346
|
+
await Device.filter(uuid__in=ids).delete()
|
348
347
|
for dev_id in ids:
|
349
|
-
await
|
348
|
+
result = await caches.get("default").delete(dev_id)
|
349
|
+
assert result == 1, "device has been cached"
|
goosebit/updater/routes.py
CHANGED
@@ -1,20 +1,12 @@
|
|
1
1
|
import time
|
2
2
|
|
3
|
-
from fastapi import APIRouter, Depends
|
3
|
+
from fastapi import APIRouter, Depends
|
4
4
|
from fastapi.requests import Request
|
5
5
|
|
6
|
-
from goosebit.settings import TENANT
|
7
|
-
|
8
6
|
from . import controller
|
9
7
|
from .manager import get_update_manager
|
10
8
|
|
11
9
|
|
12
|
-
async def verify_tenant(tenant: str):
|
13
|
-
if not tenant == TENANT:
|
14
|
-
raise HTTPException(404)
|
15
|
-
return tenant
|
16
|
-
|
17
|
-
|
18
10
|
async def log_last_connection(request: Request, dev_id: str):
|
19
11
|
host = request.client.host
|
20
12
|
updater = await get_update_manager(dev_id)
|
@@ -22,8 +14,8 @@ async def log_last_connection(request: Request, dev_id: str):
|
|
22
14
|
|
23
15
|
|
24
16
|
router = APIRouter(
|
25
|
-
prefix="/
|
26
|
-
dependencies=[Depends(
|
17
|
+
prefix="/ddi",
|
18
|
+
dependencies=[Depends(log_last_connection)],
|
27
19
|
tags=["ddi"],
|
28
20
|
)
|
29
21
|
router.include_router(controller.router)
|
goosebit/updates/__init__.py
CHANGED
@@ -1,68 +1,127 @@
|
|
1
|
+
import shutil
|
1
2
|
from pathlib import Path
|
2
3
|
from urllib.parse import unquote, urlparse
|
3
4
|
from urllib.request import url2pathname
|
4
5
|
|
6
|
+
from fastapi import HTTPException
|
5
7
|
from fastapi.requests import Request
|
8
|
+
from tortoise.expressions import Q
|
6
9
|
|
7
|
-
from goosebit.models import
|
10
|
+
from goosebit.db.models import Hardware, Software
|
11
|
+
from goosebit.updater.manager import UpdateManager
|
8
12
|
|
9
13
|
from . import swdesc
|
10
14
|
|
11
15
|
|
12
|
-
async def
|
16
|
+
async def create_software_update(uri: str, temp_file: Path | None) -> Software:
|
13
17
|
parsed_uri = urlparse(uri)
|
14
18
|
|
19
|
+
# parse swu header into update_info
|
15
20
|
if parsed_uri.scheme == "file":
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
21
|
+
try:
|
22
|
+
update_info = await swdesc.parse_file(temp_file)
|
23
|
+
except Exception:
|
24
|
+
raise HTTPException(422, "Software swu header cannot be parsed")
|
25
|
+
|
20
26
|
elif parsed_uri.scheme.startswith("http"):
|
21
|
-
|
22
|
-
|
23
|
-
|
27
|
+
try:
|
28
|
+
update_info = await swdesc.parse_remote(uri)
|
29
|
+
except Exception:
|
30
|
+
raise HTTPException(422, "Software swu header cannot be parsed")
|
31
|
+
|
24
32
|
else:
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
)
|
34
|
-
)[0]
|
33
|
+
raise HTTPException(422, "Software URI protocol unknown")
|
34
|
+
|
35
|
+
if update_info is None:
|
36
|
+
raise HTTPException(422, "Software swu header contains invalid data")
|
37
|
+
|
38
|
+
# check for collisions
|
39
|
+
is_colliding = await _is_software_colliding(update_info)
|
40
|
+
if is_colliding:
|
41
|
+
raise HTTPException(409, "Software with same version and overlapping compatibility already exists")
|
35
42
|
|
43
|
+
# for local file: rename temp file to final name
|
44
|
+
if parsed_uri.scheme == "file":
|
45
|
+
path = _unique_path(parsed_uri)
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
47
|
+
shutil.copy(temp_file, path)
|
48
|
+
uri = path.absolute().as_uri()
|
49
|
+
|
50
|
+
# create software
|
51
|
+
software = await Software.create(
|
52
|
+
uri=uri,
|
53
|
+
version=str(update_info["version"]),
|
54
|
+
size=update_info["size"],
|
55
|
+
hash=update_info["hash"],
|
56
|
+
)
|
57
|
+
|
58
|
+
# create compatibility information
|
36
59
|
for comp in update_info["compatibility"]:
|
37
60
|
model = comp.get("hw_model", "default")
|
38
61
|
revision = comp.get("hw_revision", "default")
|
39
|
-
await
|
40
|
-
await
|
41
|
-
return
|
62
|
+
await software.compatibility.add((await Hardware.get_or_create(model=model, revision=revision))[0])
|
63
|
+
await software.save()
|
64
|
+
return software
|
65
|
+
|
66
|
+
|
67
|
+
async def _is_software_colliding(update_info):
|
68
|
+
version = update_info["version"]
|
69
|
+
compatibility = update_info["compatibility"]
|
70
|
+
|
71
|
+
# Extract hardware model and revision from compatibility info
|
72
|
+
hw_model_revisions = [(hw["hw_model"], hw["hw_revision"]) for hw in compatibility]
|
73
|
+
|
74
|
+
# Build the query for hardware matching the provided model and revision
|
75
|
+
hardware_query = Q()
|
76
|
+
for hw_model, hw_revision in hw_model_revisions:
|
77
|
+
hardware_query |= Q(model=hw_model, revision=hw_revision)
|
78
|
+
|
79
|
+
# Find the hardware IDs matching the provided model and revision
|
80
|
+
hardware_ids = await Hardware.filter(hardware_query).values_list("id", flat=True)
|
81
|
+
|
82
|
+
# Check if any existing software with the same version is compatible with any of these hardware IDs
|
83
|
+
is_colliding = await Software.filter(version=version, compatibility__in=hardware_ids).exists()
|
84
|
+
|
85
|
+
return is_colliding
|
86
|
+
|
87
|
+
|
88
|
+
def _unique_path(uri):
|
89
|
+
path = Path(url2pathname(unquote(uri.path)))
|
90
|
+
if not path.exists():
|
91
|
+
return path
|
92
|
+
|
93
|
+
counter = 1
|
94
|
+
new_path = path.with_name(f"{path.stem}-{counter}{path.suffix}")
|
95
|
+
while new_path.exists():
|
96
|
+
counter += 1
|
97
|
+
new_path = path.with_name(f"{path.stem}-{counter}{path.suffix}")
|
98
|
+
|
99
|
+
return new_path
|
42
100
|
|
43
101
|
|
44
|
-
def generate_chunk(request: Request,
|
45
|
-
|
102
|
+
async def generate_chunk(request: Request, updater: UpdateManager) -> list:
|
103
|
+
_, software = await updater.get_update()
|
104
|
+
if software is None:
|
46
105
|
return []
|
47
|
-
if
|
106
|
+
if software.local:
|
48
107
|
href = str(
|
49
108
|
request.url_for(
|
50
|
-
"
|
51
|
-
|
109
|
+
"download_artifact",
|
110
|
+
dev_id=updater.dev_id,
|
52
111
|
)
|
53
112
|
)
|
54
113
|
else:
|
55
|
-
href =
|
114
|
+
href = software.uri
|
56
115
|
return [
|
57
116
|
{
|
58
117
|
"part": "os",
|
59
118
|
"version": "1",
|
60
|
-
"name":
|
119
|
+
"name": software.path.name,
|
61
120
|
"artifacts": [
|
62
121
|
{
|
63
|
-
"filename":
|
64
|
-
"hashes": {"sha1":
|
65
|
-
"size":
|
122
|
+
"filename": software.path.name,
|
123
|
+
"hashes": {"sha1": software.hash},
|
124
|
+
"size": software.size,
|
66
125
|
"_links": {"download": {"href": href}},
|
67
126
|
}
|
68
127
|
],
|
goosebit/updates/swdesc.py
CHANGED
@@ -8,8 +8,6 @@ import httpx
|
|
8
8
|
import libconf
|
9
9
|
import semver
|
10
10
|
|
11
|
-
from goosebit.settings import UPDATES_DIR
|
12
|
-
|
13
11
|
logger = logging.getLogger(__name__)
|
14
12
|
|
15
13
|
|
@@ -69,12 +67,9 @@ async def parse_file(file: Path):
|
|
69
67
|
async def parse_remote(url: str):
|
70
68
|
async with httpx.AsyncClient() as c:
|
71
69
|
file = await c.get(url)
|
72
|
-
|
73
|
-
async with aiofiles.open(temp_file, "w+b") as f:
|
70
|
+
async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f:
|
74
71
|
await f.write(file.content)
|
75
|
-
|
76
|
-
temp_file.unlink()
|
77
|
-
return parsed_file
|
72
|
+
return await parse_file(Path(f.name))
|
78
73
|
|
79
74
|
|
80
75
|
def _sha1_hash_file(file_path: Path):
|
@@ -0,0 +1,173 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: goosebit
|
3
|
+
Version: 0.2.1
|
4
|
+
Summary:
|
5
|
+
Author: Upstream Data
|
6
|
+
Author-email: brett@upstreamdata.ca
|
7
|
+
Requires-Python: >=3.11,<4.0
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
11
|
+
Provides-Extra: postgresql
|
12
|
+
Requires-Dist: aerich (>=0.7.2,<0.8.0)
|
13
|
+
Requires-Dist: aiocache (>=0.12.2,<0.13.0)
|
14
|
+
Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
|
15
|
+
Requires-Dist: argon2-cffi (>=23.1.0,<24.0.0)
|
16
|
+
Requires-Dist: asyncpg (>=0.29.0,<0.30.0) ; extra == "postgresql"
|
17
|
+
Requires-Dist: fastapi[uvicorn] (>=0.111.0,<0.112.0)
|
18
|
+
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
19
|
+
Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
|
20
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
|
21
|
+
Requires-Dist: joserfc (>=1.0.0,<2.0.0)
|
22
|
+
Requires-Dist: libconf (>=2.0.1,<3.0.0)
|
23
|
+
Requires-Dist: opentelemetry-distro (>=0.47b0,<0.48)
|
24
|
+
Requires-Dist: opentelemetry-exporter-prometheus (>=0.47b0,<0.48)
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.47b0,<0.48)
|
26
|
+
Requires-Dist: pydantic-settings (>=2.4.0,<3.0.0)
|
27
|
+
Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
|
28
|
+
Requires-Dist: semver (>=3.0.2,<4.0.0)
|
29
|
+
Requires-Dist: tortoise-orm (>=0.21.4,<0.22.0)
|
30
|
+
Requires-Dist: websockets (>=12.0,<13.0)
|
31
|
+
Description-Content-Type: text/markdown
|
32
|
+
|
33
|
+
# gooseBit
|
34
|
+
|
35
|
+
<img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
|
36
|
+
|
37
|
+
---
|
38
|
+
|
39
|
+
A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
|
40
|
+
|
41
|
+
## Quick Start
|
42
|
+
|
43
|
+
### Installation
|
44
|
+
|
45
|
+
1. Install dependencies using [Poetry](https://python-poetry.org/):
|
46
|
+
```bash
|
47
|
+
poetry install
|
48
|
+
```
|
49
|
+
2. Launch gooseBit:
|
50
|
+
```bash
|
51
|
+
python main.py
|
52
|
+
```
|
53
|
+
|
54
|
+
### Initial Configuration
|
55
|
+
|
56
|
+
Before running gooseBit for the first time, update the default credentials in `settings.yaml`. The default login for testing purposes is:
|
57
|
+
|
58
|
+
- **Username:** `admin@goosebit.local`
|
59
|
+
- **Password:** `admin`
|
60
|
+
|
61
|
+
## Assumptions
|
62
|
+
|
63
|
+
- Devices use [SWUpdate](https://swupdate.org) for managing software updates.
|
64
|
+
|
65
|
+
## Features
|
66
|
+
|
67
|
+
### Device Registry
|
68
|
+
|
69
|
+
When a device connects to gooseBit for the first time, it is automatically added to the device registry. The server will then request the device's configuration data, including:
|
70
|
+
|
71
|
+
- `hw_model` and `hw_revision`: Used to match compatible software.
|
72
|
+
- `sw_version`: Indicates the currently installed software version.
|
73
|
+
|
74
|
+
The registry tracks each device's status, including the last online timestamp, installed software version, update state, and more.
|
75
|
+
|
76
|
+
### Software Repository
|
77
|
+
|
78
|
+
Software packages (`*.swu` files) can be hosted directly on the gooseBit server or on an external server. gooseBit parses the software metadata to determine compatibility with specific hardware models and revisions.
|
79
|
+
|
80
|
+
### Device Update Modes
|
81
|
+
|
82
|
+
Devices can be configured with different update modes. The default mode is `Rollout`.
|
83
|
+
|
84
|
+
#### 1. Manual Update to Specified Software
|
85
|
+
|
86
|
+
Assign specific software to a device manually. Once installed, no further updates will be triggered.
|
87
|
+
|
88
|
+
#### 2. Automatic Update to Latest Software
|
89
|
+
|
90
|
+
Automatically updates the device to the latest compatible software, based on the reported `hw_model` and `hw_revision`. Note: versions are interpreted as [SemVer](https://semver.org) versions.
|
91
|
+
|
92
|
+
#### 3. Software Rollout
|
93
|
+
|
94
|
+
Rollouts target all devices with a specified "feed" value, ensuring that the assigned software is installed on all matching devices. Rollouts also track success and error rates, with future plans for automatic aborts. If multiple rollouts exist for the same feed, the most recent rollout takes precedence.
|
95
|
+
|
96
|
+
### Pause Updates
|
97
|
+
|
98
|
+
Devices can be pinned to their current software version, preventing any updates from being applied.
|
99
|
+
|
100
|
+
### Real-time Update Logs
|
101
|
+
|
102
|
+
While updates are in progress, gooseBit captures real-time logs, which are accessible through the device repository.
|
103
|
+
|
104
|
+
## Development
|
105
|
+
|
106
|
+
### Database
|
107
|
+
|
108
|
+
Create or upgrade database
|
109
|
+
|
110
|
+
```bash
|
111
|
+
poetry run aerich upgrade
|
112
|
+
```
|
113
|
+
|
114
|
+
After a model change create the migration
|
115
|
+
|
116
|
+
```bash
|
117
|
+
poetry run aerich migrate
|
118
|
+
```
|
119
|
+
|
120
|
+
### Code formatting and linting
|
121
|
+
|
122
|
+
Code is formatted using different tools
|
123
|
+
|
124
|
+
- black and isort for `*.py`
|
125
|
+
- biomejs for `*.js`, `*.json`
|
126
|
+
- prettier for `*.html`, `*.md`, `*.yml`, `*.yaml`
|
127
|
+
|
128
|
+
Code is linted using different tools as well
|
129
|
+
|
130
|
+
- flake8 for `*.py`
|
131
|
+
- biomejs for `*.js`
|
132
|
+
|
133
|
+
Best to have pre-commit install git hooks that run all those tools before a commit:
|
134
|
+
|
135
|
+
```bash
|
136
|
+
poetry run pre-commit install
|
137
|
+
```
|
138
|
+
|
139
|
+
To manually apply the hooks to all files use:
|
140
|
+
|
141
|
+
```bash
|
142
|
+
pre-commit run --all-files
|
143
|
+
```
|
144
|
+
|
145
|
+
### Testing
|
146
|
+
|
147
|
+
Tests are implemented using pytest. To run all tests
|
148
|
+
|
149
|
+
```bash
|
150
|
+
poetry run pytest
|
151
|
+
```
|
152
|
+
|
153
|
+
### Structure
|
154
|
+
|
155
|
+
The structure of gooseBit is as follows:
|
156
|
+
|
157
|
+
- `api`: Files for the API.
|
158
|
+
- `ui`: Files for the UI.
|
159
|
+
- `bff`: Backend for frontend API.
|
160
|
+
- `static`: Static files.
|
161
|
+
- `templates`: Jinja2 formatted templates.
|
162
|
+
- `nav`: Navbar handler.
|
163
|
+
- `updater`: DDI API handler and device update manager.
|
164
|
+
- `updates`: SWUpdate file parsing.
|
165
|
+
- `realtime`: Realtime API functionality with websockets.
|
166
|
+
- `auth`: Authentication functions and permission handling.
|
167
|
+
- `models`: Database models.
|
168
|
+
- `db`: Database config and initialization.
|
169
|
+
- `schema`: Pydantic models used for API type hinting.
|
170
|
+
- `settings`: Settings loader and handler.
|
171
|
+
- `telemetry`: Telemetry data handlers.
|
172
|
+
- `routes`: Routes for a giving endpoint, including the router.
|
173
|
+
|