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.
Files changed (105) hide show
  1. goosebit/__init__.py +50 -19
  2. goosebit/__main__.py +7 -0
  3. goosebit/api/responses.py +5 -0
  4. goosebit/api/routes.py +5 -15
  5. goosebit/api/telemetry/__init__.py +1 -0
  6. goosebit/{telemetry/__init__.py → api/telemetry/metrics.py} +9 -3
  7. goosebit/api/telemetry/prometheus/__init__.py +2 -0
  8. goosebit/api/telemetry/prometheus/readers.py +3 -0
  9. goosebit/api/telemetry/prometheus/routes.py +18 -0
  10. goosebit/api/telemetry/routes.py +9 -0
  11. goosebit/api/v1/__init__.py +1 -0
  12. goosebit/api/v1/devices/__init__.py +1 -0
  13. goosebit/api/v1/devices/device/__init__.py +1 -0
  14. goosebit/api/v1/devices/device/responses.py +13 -0
  15. goosebit/api/v1/devices/device/routes.py +27 -0
  16. goosebit/api/v1/devices/requests.py +7 -0
  17. goosebit/api/v1/devices/responses.py +16 -0
  18. goosebit/api/v1/devices/routes.py +35 -0
  19. goosebit/api/v1/download/__init__.py +1 -0
  20. goosebit/api/v1/download/routes.py +22 -0
  21. goosebit/api/v1/rollouts/__init__.py +1 -0
  22. goosebit/api/v1/rollouts/requests.py +16 -0
  23. goosebit/api/v1/rollouts/responses.py +19 -0
  24. goosebit/api/v1/rollouts/routes.py +50 -0
  25. goosebit/api/v1/routes.py +9 -0
  26. goosebit/api/v1/software/__init__.py +1 -0
  27. goosebit/api/v1/software/requests.py +5 -0
  28. goosebit/api/v1/software/responses.py +16 -0
  29. goosebit/api/v1/software/routes.py +77 -0
  30. goosebit/auth/__init__.py +101 -101
  31. goosebit/db/__init__.py +11 -0
  32. goosebit/db/config.py +10 -0
  33. goosebit/db/migrations/models/0_20240830054046_init.py +136 -0
  34. goosebit/{models.py → db/models.py} +17 -10
  35. goosebit/realtime/logs.py +4 -3
  36. goosebit/realtime/routes.py +2 -2
  37. goosebit/schema/__init__.py +0 -0
  38. goosebit/schema/devices.py +73 -0
  39. goosebit/schema/rollouts.py +31 -0
  40. goosebit/schema/software.py +37 -0
  41. goosebit/settings/__init__.py +17 -0
  42. goosebit/settings/const.py +21 -0
  43. goosebit/settings/schema.py +86 -0
  44. goosebit/ui/bff/__init__.py +1 -0
  45. goosebit/ui/bff/devices/__init__.py +1 -0
  46. goosebit/ui/bff/devices/requests.py +12 -0
  47. goosebit/ui/bff/devices/responses.py +39 -0
  48. goosebit/ui/bff/devices/routes.py +72 -0
  49. goosebit/ui/bff/download/__init__.py +1 -0
  50. goosebit/ui/bff/download/routes.py +22 -0
  51. goosebit/ui/bff/rollouts/__init__.py +1 -0
  52. goosebit/ui/bff/rollouts/responses.py +37 -0
  53. goosebit/ui/bff/rollouts/routes.py +52 -0
  54. goosebit/ui/bff/routes.py +11 -0
  55. goosebit/ui/bff/software/__init__.py +1 -0
  56. goosebit/ui/bff/software/responses.py +37 -0
  57. goosebit/ui/bff/software/routes.py +83 -0
  58. goosebit/ui/nav.py +16 -0
  59. goosebit/ui/routes.py +29 -66
  60. goosebit/ui/static/favicon.ico +0 -0
  61. goosebit/ui/static/favicon.svg +1 -1
  62. goosebit/ui/static/js/devices.js +47 -71
  63. goosebit/ui/static/js/index.js +4 -9
  64. goosebit/ui/static/js/login.js +23 -0
  65. goosebit/ui/static/js/logs.js +1 -1
  66. goosebit/ui/static/js/rollouts.js +33 -19
  67. goosebit/ui/static/js/{firmware.js → software.js} +87 -86
  68. goosebit/ui/static/js/util.js +60 -6
  69. goosebit/ui/static/svg/goosebit-logo.svg +1 -1
  70. goosebit/ui/templates/__init__.py +9 -1
  71. goosebit/ui/templates/devices.html.jinja +75 -0
  72. goosebit/ui/templates/index.html.jinja +25 -0
  73. goosebit/ui/templates/login.html.jinja +57 -0
  74. goosebit/ui/templates/logs.html.jinja +31 -0
  75. goosebit/ui/templates/nav.html.jinja +84 -0
  76. goosebit/ui/templates/rollouts.html.jinja +93 -0
  77. goosebit/ui/templates/software.html.jinja +139 -0
  78. goosebit/updater/controller/v1/routes.py +101 -96
  79. goosebit/updater/controller/v1/schema.py +56 -0
  80. goosebit/updater/manager.py +65 -65
  81. goosebit/updater/routes.py +3 -11
  82. goosebit/updates/__init__.py +91 -32
  83. goosebit/updates/swdesc.py +2 -7
  84. goosebit-0.2.1.dist-info/METADATA +173 -0
  85. goosebit-0.2.1.dist-info/RECORD +95 -0
  86. goosebit/api/devices.py +0 -136
  87. goosebit/api/download.py +0 -34
  88. goosebit/api/firmware.py +0 -57
  89. goosebit/api/helper.py +0 -30
  90. goosebit/api/rollouts.py +0 -87
  91. goosebit/db.py +0 -37
  92. goosebit/permissions.py +0 -75
  93. goosebit/settings.py +0 -64
  94. goosebit/telemetry/prometheus.py +0 -10
  95. goosebit/ui/templates/devices.html +0 -115
  96. goosebit/ui/templates/firmware.html +0 -163
  97. goosebit/ui/templates/index.html +0 -23
  98. goosebit/ui/templates/login.html +0 -65
  99. goosebit/ui/templates/logs.html +0 -36
  100. goosebit/ui/templates/nav.html +0 -117
  101. goosebit/ui/templates/rollouts.html +0 -76
  102. goosebit-0.1.2.dist-info/METADATA +0 -123
  103. goosebit-0.1.2.dist-info/RECORD +0 -51
  104. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/LICENSE +0 -0
  105. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/WHEEL +0 -0
@@ -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, Optional
9
+ from typing import Callable
10
10
 
11
- from aiocache import Cache, cached
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 POLL_TIME, POLL_TIME_UPDATING
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 update_fw_version(self, version: str) -> None:
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, firmware: Firmware | None):
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) -> Optional[Rollout]:
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, POLL_TIME)
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 == POLL_TIME:
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, Firmware]: ...
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 = POLL_TIME_UPDATING
138
+ self.poll_time = config.poll_time_updating
133
139
 
134
- async def _get_firmware(self) -> Firmware:
135
- return await Firmware.latest(await self.get_device())
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, Firmware]:
138
- firmware = await self._get_firmware()
139
- return HandlingType.FORCED, firmware
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
- cache = Cache(namespace="main")
165
- await cache.set(self.dev_id, device, ttl=600)
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 update_fw_version(self, version: str) -> None:
173
+ async def update_sw_version(self, version: str) -> None:
174
174
  device = await self.get_device()
175
- device.fw_version = version
176
- await self.save_device(device, update_fields=["fw_version"])
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, firmware: Firmware | None):
198
+ async def update_update(self, update_mode: UpdateModeEnum, software: Software | None):
199
199
  device = await self.get_device()
200
- device.assigned_firmware = firmware
200
+ device.assigned_software = software
201
201
  device.update_mode = update_mode
202
- await self.save_device(device, update_fields=["assigned_firmware_id", "update_mode"])
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("hw_model") or "default"
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) -> Optional[Rollout]:
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
- firmware__compatibility__devices__uuid=device.uuid,
251
+ software__compatibility__devices__uuid=device.uuid,
252
252
  )
253
253
  .order_by("-created_at")
254
254
  .first()
255
- .prefetch_related("firmware")
255
+ .prefetch_related("software")
256
256
  )
257
257
 
258
258
  return None
259
259
 
260
- async def _get_firmware(self) -> Firmware | None:
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("firmware")
268
- return rollout.firmware
267
+ await rollout.fetch_related("software")
268
+ return rollout.software
269
269
  if device.update_mode == UpdateModeEnum.ASSIGNED:
270
- await device.fetch_related("assigned_firmware")
271
- return device.assigned_firmware
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 Firmware.latest(device)
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, Firmware]:
279
+ async def get_update(self) -> tuple[HandlingType, Software]:
280
280
  device = await self.get_device()
281
- firmware = await self._get_firmware()
281
+ software = await self._get_software()
282
282
 
283
- if firmware is None:
283
+ if software is None:
284
284
  handling_type = HandlingType.SKIP
285
- self.poll_time = POLL_TIME
285
+ self.poll_time = config.poll_time_default
286
286
 
287
- elif firmware.version == device.fw_version and not device.force_update:
287
+ elif software.version == device.sw_version and not device.force_update:
288
288
  handling_type = HandlingType.SKIP
289
- self.poll_time = 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 = 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 = POLL_TIME_UPDATING
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, firmware
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(id__in=ids).delete()
347
- cache = Cache(namespace="main")
346
+ await Device.filter(uuid__in=ids).delete()
348
347
  for dev_id in ids:
349
- await cache.delete(dev_id)
348
+ result = await caches.get("default").delete(dev_id)
349
+ assert result == 1, "device has been cached"
@@ -1,20 +1,12 @@
1
1
  import time
2
2
 
3
- from fastapi import APIRouter, Depends, HTTPException
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="/{tenant}",
26
- dependencies=[Depends(verify_tenant), Depends(log_last_connection)],
17
+ prefix="/ddi",
18
+ dependencies=[Depends(log_last_connection)],
27
19
  tags=["ddi"],
28
20
  )
29
21
  router.include_router(controller.router)
@@ -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 Firmware, Hardware
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 create_firmware_update(uri: str):
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
- file_path = Path(url2pathname(unquote(parsed_uri.path)))
17
- update_info = await swdesc.parse_file(file_path)
18
- if update_info is None:
19
- return
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
- update_info = await swdesc.parse_remote(uri)
22
- if update_info is None:
23
- return
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
- return
26
-
27
- firmware = (
28
- await Firmware.get_or_create(
29
- uri=uri,
30
- version=str(update_info["version"]),
31
- size=update_info["size"],
32
- hash=update_info["hash"],
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 firmware.compatibility.add((await Hardware.get_or_create(model=model, revision=revision))[0])
40
- await firmware.save()
41
- return firmware
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, firmware: Firmware | None) -> list:
45
- if firmware is None:
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 firmware.local:
106
+ if software.local:
48
107
  href = str(
49
108
  request.url_for(
50
- "download_file",
51
- file_id=firmware.id,
109
+ "download_artifact",
110
+ dev_id=updater.dev_id,
52
111
  )
53
112
  )
54
113
  else:
55
- href = firmware.uri
114
+ href = software.uri
56
115
  return [
57
116
  {
58
117
  "part": "os",
59
118
  "version": "1",
60
- "name": firmware.path.name,
119
+ "name": software.path.name,
61
120
  "artifacts": [
62
121
  {
63
- "filename": firmware.path.name,
64
- "hashes": {"sha1": firmware.hash},
65
- "size": firmware.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
  ],
@@ -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
- temp_file = UPDATES_DIR.joinpath("temp.swu")
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
- parsed_file = await parse_file(temp_file)
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
+