goosebit 0.2.5__py3-none-any.whl → 0.2.7__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 +41 -7
- goosebit/api/telemetry/metrics.py +1 -5
- goosebit/api/v1/devices/device/responses.py +1 -0
- goosebit/api/v1/devices/device/routes.py +8 -8
- goosebit/api/v1/devices/requests.py +20 -0
- goosebit/api/v1/devices/routes.py +68 -8
- goosebit/api/v1/download/routes.py +14 -3
- goosebit/api/v1/rollouts/routes.py +5 -4
- goosebit/api/v1/routes.py +2 -1
- goosebit/api/v1/settings/routes.py +14 -0
- goosebit/api/v1/settings/users/__init__.py +1 -0
- goosebit/api/v1/settings/users/requests.py +16 -0
- goosebit/api/v1/settings/users/responses.py +7 -0
- goosebit/api/v1/settings/users/routes.py +56 -0
- goosebit/api/v1/software/routes.py +18 -14
- goosebit/auth/__init__.py +49 -13
- goosebit/auth/permissions.py +80 -0
- goosebit/db/config.py +57 -1
- goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
- goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
- goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
- goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
- goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
- goosebit/db/models.py +19 -8
- goosebit/db/pg_ssl_context.py +51 -0
- goosebit/device_manager.py +262 -0
- goosebit/plugins/__init__.py +32 -0
- goosebit/schema/devices.py +8 -5
- goosebit/schema/plugins.py +67 -0
- goosebit/schema/updates.py +15 -0
- goosebit/schema/users.py +9 -0
- goosebit/settings/__init__.py +0 -3
- goosebit/settings/schema.py +60 -14
- goosebit/storage/__init__.py +62 -0
- goosebit/storage/base.py +14 -0
- goosebit/storage/filesystem.py +111 -0
- goosebit/storage/s3.py +104 -0
- goosebit/ui/bff/common/columns.py +50 -0
- goosebit/ui/bff/common/responses.py +1 -0
- goosebit/ui/bff/devices/device/__init__.py +1 -0
- goosebit/ui/bff/devices/device/routes.py +17 -0
- goosebit/ui/bff/devices/requests.py +1 -0
- goosebit/ui/bff/devices/routes.py +49 -46
- goosebit/ui/bff/download/routes.py +14 -3
- goosebit/ui/bff/rollouts/routes.py +32 -4
- goosebit/ui/bff/routes.py +2 -1
- goosebit/ui/bff/settings/__init__.py +1 -0
- goosebit/ui/bff/settings/routes.py +20 -0
- goosebit/ui/bff/settings/users/__init__.py +1 -0
- goosebit/ui/bff/settings/users/responses.py +33 -0
- goosebit/ui/bff/settings/users/routes.py +80 -0
- goosebit/ui/bff/software/routes.py +40 -12
- goosebit/ui/nav.py +12 -2
- goosebit/ui/routes.py +66 -13
- goosebit/ui/static/js/devices.js +32 -24
- goosebit/ui/static/js/login.js +21 -5
- goosebit/ui/static/js/logs.js +7 -22
- goosebit/ui/static/js/rollouts.js +31 -30
- goosebit/ui/static/js/settings.js +322 -0
- goosebit/ui/static/js/setup.js +28 -0
- goosebit/ui/static/js/software.js +127 -121
- goosebit/ui/static/js/util.js +25 -4
- goosebit/ui/templates/__init__.py +10 -1
- goosebit/ui/templates/login.html.jinja +5 -0
- goosebit/ui/templates/nav.html.jinja +13 -5
- goosebit/ui/templates/rollouts.html.jinja +4 -22
- goosebit/ui/templates/settings.html.jinja +88 -0
- goosebit/ui/templates/setup.html.jinja +71 -0
- goosebit/ui/templates/software.html.jinja +0 -11
- goosebit/updater/controller/v1/routes.py +119 -77
- goosebit/updater/routes.py +83 -8
- goosebit/updates/__init__.py +24 -31
- goosebit/updates/swdesc.py +15 -8
- goosebit/users/__init__.py +63 -0
- goosebit/util/__init__.py +0 -0
- goosebit/util/path.py +42 -0
- goosebit/util/version.py +92 -0
- goosebit-0.2.7.dist-info/METADATA +280 -0
- goosebit-0.2.7.dist-info/RECORD +133 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/WHEEL +1 -1
- goosebit/realtime/logs.py +0 -42
- goosebit/realtime/routes.py +0 -13
- goosebit/updater/manager.py +0 -325
- goosebit-0.2.5.dist-info/METADATA +0 -189
- goosebit-0.2.5.dist-info/RECORD +0 -99
- /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/LICENSE +0 -0
- {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,262 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import re
|
5
|
+
from enum import StrEnum
|
6
|
+
from typing import Any, Awaitable, Callable, Optional
|
7
|
+
|
8
|
+
from aiocache import caches
|
9
|
+
from fastapi.requests import Request
|
10
|
+
|
11
|
+
from goosebit.db.models import (
|
12
|
+
Device,
|
13
|
+
Hardware,
|
14
|
+
Rollout,
|
15
|
+
Software,
|
16
|
+
UpdateModeEnum,
|
17
|
+
UpdateStateEnum,
|
18
|
+
)
|
19
|
+
from goosebit.schema.updates import UpdateChunk
|
20
|
+
|
21
|
+
caches.set_config(
|
22
|
+
{
|
23
|
+
"default": {
|
24
|
+
"cache": "aiocache.SimpleMemoryCache",
|
25
|
+
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
26
|
+
"ttl": 600,
|
27
|
+
},
|
28
|
+
}
|
29
|
+
)
|
30
|
+
|
31
|
+
|
32
|
+
class HandlingType(StrEnum):
|
33
|
+
SKIP = "skip"
|
34
|
+
ATTEMPT = "attempt"
|
35
|
+
FORCED = "forced"
|
36
|
+
|
37
|
+
|
38
|
+
class DeviceManager:
|
39
|
+
_hardware_default = None
|
40
|
+
|
41
|
+
_update_sources: list[Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]]] = []
|
42
|
+
_config_callbacks: list[Callable[[Device, dict[str, Any]], Awaitable[None]]] = []
|
43
|
+
|
44
|
+
@staticmethod
|
45
|
+
async def get_device(dev_id: str) -> Device:
|
46
|
+
cache = caches.get("default")
|
47
|
+
device = await cache.get(dev_id)
|
48
|
+
if device:
|
49
|
+
return device
|
50
|
+
|
51
|
+
hardware = DeviceManager._hardware_default
|
52
|
+
if hardware is None:
|
53
|
+
hardware = (await Hardware.get_or_create(model="default", revision="default"))[0]
|
54
|
+
DeviceManager._hardware_default = hardware
|
55
|
+
|
56
|
+
device = (await Device.get_or_create(id=dev_id, defaults={"hardware": hardware}))[0]
|
57
|
+
result = await cache.set(device.id, device, ttl=600)
|
58
|
+
assert result, "device being cached"
|
59
|
+
|
60
|
+
return device
|
61
|
+
|
62
|
+
@staticmethod
|
63
|
+
async def save_device(device: Device, update_fields: list[str]):
|
64
|
+
await device.save(update_fields=update_fields)
|
65
|
+
|
66
|
+
# only update cache after a successful database save
|
67
|
+
result = await caches.get("default").set(device.id, device, ttl=600)
|
68
|
+
assert result, "device being cached"
|
69
|
+
|
70
|
+
@staticmethod
|
71
|
+
async def update_auth_token(device: Device, auth_token: str) -> None:
|
72
|
+
device.auth_token = auth_token
|
73
|
+
await DeviceManager.save_device(device, update_fields=["auth_token"])
|
74
|
+
|
75
|
+
@staticmethod
|
76
|
+
async def update_force_update(device: Device, force_update: bool) -> None:
|
77
|
+
device.force_update = force_update
|
78
|
+
await DeviceManager.save_device(device, update_fields=["force_update"])
|
79
|
+
|
80
|
+
@staticmethod
|
81
|
+
async def update_sw_version(device: Device, version: str) -> None:
|
82
|
+
device.sw_version = version
|
83
|
+
await DeviceManager.save_device(device, update_fields=["sw_version"])
|
84
|
+
|
85
|
+
@staticmethod
|
86
|
+
async def update_hardware(device: Device, hardware: Hardware) -> None:
|
87
|
+
device.hardware = hardware
|
88
|
+
await DeviceManager.save_device(device, update_fields=["hardware"])
|
89
|
+
|
90
|
+
@staticmethod
|
91
|
+
async def update_device_state(device: Device, state: UpdateStateEnum) -> None:
|
92
|
+
device.last_state = state
|
93
|
+
await DeviceManager.save_device(device, update_fields=["last_state"])
|
94
|
+
|
95
|
+
@staticmethod
|
96
|
+
async def update_last_connection(device: Device, last_seen: int, last_ip: str | None = None) -> None:
|
97
|
+
device.last_seen = last_seen
|
98
|
+
if last_ip is None:
|
99
|
+
await DeviceManager.save_device(device, update_fields=["last_seen"])
|
100
|
+
elif ":" in last_ip:
|
101
|
+
device.last_ipv6 = last_ip
|
102
|
+
await DeviceManager.save_device(device, update_fields=["last_seen", "last_ipv6"])
|
103
|
+
else:
|
104
|
+
device.last_ip = last_ip
|
105
|
+
await DeviceManager.save_device(device, update_fields=["last_seen", "last_ip"])
|
106
|
+
|
107
|
+
@staticmethod
|
108
|
+
async def update_update(device: Device, update_mode: UpdateModeEnum, software: Software | None):
|
109
|
+
device.assigned_software = software
|
110
|
+
device.update_mode = update_mode
|
111
|
+
if not update_mode == UpdateModeEnum.ROLLOUT:
|
112
|
+
device.feed = None
|
113
|
+
await DeviceManager.save_device(device, update_fields=["assigned_software_id", "update_mode", "feed"])
|
114
|
+
|
115
|
+
@staticmethod
|
116
|
+
async def update_name(device: Device, name: str):
|
117
|
+
device.name = name
|
118
|
+
await DeviceManager.save_device(device, update_fields=["name"])
|
119
|
+
|
120
|
+
@staticmethod
|
121
|
+
async def update_feed(device: Device, feed: str):
|
122
|
+
device.feed = feed
|
123
|
+
await DeviceManager.save_device(device, update_fields=["feed"])
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def add_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]):
|
127
|
+
DeviceManager._config_callbacks.append(callback)
|
128
|
+
|
129
|
+
@staticmethod
|
130
|
+
def remove_config_callback(callback: Callable[[Device, dict[str, Any]], Awaitable[None]]):
|
131
|
+
DeviceManager._config_callbacks.remove(callback)
|
132
|
+
|
133
|
+
@staticmethod
|
134
|
+
async def update_config_data(device: Device, **kwargs: dict[str, Any]):
|
135
|
+
model = kwargs.get("hw_boardname") or "default"
|
136
|
+
revision = kwargs.get("hw_revision") or "default"
|
137
|
+
sw_version = kwargs.get("sw_version")
|
138
|
+
|
139
|
+
await asyncio.gather(
|
140
|
+
*[cb(device, **kwargs) for cb in DeviceManager._config_callbacks] # type: ignore[call-arg]
|
141
|
+
)
|
142
|
+
|
143
|
+
hardware = (await Hardware.get_or_create(model=model, revision=revision))[0]
|
144
|
+
modified = False
|
145
|
+
|
146
|
+
if device.hardware != hardware:
|
147
|
+
device.hardware = hardware
|
148
|
+
modified = True
|
149
|
+
|
150
|
+
if device.last_state == UpdateStateEnum.UNKNOWN:
|
151
|
+
device.last_state = UpdateStateEnum.REGISTERED
|
152
|
+
modified = True
|
153
|
+
|
154
|
+
if device.sw_version != sw_version:
|
155
|
+
device.sw_version = sw_version
|
156
|
+
modified = True
|
157
|
+
|
158
|
+
if modified:
|
159
|
+
await DeviceManager.save_device(device, update_fields=["hardware_id", "last_state", "sw_version"])
|
160
|
+
|
161
|
+
@staticmethod
|
162
|
+
async def deployment_action_start(device: Device):
|
163
|
+
device.last_log = ""
|
164
|
+
device.progress = 0
|
165
|
+
await DeviceManager.save_device(device, update_fields=["last_log", "progress"])
|
166
|
+
|
167
|
+
@staticmethod
|
168
|
+
async def deployment_action_success(device: Device):
|
169
|
+
device.progress = 100
|
170
|
+
await DeviceManager.save_device(device, update_fields=["progress"])
|
171
|
+
|
172
|
+
@staticmethod
|
173
|
+
async def get_rollout(device: Device) -> Rollout | None:
|
174
|
+
if device.update_mode == UpdateModeEnum.ROLLOUT:
|
175
|
+
return (
|
176
|
+
await Rollout.filter(
|
177
|
+
feed=device.feed,
|
178
|
+
paused=False,
|
179
|
+
software__compatibility__devices__id=device.id,
|
180
|
+
)
|
181
|
+
.order_by("-created_at")
|
182
|
+
.first()
|
183
|
+
.prefetch_related("software")
|
184
|
+
)
|
185
|
+
|
186
|
+
return None
|
187
|
+
|
188
|
+
@staticmethod
|
189
|
+
async def _get_software(device: Device) -> Software | None:
|
190
|
+
if device.update_mode == UpdateModeEnum.ROLLOUT:
|
191
|
+
rollout = await DeviceManager.get_rollout(device)
|
192
|
+
if not rollout or rollout.paused:
|
193
|
+
return None
|
194
|
+
await rollout.fetch_related("software")
|
195
|
+
return rollout.software
|
196
|
+
if device.update_mode == UpdateModeEnum.ASSIGNED:
|
197
|
+
await device.fetch_related("assigned_software")
|
198
|
+
return device.assigned_software
|
199
|
+
|
200
|
+
if device.update_mode == UpdateModeEnum.LATEST:
|
201
|
+
return await Software.latest(device)
|
202
|
+
|
203
|
+
assert device.update_mode == UpdateModeEnum.PINNED
|
204
|
+
return None
|
205
|
+
|
206
|
+
@staticmethod
|
207
|
+
def add_update_source(source: Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]]):
|
208
|
+
DeviceManager._update_sources.append(source)
|
209
|
+
|
210
|
+
@staticmethod
|
211
|
+
async def get_alt_src_updates(request: Request, device: Device) -> list[tuple[HandlingType, UpdateChunk | None]]:
|
212
|
+
return await asyncio.gather(*[source(request, device) for source in DeviceManager._update_sources])
|
213
|
+
|
214
|
+
@staticmethod
|
215
|
+
async def get_update(device: Device) -> tuple[HandlingType, Software | None]:
|
216
|
+
software = await DeviceManager._get_software(device)
|
217
|
+
|
218
|
+
if software is None:
|
219
|
+
handling_type = HandlingType.SKIP
|
220
|
+
|
221
|
+
elif software.version == device.sw_version and not device.force_update:
|
222
|
+
handling_type = HandlingType.SKIP
|
223
|
+
|
224
|
+
elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
|
225
|
+
handling_type = HandlingType.SKIP
|
226
|
+
|
227
|
+
else:
|
228
|
+
handling_type = HandlingType.FORCED
|
229
|
+
|
230
|
+
return handling_type, software
|
231
|
+
|
232
|
+
@staticmethod
|
233
|
+
async def update_log(device: Device, log_data: str) -> None:
|
234
|
+
if log_data is None:
|
235
|
+
return
|
236
|
+
|
237
|
+
if device.last_log is None:
|
238
|
+
device.last_log = ""
|
239
|
+
|
240
|
+
# SWUpdate-specific log parsing to report progress
|
241
|
+
matches = re.findall(r"Downloaded (\d+)%", log_data)
|
242
|
+
if matches:
|
243
|
+
device.progress = matches[-1]
|
244
|
+
|
245
|
+
device.last_log += f"{log_data}\n"
|
246
|
+
|
247
|
+
await DeviceManager.save_device(device, update_fields=["progress", "last_log"])
|
248
|
+
|
249
|
+
@staticmethod
|
250
|
+
async def delete_devices(ids: list[str]):
|
251
|
+
await Device.filter(id__in=ids).delete()
|
252
|
+
for dev_id in ids:
|
253
|
+
result = await caches.get("default").delete(dev_id)
|
254
|
+
assert result == 1, "device has been cached"
|
255
|
+
|
256
|
+
|
257
|
+
async def get_device(dev_id: str) -> Device:
|
258
|
+
return await DeviceManager.get_device(dev_id)
|
259
|
+
|
260
|
+
|
261
|
+
async def get_device_or_none(dev_id: str) -> Optional[Device]:
|
262
|
+
return await Device.get_or_none(id=dev_id)
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import logging
|
2
|
+
from importlib.metadata import entry_points
|
3
|
+
|
4
|
+
from goosebit.schema.plugins import PluginSchema
|
5
|
+
from goosebit.settings import config
|
6
|
+
|
7
|
+
logger = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
|
10
|
+
def load() -> list:
|
11
|
+
plugin_configs = []
|
12
|
+
logger.info("Checking for plugins to be loaded...")
|
13
|
+
if len(config.plugins) == 0:
|
14
|
+
logger.info("No plugins found.")
|
15
|
+
return []
|
16
|
+
logger.info(f"Found plugins enabled in config: {config.plugins}")
|
17
|
+
|
18
|
+
entries = entry_points(group="goosebit.plugins")
|
19
|
+
|
20
|
+
for plugin in config.plugins:
|
21
|
+
modules = entries.select(name=plugin)
|
22
|
+
for module in modules: # should be 1 or 0
|
23
|
+
logger.info(f"Loading plugin: {plugin}")
|
24
|
+
loaded_plugin = module.load()
|
25
|
+
if not hasattr(loaded_plugin, "config"):
|
26
|
+
logger.error(f"Failed to load plugin: {plugin}, plugin has not defined config")
|
27
|
+
continue
|
28
|
+
if not isinstance(loaded_plugin.config, PluginSchema):
|
29
|
+
logger.error(f"Failed to load plugin: {plugin}, config is not an instance of PluginSchema")
|
30
|
+
continue
|
31
|
+
plugin_configs.append(loaded_plugin.config)
|
32
|
+
return plugin_configs
|
goosebit/schema/devices.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import time
|
4
|
+
from datetime import datetime
|
4
5
|
from enum import Enum, IntEnum, StrEnum
|
5
6
|
from typing import Annotated
|
6
7
|
|
@@ -8,7 +9,7 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_fie
|
|
8
9
|
|
9
10
|
from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
|
10
11
|
from goosebit.schema.software import HardwareSchema, SoftwareSchema
|
11
|
-
from goosebit.
|
12
|
+
from goosebit.settings import config
|
12
13
|
|
13
14
|
|
14
15
|
class ConvertableEnum(StrEnum):
|
@@ -29,14 +30,14 @@ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
|
|
29
30
|
class DeviceSchema(BaseModel):
|
30
31
|
model_config = ConfigDict(from_attributes=True)
|
31
32
|
|
32
|
-
|
33
|
+
id: str
|
33
34
|
name: str | None
|
34
35
|
sw_version: str | None
|
35
36
|
|
36
37
|
assigned_software: SoftwareSchema | None = Field(exclude=True)
|
37
38
|
hardware: HardwareSchema | None = Field(exclude=True)
|
38
39
|
|
39
|
-
feed: str
|
40
|
+
feed: str | None
|
40
41
|
progress: int | None
|
41
42
|
last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
|
42
43
|
update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
|
@@ -45,10 +46,11 @@ class DeviceSchema(BaseModel):
|
|
45
46
|
last_seen: Annotated[
|
46
47
|
int | None, BeforeValidator(lambda last_seen: round(time.time() - last_seen) if last_seen is not None else None)
|
47
48
|
]
|
49
|
+
auth_token: str | None
|
48
50
|
|
49
51
|
@computed_field # type: ignore[misc]
|
50
52
|
@property
|
51
|
-
def
|
53
|
+
def polling(self) -> bool | None:
|
52
54
|
return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None
|
53
55
|
|
54
56
|
@computed_field # type: ignore[misc]
|
@@ -74,4 +76,5 @@ class DeviceSchema(BaseModel):
|
|
74
76
|
@computed_field # type: ignore[misc]
|
75
77
|
@property
|
76
78
|
def poll_seconds(self) -> int:
|
77
|
-
|
79
|
+
time_obj = datetime.strptime(config.poll_time, "%H:%M:%S")
|
80
|
+
return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import inspect
|
2
|
+
from typing import Any, Awaitable, Callable, Type
|
3
|
+
|
4
|
+
from fastapi import APIRouter
|
5
|
+
from fastapi.requests import Request
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
7
|
+
from fastapi.templating import Jinja2Templates
|
8
|
+
from pydantic import BaseModel, Field, computed_field
|
9
|
+
from pydantic_settings import (
|
10
|
+
BaseSettings,
|
11
|
+
PydanticBaseSettingsSource,
|
12
|
+
SettingsConfigDict,
|
13
|
+
YamlConfigSettingsSource,
|
14
|
+
)
|
15
|
+
|
16
|
+
from goosebit.db import Device
|
17
|
+
from goosebit.device_manager import HandlingType
|
18
|
+
from goosebit.schema.updates import UpdateChunk
|
19
|
+
from goosebit.settings import config
|
20
|
+
|
21
|
+
|
22
|
+
def get_module_name():
|
23
|
+
module = inspect.getmodule(inspect.stack()[2][0])
|
24
|
+
if module is not None:
|
25
|
+
return module.__name__.split(".")[0]
|
26
|
+
raise TypeError("Could not discover plugin module name")
|
27
|
+
|
28
|
+
|
29
|
+
class PluginSchema(BaseModel):
|
30
|
+
class Config:
|
31
|
+
arbitrary_types_allowed = True
|
32
|
+
|
33
|
+
name: str = Field(
|
34
|
+
default_factory=get_module_name
|
35
|
+
) # get the name of the package this was initialized in (plugin package)
|
36
|
+
middleware: Type[Any] | None = None # ASGI middleware class
|
37
|
+
router: APIRouter | None = None
|
38
|
+
db_model_path: str | None = None
|
39
|
+
static_files: StaticFiles | None = None
|
40
|
+
templates: Jinja2Templates | None = None
|
41
|
+
update_source_hook: Callable[[Request, Device], Awaitable[tuple[HandlingType, UpdateChunk | None]]] | None = None
|
42
|
+
config_data_hook: Callable[[Device, dict[str, Any]], Awaitable[None]] | None = None
|
43
|
+
|
44
|
+
@computed_field # type: ignore[misc]
|
45
|
+
@property
|
46
|
+
def url_prefix(self) -> str:
|
47
|
+
return f"/plugins/{self.name}"
|
48
|
+
|
49
|
+
@computed_field # type: ignore[misc]
|
50
|
+
@property
|
51
|
+
def static_files_name(self) -> str:
|
52
|
+
return f"{self.name}_static"
|
53
|
+
|
54
|
+
|
55
|
+
class PluginSettings(BaseSettings):
|
56
|
+
model_config = SettingsConfigDict(extra="ignore")
|
57
|
+
|
58
|
+
@classmethod
|
59
|
+
def settings_customise_sources(
|
60
|
+
cls,
|
61
|
+
settings_cls: type[BaseSettings],
|
62
|
+
init_settings: PydanticBaseSettingsSource,
|
63
|
+
env_settings: PydanticBaseSettingsSource,
|
64
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
65
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
66
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
67
|
+
return tuple([env_settings, YamlConfigSettingsSource(settings_cls, config.config_file)])
|
@@ -0,0 +1,15 @@
|
|
1
|
+
from pydantic import BaseModel, Field
|
2
|
+
|
3
|
+
|
4
|
+
class UpdateChunkArtifact(BaseModel):
|
5
|
+
filename: str
|
6
|
+
hashes: dict[str, str]
|
7
|
+
size: int
|
8
|
+
links: dict[str, dict[str, str]] = Field(serialization_alias="_links")
|
9
|
+
|
10
|
+
|
11
|
+
class UpdateChunk(BaseModel):
|
12
|
+
part: str = "os"
|
13
|
+
version: str = "1"
|
14
|
+
name: str
|
15
|
+
artifacts: list[UpdateChunkArtifact]
|
goosebit/schema/users.py
ADDED
goosebit/settings/__init__.py
CHANGED
goosebit/settings/schema.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
import os
|
2
2
|
import secrets
|
3
|
+
from enum import StrEnum
|
3
4
|
from pathlib import Path
|
4
5
|
from typing import Annotated
|
5
6
|
|
6
|
-
from joserfc.
|
7
|
-
from pydantic import BaseModel, BeforeValidator, Field
|
7
|
+
from joserfc.jwk import OctKey
|
8
|
+
from pydantic import BaseModel, BeforeValidator, Field, model_validator
|
8
9
|
from pydantic_settings import (
|
9
10
|
BaseSettings,
|
10
11
|
PydanticBaseSettingsSource,
|
@@ -12,16 +13,36 @@ from pydantic_settings import (
|
|
12
13
|
YamlConfigSettingsSource,
|
13
14
|
)
|
14
15
|
|
15
|
-
from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT
|
16
|
+
from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT
|
16
17
|
|
17
18
|
|
18
|
-
class
|
19
|
-
|
20
|
-
|
21
|
-
|
19
|
+
class DeviceAuthMode(StrEnum):
|
20
|
+
SETUP = "setup" # setup mode, any devices polling with an auth token that don't have one will save it
|
21
|
+
STRICT = "strict" # all devices must have keys, and all keys must be set up with the API
|
22
|
+
EXTERNAL = "external" # all devices must have keys, keys must be validated by external auth service
|
23
|
+
LAX = "lax" # devices may or may not use keys, but device with keys set must poll with them
|
22
24
|
|
23
|
-
|
24
|
-
|
25
|
+
|
26
|
+
class ExternalAuthMode(StrEnum):
|
27
|
+
BEARER = "bearer" # validate token with external service using Bearer token
|
28
|
+
JSON = "json" # validate token with external service using JSON payload
|
29
|
+
|
30
|
+
|
31
|
+
class DeviceAuthSettings(BaseModel):
|
32
|
+
enable: bool = False
|
33
|
+
mode: DeviceAuthMode = DeviceAuthMode.STRICT
|
34
|
+
external_url: str | None = None
|
35
|
+
external_mode: ExternalAuthMode = ExternalAuthMode.JSON
|
36
|
+
external_json_key: str = "token"
|
37
|
+
|
38
|
+
@model_validator(mode="after")
|
39
|
+
def validate_external_mode_config(self):
|
40
|
+
if self.mode == DeviceAuthMode.EXTERNAL:
|
41
|
+
if self.external_url is None:
|
42
|
+
raise ValueError("External URL is required when using external authentication mode")
|
43
|
+
if self.external_mode == ExternalAuthMode.JSON and not self.external_json_key:
|
44
|
+
raise ValueError("External JSON key is required when using JSON mode")
|
45
|
+
return self
|
25
46
|
|
26
47
|
|
27
48
|
class PrometheusSettings(BaseModel):
|
@@ -32,22 +53,47 @@ class MetricsSettings(BaseModel):
|
|
32
53
|
prometheus: PrometheusSettings = PrometheusSettings()
|
33
54
|
|
34
55
|
|
56
|
+
class StorageType(StrEnum):
|
57
|
+
FILESYSTEM = "filesystem"
|
58
|
+
S3 = "s3"
|
59
|
+
|
60
|
+
|
61
|
+
class S3StorageSettings(BaseModel):
|
62
|
+
bucket: str
|
63
|
+
region: str = "us-east-1"
|
64
|
+
endpoint_url: str | None = None
|
65
|
+
access_key_id: str | None = None
|
66
|
+
secret_access_key: str | None = None
|
67
|
+
|
68
|
+
|
69
|
+
class StorageSettings(BaseModel):
|
70
|
+
backend: StorageType = StorageType.FILESYSTEM
|
71
|
+
s3: S3StorageSettings | None = None
|
72
|
+
|
73
|
+
|
35
74
|
class GooseBitSettings(BaseSettings):
|
36
|
-
model_config = SettingsConfigDict(env_prefix="GOOSEBIT_")
|
75
|
+
model_config = SettingsConfigDict(env_prefix="GOOSEBIT_", extra="ignore", env_nested_delimiter="__")
|
37
76
|
|
38
77
|
port: int = 60053 # GOOSE
|
78
|
+
tenant: str = "DEFAULT"
|
79
|
+
|
80
|
+
poll_time: str = "00:01:00"
|
39
81
|
|
40
|
-
|
41
|
-
|
42
|
-
|
82
|
+
max_concurrent_updates: int = 1000
|
83
|
+
|
84
|
+
device_auth: DeviceAuthSettings = DeviceAuthSettings()
|
43
85
|
|
44
86
|
secret_key: Annotated[OctKey, BeforeValidator(OctKey.import_key)] = secrets.token_hex(16)
|
45
87
|
|
46
|
-
|
88
|
+
plugins: list[str] = Field(default_factory=list)
|
47
89
|
|
48
90
|
db_uri: str = f"sqlite:///{GOOSEBIT_ROOT_DIR.joinpath('db.sqlite3')}"
|
91
|
+
db_ssl_crt: Path | None = None
|
92
|
+
|
49
93
|
artifacts_dir: Path = GOOSEBIT_ROOT_DIR.joinpath("artifacts")
|
50
94
|
|
95
|
+
storage: StorageSettings = StorageSettings()
|
96
|
+
|
51
97
|
metrics: MetricsSettings = MetricsSettings()
|
52
98
|
|
53
99
|
logging: dict = LOGGING_DEFAULT
|
@@ -0,0 +1,62 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import AsyncIterable
|
3
|
+
|
4
|
+
from goosebit.settings import config
|
5
|
+
from goosebit.settings.schema import GooseBitSettings, StorageType
|
6
|
+
from goosebit.storage.base import StorageProtocol
|
7
|
+
|
8
|
+
from .filesystem import FilesystemStorageBackend
|
9
|
+
from .s3 import S3StorageBackend
|
10
|
+
|
11
|
+
|
12
|
+
class GoosebitStorage:
|
13
|
+
def __init__(self, config: GooseBitSettings):
|
14
|
+
self.config = config
|
15
|
+
self._backend: StorageProtocol = self._create_backend()
|
16
|
+
|
17
|
+
def _create_backend(self) -> StorageProtocol:
|
18
|
+
|
19
|
+
if self.config.storage.backend == StorageType.FILESYSTEM:
|
20
|
+
return FilesystemStorageBackend(base_path=self.config.artifacts_dir)
|
21
|
+
|
22
|
+
elif self.config.storage.backend == StorageType.S3:
|
23
|
+
if self.config.storage.s3 is None:
|
24
|
+
return FilesystemStorageBackend(base_path=self.config.artifacts_dir)
|
25
|
+
|
26
|
+
s3_config = self.config.storage.s3
|
27
|
+
return S3StorageBackend(
|
28
|
+
bucket=s3_config.bucket,
|
29
|
+
region=s3_config.region,
|
30
|
+
endpoint_url=s3_config.endpoint_url,
|
31
|
+
access_key_id=s3_config.access_key_id,
|
32
|
+
secret_access_key=s3_config.secret_access_key,
|
33
|
+
)
|
34
|
+
|
35
|
+
else:
|
36
|
+
raise ValueError(f"Unknown storage backend type: {self.config.storage.backend}")
|
37
|
+
|
38
|
+
@property
|
39
|
+
def backend(self) -> StorageProtocol:
|
40
|
+
if self._backend is None:
|
41
|
+
raise RuntimeError("Storage backend not initialized. Use async context manager.")
|
42
|
+
return self._backend
|
43
|
+
|
44
|
+
async def store_file(self, source_path: Path, dest_path: Path) -> str:
|
45
|
+
return await self.backend.store_file(source_path, dest_path)
|
46
|
+
|
47
|
+
async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]:
|
48
|
+
async for chunk in self.backend.get_file_stream(uri): # type: ignore[attr-defined]
|
49
|
+
yield chunk
|
50
|
+
|
51
|
+
async def get_download_url(self, uri: str) -> str:
|
52
|
+
return await self.backend.get_download_url(uri)
|
53
|
+
|
54
|
+
def get_temp_dir(self) -> Path:
|
55
|
+
return self.backend.get_temp_dir()
|
56
|
+
|
57
|
+
async def delete_file(self, uri: str) -> bool:
|
58
|
+
return await self.backend.delete_file(uri)
|
59
|
+
|
60
|
+
|
61
|
+
# Init the module-level storage instance
|
62
|
+
storage = GoosebitStorage(config)
|
goosebit/storage/base.py
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import AsyncIterable, Protocol
|
3
|
+
|
4
|
+
|
5
|
+
class StorageProtocol(Protocol):
|
6
|
+
async def store_file(self, source_path: Path, dest_path: Path) -> str: ...
|
7
|
+
|
8
|
+
async def get_file_stream(self, uri: str) -> AsyncIterable[bytes]: ...
|
9
|
+
|
10
|
+
async def get_download_url(self, uri: str) -> str: ...
|
11
|
+
|
12
|
+
async def delete_file(self, uri: str) -> bool: ...
|
13
|
+
|
14
|
+
def get_temp_dir(self) -> Path: ...
|