goosebit 0.2.5__py3-none-any.whl → 0.2.6__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 (88) hide show
  1. goosebit/__init__.py +41 -7
  2. goosebit/api/telemetry/metrics.py +1 -5
  3. goosebit/api/v1/devices/device/responses.py +1 -0
  4. goosebit/api/v1/devices/device/routes.py +8 -8
  5. goosebit/api/v1/devices/requests.py +20 -0
  6. goosebit/api/v1/devices/routes.py +68 -8
  7. goosebit/api/v1/download/routes.py +14 -3
  8. goosebit/api/v1/rollouts/routes.py +5 -4
  9. goosebit/api/v1/routes.py +2 -1
  10. goosebit/api/v1/settings/routes.py +14 -0
  11. goosebit/api/v1/settings/users/__init__.py +1 -0
  12. goosebit/api/v1/settings/users/requests.py +16 -0
  13. goosebit/api/v1/settings/users/responses.py +7 -0
  14. goosebit/api/v1/settings/users/routes.py +56 -0
  15. goosebit/api/v1/software/routes.py +18 -14
  16. goosebit/auth/__init__.py +49 -13
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  20. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  21. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  22. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  23. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  24. goosebit/db/models.py +19 -8
  25. goosebit/db/pg_ssl_context.py +51 -0
  26. goosebit/device_manager.py +262 -0
  27. goosebit/plugins/__init__.py +32 -0
  28. goosebit/schema/devices.py +8 -5
  29. goosebit/schema/plugins.py +67 -0
  30. goosebit/schema/updates.py +15 -0
  31. goosebit/schema/users.py +9 -0
  32. goosebit/settings/__init__.py +0 -3
  33. goosebit/settings/schema.py +60 -14
  34. goosebit/storage/__init__.py +62 -0
  35. goosebit/storage/base.py +14 -0
  36. goosebit/storage/filesystem.py +111 -0
  37. goosebit/storage/s3.py +104 -0
  38. goosebit/ui/bff/common/columns.py +50 -0
  39. goosebit/ui/bff/common/responses.py +1 -0
  40. goosebit/ui/bff/devices/device/__init__.py +1 -0
  41. goosebit/ui/bff/devices/device/routes.py +17 -0
  42. goosebit/ui/bff/devices/requests.py +1 -0
  43. goosebit/ui/bff/devices/routes.py +49 -46
  44. goosebit/ui/bff/download/routes.py +14 -3
  45. goosebit/ui/bff/rollouts/routes.py +32 -4
  46. goosebit/ui/bff/routes.py +2 -1
  47. goosebit/ui/bff/settings/__init__.py +1 -0
  48. goosebit/ui/bff/settings/routes.py +20 -0
  49. goosebit/ui/bff/settings/users/__init__.py +1 -0
  50. goosebit/ui/bff/settings/users/responses.py +33 -0
  51. goosebit/ui/bff/settings/users/routes.py +80 -0
  52. goosebit/ui/bff/software/routes.py +40 -12
  53. goosebit/ui/nav.py +12 -2
  54. goosebit/ui/routes.py +66 -13
  55. goosebit/ui/static/js/devices.js +32 -24
  56. goosebit/ui/static/js/login.js +21 -5
  57. goosebit/ui/static/js/logs.js +7 -22
  58. goosebit/ui/static/js/rollouts.js +31 -30
  59. goosebit/ui/static/js/settings.js +322 -0
  60. goosebit/ui/static/js/setup.js +28 -0
  61. goosebit/ui/static/js/software.js +127 -121
  62. goosebit/ui/static/js/util.js +25 -4
  63. goosebit/ui/templates/__init__.py +10 -1
  64. goosebit/ui/templates/login.html.jinja +5 -0
  65. goosebit/ui/templates/nav.html.jinja +13 -5
  66. goosebit/ui/templates/rollouts.html.jinja +4 -22
  67. goosebit/ui/templates/settings.html.jinja +88 -0
  68. goosebit/ui/templates/setup.html.jinja +71 -0
  69. goosebit/ui/templates/software.html.jinja +0 -11
  70. goosebit/updater/controller/v1/routes.py +119 -77
  71. goosebit/updater/routes.py +83 -8
  72. goosebit/updates/__init__.py +24 -31
  73. goosebit/updates/swdesc.py +15 -8
  74. goosebit/users/__init__.py +63 -0
  75. goosebit/util/__init__.py +0 -0
  76. goosebit/util/path.py +42 -0
  77. goosebit/util/version.py +92 -0
  78. goosebit-0.2.6.dist-info/METADATA +280 -0
  79. goosebit-0.2.6.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  81. goosebit/realtime/logs.py +0 -42
  82. goosebit/realtime/routes.py +0 -13
  83. goosebit/updater/manager.py +0 -325
  84. goosebit-0.2.5.dist-info/METADATA +0 -189
  85. goosebit-0.2.5.dist-info/RECORD +0 -99
  86. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  87. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
  88. {goosebit-0.2.5.dist-info → goosebit-0.2.6.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
@@ -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.updater.manager import DeviceUpdateManager
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
- uuid: str
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 online(self) -> bool | None:
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
- return DeviceUpdateManager(self.uuid).poll_seconds
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]
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class UserSchema(BaseModel):
5
+ model_config = ConfigDict(from_attributes=True)
6
+
7
+ username: str
8
+ enabled: bool
9
+ permissions: list[str]
@@ -12,6 +12,3 @@ logger = logging.getLogger(__name__)
12
12
 
13
13
  if config.config_file is not None:
14
14
  logger.info(f"Loading settings from: {config.config_file}")
15
-
16
-
17
- USERS = {u.username: u for u in config.users}
@@ -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.rfc7518.oct_key import OctKey
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, PWD_CXT
16
+ from .const import CURRENT_DIR, GOOSEBIT_ROOT_DIR, LOGGING_DEFAULT
16
17
 
17
18
 
18
- class User(BaseModel):
19
- username: str
20
- hashed_pwd: Annotated[str, BeforeValidator(PWD_CXT.hash)] = Field(validation_alias="password")
21
- permissions: set[str]
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
- def get_json_permissions(self):
24
- return [str(p) for p in self.permissions]
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
- poll_time_default: str = "00:01:00"
41
- poll_time_updating: str = "00:00:05"
42
- poll_time_registration: str = "00:00:10"
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
- users: list[User] = []
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)
@@ -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: ...