goosebit 0.1.1__py3-none-any.whl → 0.1.2__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 (52) hide show
  1. goosebit/__init__.py +5 -2
  2. goosebit/api/__init__.py +1 -1
  3. goosebit/api/devices.py +59 -39
  4. goosebit/api/download.py +28 -14
  5. goosebit/api/firmware.py +40 -34
  6. goosebit/api/helper.py +30 -0
  7. goosebit/api/rollouts.py +64 -13
  8. goosebit/api/routes.py +14 -7
  9. goosebit/auth/__init__.py +14 -6
  10. goosebit/db.py +5 -0
  11. goosebit/models.py +110 -10
  12. goosebit/permissions.py +26 -20
  13. goosebit/realtime/__init__.py +1 -1
  14. goosebit/realtime/logs.py +3 -6
  15. goosebit/settings.py +4 -6
  16. goosebit/telemetry/__init__.py +28 -0
  17. goosebit/telemetry/prometheus.py +10 -0
  18. goosebit/ui/__init__.py +1 -1
  19. goosebit/ui/routes.py +33 -40
  20. goosebit/ui/static/js/devices.js +187 -250
  21. goosebit/ui/static/js/firmware.js +229 -92
  22. goosebit/ui/static/js/index.js +79 -90
  23. goosebit/ui/static/js/logs.js +14 -11
  24. goosebit/ui/static/js/rollouts.js +169 -27
  25. goosebit/ui/static/js/util.js +66 -0
  26. goosebit/ui/templates/devices.html +75 -51
  27. goosebit/ui/templates/firmware.html +149 -35
  28. goosebit/ui/templates/index.html +9 -26
  29. goosebit/ui/templates/login.html +58 -27
  30. goosebit/ui/templates/logs.html +15 -5
  31. goosebit/ui/templates/nav.html +77 -26
  32. goosebit/ui/templates/rollouts.html +62 -39
  33. goosebit/updater/__init__.py +1 -1
  34. goosebit/updater/controller/__init__.py +1 -1
  35. goosebit/updater/controller/v1/__init__.py +1 -1
  36. goosebit/updater/controller/v1/routes.py +53 -35
  37. goosebit/updater/manager.py +205 -103
  38. goosebit/updater/routes.py +4 -7
  39. goosebit/updates/__init__.py +70 -0
  40. goosebit/updates/swdesc.py +83 -0
  41. {goosebit-0.1.1.dist-info → goosebit-0.1.2.dist-info}/METADATA +53 -3
  42. goosebit-0.1.2.dist-info/RECORD +51 -0
  43. goosebit/updater/download/__init__.py +0 -1
  44. goosebit/updater/download/routes.py +0 -6
  45. goosebit/updater/download/v1/__init__.py +0 -1
  46. goosebit/updater/download/v1/routes.py +0 -13
  47. goosebit/updater/misc.py +0 -57
  48. goosebit/updates/artifacts.py +0 -89
  49. goosebit/updates/version.py +0 -38
  50. goosebit-0.1.1.dist-info/RECORD +0 -53
  51. {goosebit-0.1.1.dist-info → goosebit-0.1.2.dist-info}/LICENSE +0 -0
  52. {goosebit-0.1.1.dist-info → goosebit-0.1.2.dist-info}/WHEEL +0 -0
goosebit/models.py CHANGED
@@ -1,5 +1,51 @@
1
+ from enum import IntEnum
2
+ from pathlib import Path
3
+ from typing import Self
4
+ from urllib.parse import unquote, urlparse
5
+ from urllib.request import url2pathname
6
+
7
+ import semver
1
8
  from tortoise import Model, fields
2
9
 
10
+ from goosebit.telemetry import devices_count
11
+
12
+
13
+ class UpdateModeEnum(IntEnum):
14
+ NONE = 0
15
+ LATEST = 1
16
+ PINNED = 2
17
+ ROLLOUT = 3
18
+ ASSIGNED = 4
19
+
20
+ def __str__(self):
21
+ return self.name.capitalize()
22
+
23
+ @classmethod
24
+ def from_str(cls, name):
25
+ try:
26
+ return cls[name.upper()]
27
+ except KeyError:
28
+ return cls.NONE
29
+
30
+
31
+ class UpdateStateEnum(IntEnum):
32
+ NONE = 0
33
+ UNKNOWN = 1
34
+ REGISTERED = 2
35
+ RUNNING = 3
36
+ ERROR = 4
37
+ FINISHED = 5
38
+
39
+ def __str__(self):
40
+ return self.name.capitalize()
41
+
42
+ @classmethod
43
+ def from_str(cls, name):
44
+ try:
45
+ return cls[name.upper()]
46
+ except KeyError:
47
+ return cls.NONE
48
+
3
49
 
4
50
  class Tag(Model):
5
51
  id = fields.IntField(primary_key=True)
@@ -9,32 +55,86 @@ class Tag(Model):
9
55
  class Device(Model):
10
56
  uuid = fields.CharField(max_length=255, primary_key=True)
11
57
  name = fields.CharField(max_length=255, null=True)
12
- fw_file = fields.CharField(max_length=255, default="latest")
58
+ assigned_firmware = fields.ForeignKeyField("models.Firmware", related_name="assigned_devices", null=True)
59
+ force_update = fields.BooleanField(default=False)
13
60
  fw_version = fields.CharField(max_length=255, null=True)
14
- hw_model = fields.CharField(max_length=255, null=True, default="default")
15
- hw_revision = fields.CharField(max_length=255, null=True, default="default")
61
+ hardware = fields.ForeignKeyField("models.Hardware", related_name="devices")
16
62
  feed = fields.CharField(max_length=255, default="default")
17
63
  flavor = fields.CharField(max_length=255, default="default")
18
- last_state = fields.CharField(max_length=255, null=True, default="unknown")
64
+ update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
65
+ last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
19
66
  progress = fields.IntField(null=True)
67
+ log_complete = fields.BooleanField(default=False)
20
68
  last_log = fields.TextField(null=True)
21
69
  last_seen = fields.BigIntField(null=True)
22
70
  last_ip = fields.CharField(max_length=15, null=True)
23
71
  last_ipv6 = fields.CharField(max_length=40, null=True)
24
- tags = fields.ManyToManyField(
25
- "models.Tag", related_name="devices", through="device_tags"
26
- )
72
+ tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
73
+
74
+ async def save(self, *args, **kwargs):
75
+ is_new = self._saved_in_db is False
76
+ await super().save(*args, **kwargs)
77
+ if is_new:
78
+ await self.notify_created()
79
+
80
+ async def delete(self, *args, **kwargs):
81
+ await super().delete(*args, **kwargs)
82
+ await self.notify_deleted()
83
+
84
+ @staticmethod
85
+ async def notify_created():
86
+ devices_count.set(await Device.all().count())
87
+
88
+ @staticmethod
89
+ async def notify_deleted():
90
+ devices_count.set(await Device.all().count())
27
91
 
28
92
 
29
93
  class Rollout(Model):
30
94
  id = fields.IntField(primary_key=True)
31
95
  created_at = fields.DatetimeField(auto_now_add=True)
32
96
  name = fields.CharField(max_length=255, null=True)
33
- hw_model = fields.CharField(max_length=255, default="default")
34
- hw_revision = fields.CharField(max_length=255, default="default")
35
97
  feed = fields.CharField(max_length=255, default="default")
36
98
  flavor = fields.CharField(max_length=255, default="default")
37
- fw_file = fields.CharField(max_length=255)
99
+ firmware = fields.ForeignKeyField("models.Firmware", related_name="rollouts")
38
100
  paused = fields.BooleanField(default=False)
39
101
  success_count = fields.IntField(default=0)
40
102
  failure_count = fields.IntField(default=0)
103
+
104
+
105
+ class Hardware(Model):
106
+ id = fields.IntField(primary_key=True)
107
+ model = fields.CharField(max_length=255)
108
+ revision = fields.CharField(max_length=255)
109
+
110
+
111
+ class Firmware(Model):
112
+ id = fields.IntField(primary_key=True)
113
+ uri = fields.CharField(max_length=255)
114
+ size = fields.BigIntField()
115
+ hash = fields.CharField(max_length=255)
116
+ version = fields.CharField(max_length=255)
117
+ compatibility = fields.ManyToManyField(
118
+ "models.Hardware",
119
+ related_name="firmwares",
120
+ through="firmware_compatibility",
121
+ )
122
+
123
+ @classmethod
124
+ async def latest(cls, device: Device) -> Self | None:
125
+ updates = await cls.filter(compatibility__devices__uuid=device.uuid)
126
+ if len(updates) == 0:
127
+ return None
128
+ return sorted(
129
+ updates,
130
+ key=lambda x: semver.Version.parse(x.version),
131
+ reverse=True,
132
+ )[0]
133
+
134
+ @property
135
+ def path(self):
136
+ return Path(url2pathname(unquote(urlparse(self.uri).path)))
137
+
138
+ @property
139
+ def local(self):
140
+ return urlparse(self.uri).scheme == "file"
goosebit/permissions.py CHANGED
@@ -1,10 +1,16 @@
1
1
  from enum import Enum
2
+ from typing import TypeVar, cast
3
+
4
+ T = TypeVar("T", bound="PermissionsBase")
2
5
 
3
6
 
4
7
  class PermissionsBase(str, Enum):
5
8
  @classmethod
6
- def full(cls) -> list:
7
- return [i for i in cls]
9
+ def full(cls) -> set[T]:
10
+ all_items = set[T]()
11
+ for permission in cls:
12
+ all_items.add(cast(T, permission))
13
+ return all_items
8
14
 
9
15
  def __str__(self):
10
16
  return self.value
@@ -17,15 +23,15 @@ class FirmwarePermissions(PermissionsBase):
17
23
 
18
24
 
19
25
  class DevicePermissions(PermissionsBase):
20
- READ = "devices.read"
21
- WRITE = "devices.write"
22
- DELETE = "devices.delete"
26
+ READ = "device.read"
27
+ WRITE = "device.write"
28
+ DELETE = "device.delete"
23
29
 
24
30
 
25
31
  class RolloutPermissions(PermissionsBase):
26
- READ = "rollouts.read"
27
- WRITE = "rollouts.write"
28
- DELETE = "rollouts.delete"
32
+ READ = "rollout.read"
33
+ WRITE = "rollout.write"
34
+ DELETE = "rollout.delete"
29
35
 
30
36
 
31
37
  class HomePermissions(PermissionsBase):
@@ -39,25 +45,25 @@ class Permissions:
39
45
  ROLLOUT = RolloutPermissions
40
46
 
41
47
  @classmethod
42
- def full(cls):
48
+ def full(cls) -> set[T]:
43
49
  all_items = set()
44
50
  for item in [cls.HOME, cls.FIRMWARE, cls.DEVICE, cls.ROLLOUT]:
45
51
  all_items.update(item.full())
46
- return list(all_items)
52
+ return all_items
47
53
 
48
54
  @classmethod
49
- def from_str(cls, permission: str):
55
+ def from_str(cls, permission: str) -> set[T]:
50
56
  if permission == "*":
51
57
  return cls.full()
52
- permission_type = permission.split(".")[0]
53
- if permission_type == "firmware":
54
- return FirmwarePermissions(permission)
55
- if permission_type == "devices":
56
- return DevicePermissions(permission)
57
- if permission_type == "rollouts":
58
- return RolloutPermissions(permission)
59
- if permission_type == "home":
60
- return HomePermissions(permission)
58
+ area, action = permission.upper().split(".")
59
+ if area == "FIRMWARE":
60
+ return {FirmwarePermissions[action]}
61
+ if area == "DEVICE":
62
+ return {DevicePermissions[action]}
63
+ if area == "ROLLOUT":
64
+ return {RolloutPermissions[action]}
65
+ if area == "HOME":
66
+ return {HomePermissions[action]}
61
67
 
62
68
 
63
69
  ADMIN = Permissions.full()
@@ -1 +1 @@
1
- from .routes import router
1
+ from .routes import router # noqa: F401
goosebit/realtime/logs.py CHANGED
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  from fastapi import APIRouter, Security
4
2
  from fastapi.websockets import WebSocket, WebSocketDisconnect
5
3
  from pydantic import BaseModel
@@ -20,17 +18,16 @@ class RealtimeLogModel(BaseModel):
20
18
 
21
19
  @router.websocket(
22
20
  "/{dev_id}",
23
- dependencies=[
24
- Security(validate_ws_user_permissions, scopes=[Permissions.HOME.READ])
25
- ],
21
+ dependencies=[Security(validate_ws_user_permissions, scopes=[Permissions.HOME.READ])],
26
22
  )
27
23
  async def device_logs(websocket: WebSocket, dev_id: str):
28
24
  await websocket.accept()
29
25
 
30
26
  manager = await get_update_manager(dev_id)
27
+ device = await manager.get_device()
31
28
 
32
29
  async def callback(log_update):
33
- data = RealtimeLogModel(log=log_update, progress=manager.device.progress)
30
+ data = RealtimeLogModel(log=log_update, progress=device.progress)
34
31
  if log_update is None:
35
32
  data.clear = True
36
33
  data.log = ""
goosebit/settings.py CHANGED
@@ -4,9 +4,9 @@ from pathlib import Path
4
4
 
5
5
  import yaml
6
6
  from argon2 import PasswordHasher
7
+ from joserfc.rfc7518.oct_key import OctKey
7
8
 
8
9
  from goosebit.permissions import Permissions
9
- from goosebit.updates.version import UpdateVersionParser
10
10
 
11
11
  BASE_DIR = Path(__file__).resolve().parent.parent
12
12
  TOKEN_SWU_DIR = BASE_DIR.joinpath("swugen")
@@ -14,12 +14,14 @@ SWUPDATE_FILES_DIR = BASE_DIR.joinpath("swupdate")
14
14
  UPDATES_DIR = BASE_DIR.joinpath("updates")
15
15
  DB_MIGRATIONS_LOC = BASE_DIR.joinpath("migrations")
16
16
 
17
- SECRET = secrets.token_hex(16)
17
+ SECRET = OctKey.import_key(secrets.token_hex(16))
18
18
  PWD_CXT = PasswordHasher()
19
19
 
20
20
  with open(BASE_DIR.joinpath("settings.yaml"), "r") as f:
21
21
  config = yaml.safe_load(f.read())
22
22
 
23
+ LOGGING = config.get("logging", {})
24
+
23
25
  TENANT = config.get("tenant", "DEFAULT")
24
26
 
25
27
  POLL_TIME = config.get("poll_time_default", "00:01:00")
@@ -29,10 +31,6 @@ POLL_TIME_REGISTRATION = config.get("poll_time_registration", "00:00:10")
29
31
  DB_LOC = BASE_DIR.joinpath(config.get("db_location", "db.sqlite3"))
30
32
  DB_URI = f"sqlite:///{DB_LOC}"
31
33
 
32
- UPDATE_VERSION_PARSER = UpdateVersionParser.create(
33
- parse_mode=config.get("version_format", "datetime"),
34
- )
35
-
36
34
 
37
35
  @dataclass
38
36
  class User:
@@ -0,0 +1,28 @@
1
+ from opentelemetry import metrics
2
+ from opentelemetry.sdk.metrics import MeterProvider
3
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
4
+
5
+ from goosebit import settings
6
+
7
+ from . import prometheus
8
+
9
+ resource = Resource(attributes={SERVICE_NAME: "goosebit"})
10
+
11
+ provider = MeterProvider(resource=resource, metric_readers=[prometheus.reader])
12
+ metrics.set_meter_provider(provider)
13
+
14
+ meter = metrics.get_meter("goosebit.meter")
15
+
16
+ devices_count = meter.create_gauge(
17
+ "devices.count",
18
+ description="The number of connected devices",
19
+ )
20
+
21
+ users_count = meter.create_gauge(
22
+ "users.count",
23
+ description="The number of registered users",
24
+ )
25
+
26
+
27
+ async def init():
28
+ users_count.set(len(settings.USERS))
@@ -0,0 +1,10 @@
1
+ from opentelemetry.exporter.prometheus import PrometheusMetricReader
2
+ from prometheus_client import start_http_server
3
+
4
+ from goosebit import settings
5
+
6
+ PROMETHEUS_PORT = settings.config.get("metrics", {}).get("prometheus", {}).get("port", 9090)
7
+
8
+ # separate file to enable it as a feature later.
9
+ reader = PrometheusMetricReader()
10
+ start_http_server(port=PROMETHEUS_PORT, addr="0.0.0.0")
goosebit/ui/__init__.py CHANGED
@@ -1 +1 @@
1
- from .routes import router
1
+ from .routes import router # noqa: F401
goosebit/ui/routes.py CHANGED
@@ -1,20 +1,19 @@
1
1
  import aiofiles
2
- from fastapi import APIRouter, Depends, Form, HTTPException, Security, UploadFile
2
+ from fastapi import APIRouter, Depends, Form, Security, UploadFile
3
3
  from fastapi.requests import Request
4
4
  from fastapi.responses import RedirectResponse
5
5
  from fastapi.security import OAuth2PasswordBearer
6
6
 
7
7
  from goosebit.auth import authenticate_session, validate_user_permissions
8
+ from goosebit.models import Firmware
8
9
  from goosebit.permissions import Permissions
9
10
  from goosebit.settings import UPDATES_DIR
10
11
  from goosebit.ui.templates import templates
11
- from goosebit.updater.misc import validate_filename
12
+ from goosebit.updates import create_firmware_update
12
13
 
13
14
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
14
15
 
15
- router = APIRouter(
16
- prefix="/ui", dependencies=[Depends(authenticate_session)], include_in_schema=False
17
- )
16
+ router = APIRouter(prefix="/ui", dependencies=[Depends(authenticate_session)], include_in_schema=False)
18
17
 
19
18
 
20
19
  @router.get("/")
@@ -24,33 +23,28 @@ async def ui_root(request: Request):
24
23
 
25
24
  @router.get(
26
25
  "/firmware",
27
- dependencies=[
28
- Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
29
- ],
26
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])],
30
27
  )
31
28
  async def firmware_ui(request: Request):
32
- return templates.TemplateResponse(
33
- "firmware.html", context={"request": request, "title": "Firmware"}
34
- )
29
+ return templates.TemplateResponse(request, "firmware.html", context={"title": "Firmware"})
35
30
 
36
31
 
37
32
  @router.post(
38
- "/upload",
39
- dependencies=[
40
- Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.WRITE])
41
- ],
33
+ "/upload/local",
34
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.WRITE])],
42
35
  )
43
- async def upload_update(
36
+ async def upload_update_local(
44
37
  request: Request,
45
38
  chunk: UploadFile = Form(...),
46
39
  init: bool = Form(...),
47
40
  done: bool = Form(...),
48
41
  filename: str = Form(...),
49
42
  ):
50
- if not validate_filename(filename):
51
- raise HTTPException(400, detail="Could not parse file data, invalid filename.")
52
-
53
43
  file = UPDATES_DIR.joinpath(filename)
44
+ firmware = await Firmware.get_or_none(uri=file.absolute().as_uri())
45
+ if firmware is not None:
46
+ await firmware.delete()
47
+
54
48
  tmpfile = file.with_suffix(".tmp")
55
49
  contents = await chunk.read()
56
50
  if init:
@@ -60,6 +54,19 @@ async def upload_update(
60
54
  await f.write(contents)
61
55
  if done:
62
56
  tmpfile.replace(file)
57
+ await create_firmware_update(file.absolute().as_uri())
58
+
59
+
60
+ @router.post(
61
+ "/upload/remote",
62
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.WRITE])],
63
+ )
64
+ async def upload_update_remote(request: Request, url: str = Form(...)):
65
+ firmware = await Firmware.get_or_none(uri=url)
66
+ if firmware is not None:
67
+ await firmware.delete()
68
+
69
+ await create_firmware_update(url)
63
70
 
64
71
 
65
72
  @router.get(
@@ -67,42 +74,28 @@ async def upload_update(
67
74
  dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
68
75
  )
69
76
  async def home_ui(request: Request):
70
- return templates.TemplateResponse(
71
- "index.html", context={"request": request, "title": "Home"}
72
- )
77
+ return templates.TemplateResponse(request, "index.html", context={"title": "Home"})
73
78
 
74
79
 
75
80
  @router.get(
76
81
  "/devices",
77
- dependencies=[
78
- Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])
79
- ],
82
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])],
80
83
  )
81
84
  async def devices_ui(request: Request):
82
- return templates.TemplateResponse(
83
- "devices.html", context={"request": request, "title": "Devices"}
84
- )
85
+ return templates.TemplateResponse(request, "devices.html", context={"title": "Devices"})
85
86
 
86
87
 
87
88
  @router.get(
88
89
  "/rollouts",
89
- dependencies=[
90
- Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])
91
- ],
90
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])],
92
91
  )
93
92
  async def rollouts_ui(request: Request):
94
- return templates.TemplateResponse(
95
- "rollouts.html", context={"request": request, "title": "Rollouts"}
96
- )
93
+ return templates.TemplateResponse(request, "rollouts.html", context={"title": "Rollouts"})
97
94
 
98
95
 
99
96
  @router.get(
100
97
  "/logs/{dev_id}",
101
- dependencies=[
102
- Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])
103
- ],
98
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.READ])],
104
99
  )
105
100
  async def logs_ui(request: Request, dev_id: str):
106
- return templates.TemplateResponse(
107
- "logs.html", context={"request": request, "title": "Log", "device": dev_id}
108
- )
101
+ return templates.TemplateResponse(request, "logs.html", context={"title": "Log", "device": dev_id})