goosebit 0.2.3__tar.gz → 0.2.5__tar.gz

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 (112) hide show
  1. {goosebit-0.2.3 → goosebit-0.2.5}/PKG-INFO +23 -7
  2. {goosebit-0.2.3 → goosebit-0.2.5}/README.md +18 -2
  3. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/__init__.py +32 -3
  4. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/devices/device/routes.py +10 -4
  5. goosebit-0.2.5/goosebit/api/v1/devices/responses.py +9 -0
  6. goosebit-0.2.5/goosebit/api/v1/devices/routes.py +51 -0
  7. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/rollouts/responses.py +2 -7
  8. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/rollouts/routes.py +7 -3
  9. goosebit-0.2.5/goosebit/api/v1/software/responses.py +9 -0
  10. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/software/routes.py +24 -11
  11. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/auth/__init__.py +12 -8
  12. goosebit-0.2.5/goosebit/db/__init__.py +22 -0
  13. goosebit-0.2.5/goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  14. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/db/models.py +19 -4
  15. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/realtime/logs.py +1 -1
  16. goosebit-0.2.5/goosebit/schema/devices.py +77 -0
  17. goosebit-0.2.5/goosebit/schema/rollouts.py +34 -0
  18. goosebit-0.2.5/goosebit/schema/software.py +42 -0
  19. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/settings/schema.py +2 -0
  20. goosebit-0.2.5/goosebit/ui/bff/common/__init__.py +0 -0
  21. goosebit-0.2.5/goosebit/ui/bff/common/requests.py +44 -0
  22. goosebit-0.2.5/goosebit/ui/bff/common/responses.py +16 -0
  23. goosebit-0.2.5/goosebit/ui/bff/common/util.py +32 -0
  24. goosebit-0.2.5/goosebit/ui/bff/devices/responses.py +35 -0
  25. goosebit-0.2.5/goosebit/ui/bff/devices/routes.py +126 -0
  26. goosebit-0.2.5/goosebit/ui/bff/rollouts/responses.py +33 -0
  27. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/routes.py +8 -6
  28. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/routes.py +4 -2
  29. goosebit-0.2.5/goosebit/ui/bff/software/responses.py +47 -0
  30. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/software/routes.py +29 -16
  31. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/nav.py +1 -1
  32. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/routes.py +10 -19
  33. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/devices.js +188 -94
  34. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/rollouts.js +20 -13
  35. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/software.js +5 -11
  36. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/util.js +43 -14
  37. goosebit-0.2.5/goosebit/ui/templates/devices.html.jinja +103 -0
  38. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/nav.html.jinja +35 -4
  39. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/rollouts.html.jinja +23 -23
  40. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/controller/v1/routes.py +33 -23
  41. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/controller/v1/schema.py +4 -4
  42. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/manager.py +28 -52
  43. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/routes.py +6 -2
  44. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updates/__init__.py +14 -21
  45. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updates/swdesc.py +36 -15
  46. {goosebit-0.2.3 → goosebit-0.2.5}/pyproject.toml +13 -5
  47. goosebit-0.2.3/goosebit/api/v1/devices/responses.py +0 -16
  48. goosebit-0.2.3/goosebit/api/v1/devices/routes.py +0 -35
  49. goosebit-0.2.3/goosebit/api/v1/software/responses.py +0 -16
  50. goosebit-0.2.3/goosebit/db/__init__.py +0 -11
  51. goosebit-0.2.3/goosebit/schema/devices.py +0 -73
  52. goosebit-0.2.3/goosebit/schema/rollouts.py +0 -31
  53. goosebit-0.2.3/goosebit/schema/software.py +0 -37
  54. goosebit-0.2.3/goosebit/ui/bff/devices/responses.py +0 -39
  55. goosebit-0.2.3/goosebit/ui/bff/devices/routes.py +0 -72
  56. goosebit-0.2.3/goosebit/ui/bff/rollouts/responses.py +0 -37
  57. goosebit-0.2.3/goosebit/ui/bff/software/responses.py +0 -37
  58. goosebit-0.2.3/goosebit/ui/static/js/index.js +0 -155
  59. goosebit-0.2.3/goosebit/ui/templates/devices.html.jinja +0 -75
  60. goosebit-0.2.3/goosebit/ui/templates/index.html.jinja +0 -25
  61. {goosebit-0.2.3 → goosebit-0.2.5}/LICENSE +0 -0
  62. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/__main__.py +0 -0
  63. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/__init__.py +0 -0
  64. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/responses.py +0 -0
  65. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/routes.py +0 -0
  66. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/__init__.py +0 -0
  67. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/metrics.py +0 -0
  68. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/__init__.py +0 -0
  69. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/readers.py +0 -0
  70. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/prometheus/routes.py +0 -0
  71. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/telemetry/routes.py +0 -0
  72. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/__init__.py +0 -0
  73. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/devices/__init__.py +0 -0
  74. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/devices/device/__init__.py +0 -0
  75. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/devices/device/responses.py +0 -0
  76. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/devices/requests.py +0 -0
  77. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/download/__init__.py +0 -0
  78. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/download/routes.py +0 -0
  79. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/rollouts/__init__.py +0 -0
  80. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/rollouts/requests.py +0 -0
  81. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/routes.py +0 -0
  82. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/software/__init__.py +0 -0
  83. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/api/v1/software/requests.py +0 -0
  84. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/db/config.py +0 -0
  85. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/db/migrations/models/0_20240830054046_init.py +0 -0
  86. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/realtime/__init__.py +0 -0
  87. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/realtime/routes.py +0 -0
  88. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/schema/__init__.py +0 -0
  89. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/settings/__init__.py +0 -0
  90. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/settings/const.py +0 -0
  91. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/__init__.py +0 -0
  92. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/__init__.py +0 -0
  93. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/devices/__init__.py +0 -0
  94. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/devices/requests.py +0 -0
  95. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/download/__init__.py +0 -0
  96. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/download/routes.py +0 -0
  97. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/rollouts/__init__.py +0 -0
  98. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/bff/software/__init__.py +0 -0
  99. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/__init__.py +0 -0
  100. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/favicon.ico +0 -0
  101. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/favicon.svg +0 -0
  102. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/login.js +0 -0
  103. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/js/logs.js +0 -0
  104. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
  105. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/__init__.py +0 -0
  106. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/login.html.jinja +0 -0
  107. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/logs.html.jinja +0 -0
  108. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/ui/templates/software.html.jinja +0 -0
  109. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/__init__.py +0 -0
  110. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/controller/__init__.py +0 -0
  111. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/controller/routes.py +0 -0
  112. {goosebit-0.2.3 → goosebit-0.2.5}/goosebit/updater/controller/v1/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: goosebit
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary:
5
5
  Author: Upstream Data
6
6
  Author-email: brett@upstreamdata.ca
@@ -8,10 +8,10 @@ Requires-Python: >=3.11,<4.0
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.11
10
10
  Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
11
12
  Provides-Extra: postgresql
12
13
  Requires-Dist: aerich (>=0.7.2,<0.8.0)
13
14
  Requires-Dist: aiocache (>=0.12.2,<0.13.0)
14
- Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
15
15
  Requires-Dist: argon2-cffi (>=23.1.0,<24.0.0)
16
16
  Requires-Dist: asyncpg (>=0.29.0,<0.30.0) ; extra == "postgresql"
17
17
  Requires-Dist: fastapi[uvicorn] (>=0.111.0,<0.112.0)
@@ -20,9 +20,9 @@ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
20
20
  Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
21
21
  Requires-Dist: joserfc (>=1.0.0,<2.0.0)
22
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)
23
+ Requires-Dist: opentelemetry-distro (>=0.49b1,<0.50)
24
+ Requires-Dist: opentelemetry-exporter-prometheus (>=0.49b1,<0.50)
25
+ Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.49b1,<0.50)
26
26
  Requires-Dist: pydantic-settings (>=2.4.0,<3.0.0)
27
27
  Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
28
28
  Requires-Dist: semver (>=3.0.2,<4.0.0)
@@ -34,6 +34,8 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
36
36
 
37
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/UpstreamDataInc/goosebit/badge)](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
38
+
37
39
  ---
38
40
 
39
41
  A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
@@ -43,10 +45,18 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
43
45
  ### Installation
44
46
 
45
47
  1. Install dependencies using [Poetry](https://python-poetry.org/):
48
+
46
49
  ```bash
47
50
  poetry install
48
51
  ```
49
- 2. Launch gooseBit:
52
+
53
+ 2. Create the database:
54
+
55
+ ```bash
56
+ poetry run aerich upgrade
57
+ ```
58
+
59
+ 3. Launch gooseBit:
50
60
  ```bash
51
61
  python main.py
52
62
  ```
@@ -117,6 +127,12 @@ After a model change create the migration
117
127
  poetry run aerich migrate
118
128
  ```
119
129
 
130
+ To seed some sample data (attention: drops all current data) use
131
+
132
+ ```bash
133
+ poetry run generate-sample-data
134
+ ```
135
+
120
136
  ### Code formatting and linting
121
137
 
122
138
  Code is formatted using different tools
@@ -139,7 +155,7 @@ poetry run pre-commit install
139
155
  To manually apply the hooks to all files use:
140
156
 
141
157
  ```bash
142
- pre-commit run --all-files
158
+ poetry run pre-commit run --all-files
143
159
  ```
144
160
 
145
161
  ### Testing
@@ -2,6 +2,8 @@
2
2
 
3
3
  <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
4
4
 
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/UpstreamDataInc/goosebit/badge)](https://scorecard.dev/viewer/?uri=github.com/UpstreamDataInc/goosebit)
6
+
5
7
  ---
6
8
 
7
9
  A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
@@ -11,10 +13,18 @@ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI AP
11
13
  ### Installation
12
14
 
13
15
  1. Install dependencies using [Poetry](https://python-poetry.org/):
16
+
14
17
  ```bash
15
18
  poetry install
16
19
  ```
17
- 2. Launch gooseBit:
20
+
21
+ 2. Create the database:
22
+
23
+ ```bash
24
+ poetry run aerich upgrade
25
+ ```
26
+
27
+ 3. Launch gooseBit:
18
28
  ```bash
19
29
  python main.py
20
30
  ```
@@ -85,6 +95,12 @@ After a model change create the migration
85
95
  poetry run aerich migrate
86
96
  ```
87
97
 
98
+ To seed some sample data (attention: drops all current data) use
99
+
100
+ ```bash
101
+ poetry run generate-sample-data
102
+ ```
103
+
88
104
  ### Code formatting and linting
89
105
 
90
106
  Code is formatted using different tools
@@ -107,7 +123,7 @@ poetry run pre-commit install
107
123
  To manually apply the hooks to all files use:
108
124
 
109
125
  ```bash
110
- pre-commit run --all-files
126
+ poetry run pre-commit run --all-files
111
127
  ```
112
128
 
113
129
  ### Testing
@@ -1,27 +1,37 @@
1
1
  import importlib.metadata
2
2
  from contextlib import asynccontextmanager
3
+ from logging import getLogger
3
4
  from typing import Annotated
4
5
 
5
- from fastapi import Depends, FastAPI
6
+ from fastapi import Depends, FastAPI, HTTPException
7
+ from fastapi.exception_handlers import http_exception_handler
6
8
  from fastapi.openapi.docs import get_swagger_ui_html
7
9
  from fastapi.requests import Request
8
10
  from fastapi.responses import RedirectResponse
9
11
  from fastapi.security import OAuth2PasswordRequestForm
10
12
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
14
+ from tortoise.exceptions import ValidationError
11
15
 
12
16
  from goosebit import api, db, realtime, ui, updater
13
17
  from goosebit.api.telemetry import metrics
14
18
  from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
19
+ from goosebit.settings import config
15
20
  from goosebit.ui.nav import nav
16
21
  from goosebit.ui.static import static
17
22
  from goosebit.ui.templates import templates
18
23
 
24
+ logger = getLogger(__name__)
25
+
19
26
 
20
27
  @asynccontextmanager
21
28
  async def lifespan(_: FastAPI):
22
- await db.init()
29
+ db_ready = await db.init()
30
+ if not db_ready:
31
+ logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
23
32
  await metrics.init()
24
- yield
33
+ if db_ready:
34
+ yield
25
35
  await db.close()
26
36
 
27
37
 
@@ -52,6 +62,19 @@ app.mount("/static", static, name="static")
52
62
  Instrumentor.instrument_app(app)
53
63
 
54
64
 
65
+ # Custom exception handler for Tortoise ValidationError
66
+ @app.exception_handler(ValidationError)
67
+ async def tortoise_validation_exception_handler(request: Request, exc: ValidationError):
68
+ raise HTTPException(422, str(exc))
69
+
70
+
71
+ # Extend default handler to do logging
72
+ @app.exception_handler(StarletteHTTPException)
73
+ async def custom_http_exception_handler(request, exc):
74
+ logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}")
75
+ return await http_exception_handler(request, exc)
76
+
77
+
55
78
  @app.middleware("http")
56
79
  async def attach_user(request: Request, call_next):
57
80
  request.scope["user"] = await get_user_from_request(request)
@@ -64,6 +87,12 @@ async def attach_nav(request: Request, call_next):
64
87
  return await call_next(request)
65
88
 
66
89
 
90
+ @app.middleware("http")
91
+ async def attach_config(request: Request, call_next):
92
+ request.scope["config"] = config
93
+ return await call_next(request)
94
+
95
+
67
96
  @app.get("/", include_in_schema=False)
68
97
  def root_redirect(request: Request):
69
98
  return RedirectResponse(request.url_for("ui_root"))
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from fastapi import APIRouter, Depends, Security
3
+ from fastapi import APIRouter, Depends, HTTPException, Security
4
4
  from fastapi.requests import Request
5
5
 
6
6
  from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse
@@ -12,16 +12,22 @@ router = APIRouter(prefix="/{dev_id}")
12
12
 
13
13
  @router.get(
14
14
  "",
15
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
15
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
16
16
  )
17
17
  async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
- return await DeviceResponse.convert(await updater.get_device())
18
+ device = await updater.get_device()
19
+ if device is None:
20
+ raise HTTPException(404)
21
+ await device.fetch_related("assigned_software", "hardware")
22
+ return DeviceResponse.model_validate(device)
19
23
 
20
24
 
21
25
  @router.get(
22
26
  "/log",
23
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
27
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
24
28
  )
25
29
  async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
26
30
  device = await updater.get_device()
31
+ if device is None:
32
+ raise HTTPException(404)
27
33
  return DeviceLogResponse(log=device.last_log)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.schema.devices import DeviceSchema
6
+
7
+
8
+ class DevicesResponse(BaseModel):
9
+ devices: list[DeviceSchema]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, Security
6
+ from fastapi.requests import Request
7
+
8
+ from goosebit.api.responses import StatusResponse
9
+ from goosebit.auth import validate_user_permissions
10
+ from goosebit.db.models import Device
11
+ from goosebit.schema.devices import DeviceSchema
12
+ from goosebit.schema.software import SoftwareSchema
13
+ from goosebit.updater.manager import delete_devices, get_update_manager
14
+
15
+ from . import device
16
+ from .requests import DevicesDeleteRequest
17
+ from .responses import DevicesResponse
18
+
19
+ router = APIRouter(prefix="/devices", tags=["devices"])
20
+
21
+
22
+ @router.get(
23
+ "",
24
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
25
+ )
26
+ async def devices_get(_: Request) -> DevicesResponse:
27
+ devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
28
+ response = DevicesResponse(devices=devices)
29
+
30
+ async def set_assigned_sw(d: DeviceSchema):
31
+ updater = await get_update_manager(d.uuid)
32
+ _, target = await updater.get_update()
33
+ if target is not None:
34
+ await target.fetch_related("compatibility")
35
+ d.assigned_software = SoftwareSchema.model_validate(target)
36
+ return d
37
+
38
+ response.devices = await asyncio.gather(*[set_assigned_sw(d) for d in response.devices])
39
+ return response
40
+
41
+
42
+ @router.delete(
43
+ "",
44
+ dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
45
+ )
46
+ async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
47
+ await delete_devices(config.devices)
48
+ return StatusResponse(success=True)
49
+
50
+
51
+ router.include_router(device.router)
@@ -1,19 +1,14 @@
1
- import asyncio
1
+ from __future__ import annotations
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
5
  from goosebit.api.responses import StatusResponse
6
- from goosebit.db.models import Rollout
7
6
  from goosebit.schema.rollouts import RolloutSchema
8
7
 
9
8
 
10
9
  class RolloutsPutResponse(StatusResponse):
11
- id: int
10
+ id: int | None = None
12
11
 
13
12
 
14
13
  class RolloutsResponse(BaseModel):
15
14
  rollouts: list[RolloutSchema]
16
-
17
- @classmethod
18
- async def convert(cls, devices: list[Rollout]):
19
- return cls(rollouts=await asyncio.gather(*[RolloutSchema.convert(d) for d in devices]))
@@ -1,9 +1,9 @@
1
- from fastapi import APIRouter, Security
1
+ from fastapi import APIRouter, HTTPException, Security
2
2
  from fastapi.requests import Request
3
3
 
4
4
  from goosebit.api.responses import StatusResponse
5
5
  from goosebit.auth import validate_user_permissions
6
- from goosebit.db.models import Rollout
6
+ from goosebit.db.models import Rollout, Software
7
7
 
8
8
  from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
9
9
  from .responses import RolloutsPutResponse, RolloutsResponse
@@ -16,7 +16,8 @@ router = APIRouter(prefix="/rollouts", tags=["rollouts"])
16
16
  dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
17
  )
18
18
  async def rollouts_get(_: Request) -> RolloutsResponse:
19
- return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software"))
19
+ rollouts = await Rollout.all().prefetch_related("software", "software__compatibility")
20
+ return RolloutsResponse(rollouts=rollouts)
20
21
 
21
22
 
22
23
  @router.post(
@@ -24,6 +25,9 @@ async def rollouts_get(_: Request) -> RolloutsResponse:
24
25
  dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
25
26
  )
26
27
  async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
28
+ software = await Software.filter(id=rollout.software_id)
29
+ if len(software) == 0:
30
+ raise HTTPException(404, f"No software with ID {rollout.software_id} found")
27
31
  rollout = await Rollout.create(
28
32
  name=rollout.name,
29
33
  feed=rollout.feed,
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.schema.software import SoftwareSchema
6
+
7
+
8
+ class SoftwareResponse(BaseModel):
9
+ software: list[SoftwareSchema]
@@ -1,6 +1,9 @@
1
- from pathlib import Path
1
+ from __future__ import annotations
2
2
 
3
- import aiofiles
3
+ import random
4
+ import string
5
+
6
+ from anyio import Path, open_file
4
7
  from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
5
8
  from fastapi.requests import Request
6
9
 
@@ -21,7 +24,8 @@ router = APIRouter(prefix="/software", tags=["software"])
21
24
  dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
25
  )
23
26
  async def software_get(_: Request) -> SoftwareResponse:
24
- return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility"))
27
+ software = await Software.all().prefetch_related("compatibility")
28
+ return SoftwareResponse(software=software)
25
29
 
26
30
 
27
31
  @router.delete(
@@ -42,8 +46,8 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat
42
46
 
43
47
  if software.local:
44
48
  path = software.path
45
- if path.exists():
46
- path.unlink()
49
+ if await path.exists():
50
+ await path.unlink()
47
51
 
48
52
  await software.delete()
49
53
  success = True
@@ -66,12 +70,21 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str
66
70
  raise HTTPException(409, "Software with same URL already exists and is referenced by rollout")
67
71
 
68
72
  software = await create_software_update(url, None)
69
- else:
73
+ elif file is not None:
70
74
  # local file
71
- file_path = config.artifacts_dir.joinpath(file.filename)
72
-
73
- async with aiofiles.tempfile.NamedTemporaryFile("w+b") as f:
74
- await f.write(await file.read())
75
- software = await create_software_update(file_path.absolute().as_uri(), Path(f.name))
75
+ artifacts_dir = Path(config.artifacts_dir)
76
+ file_path = artifacts_dir.joinpath(file.filename)
77
+ tmp_file_path = artifacts_dir.joinpath("tmp", ("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp"))
78
+ await tmp_file_path.parent.mkdir(parents=True, exist_ok=True)
79
+ file_absolute_path = await file_path.absolute()
80
+ tmp_file_absolute_path = await tmp_file_path.absolute()
81
+ try:
82
+ async with await open_file(tmp_file_path, "w+b") as f:
83
+ await f.write(await file.read())
84
+ software = await create_software_update(file_absolute_path.as_uri(), tmp_file_absolute_path)
85
+ finally:
86
+ await tmp_file_path.unlink(missing_ok=True)
87
+ else:
88
+ raise HTTPException(422)
76
89
 
77
90
  return {"id": software.id}
@@ -16,7 +16,11 @@ from goosebit.settings.schema import User
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
- oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
19
+ oauth2_bearer = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
20
+
21
+
22
+ async def oauth2_auth(connection: HTTPConnection):
23
+ return await oauth2_bearer(connection)
20
24
 
21
25
 
22
26
  async def session_auth(connection: HTTPConnection) -> str:
@@ -27,15 +31,15 @@ def create_token(username: str) -> str:
27
31
  return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
28
32
 
29
33
 
30
- def get_user_from_token(token: str) -> User | None:
34
+ def get_user_from_token(token: str | None) -> User | None:
31
35
  if token is None:
32
- return
36
+ return None
33
37
  try:
34
38
  token_data = jwt.decode(token, config.secret_key)
35
39
  username = token_data.claims["username"]
36
40
  return USERS.get(username)
37
41
  except (BadSignatureError, LookupError, ValueError):
38
- pass
42
+ return None
39
43
 
40
44
 
41
45
  def login_user(username: str, password: str) -> str:
@@ -58,9 +62,9 @@ def login_user(username: str, password: str) -> str:
58
62
 
59
63
 
60
64
  def get_current_user(
61
- session_token: Annotated[str, Depends(session_auth)] = None,
62
- oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
63
- ) -> User:
65
+ session_token: Annotated[str | None, Depends(session_auth)] = None,
66
+ oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
67
+ ) -> User | None:
64
68
  session_user = get_user_from_token(session_token)
65
69
  oauth2_user = get_user_from_token(oauth2_token)
66
70
  user = session_user or oauth2_user
@@ -68,7 +72,7 @@ def get_current_user(
68
72
 
69
73
 
70
74
  # using | Request because oauth2_auth.__call__ expects is
71
- async def get_user_from_request(connection: HTTPConnection | Request) -> User:
75
+ async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
72
76
  token = await session_auth(connection) or await oauth2_auth(connection)
73
77
  return get_user_from_token(token)
74
78
 
@@ -0,0 +1,22 @@
1
+ from logging import getLogger
2
+
3
+ from tortoise import Tortoise
4
+ from tortoise.exceptions import OperationalError
5
+
6
+ from goosebit.db.config import TORTOISE_CONF
7
+ from goosebit.db.models import Device
8
+
9
+ logger = getLogger(__name__)
10
+
11
+
12
+ async def init() -> bool:
13
+ await Tortoise.init(config=TORTOISE_CONF)
14
+ try:
15
+ await Device.first()
16
+ except OperationalError:
17
+ return False
18
+ return True
19
+
20
+
21
+ async def close():
22
+ await Tortoise.close_connections()
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ ALTER TABLE "device" DROP COLUMN "log_complete";"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ ALTER TABLE "device" ADD "log_complete" INT NOT NULL DEFAULT 0;"""
@@ -1,11 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  from enum import IntEnum
2
- from pathlib import Path
3
4
  from typing import Self
4
5
  from urllib.parse import unquote, urlparse
5
6
  from urllib.request import url2pathname
6
7
 
7
8
  import semver
9
+ from anyio import Path
10
+ from semver import Version
8
11
  from tortoise import Model, fields
12
+ from tortoise.exceptions import ValidationError
9
13
 
10
14
  from goosebit.api.telemetry.metrics import devices_count
11
15
 
@@ -65,7 +69,6 @@ class Device(Model):
65
69
  update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
66
70
  last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
67
71
  progress = fields.IntField(null=True)
68
- log_complete = fields.BooleanField(default=False)
69
72
  last_log = fields.TextField(null=True)
70
73
  last_seen = fields.BigIntField(null=True)
71
74
  last_ip = fields.CharField(max_length=15, null=True)
@@ -73,6 +76,14 @@ class Device(Model):
73
76
  tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
74
77
 
75
78
  async def save(self, *args, **kwargs):
79
+ # Check if the software is compatible with the hardware before saving
80
+ if self.assigned_software and self.hardware:
81
+ # Check if the assigned software is compatible with the hardware
82
+ await self.fetch_related("assigned_software", "hardware")
83
+ is_compatible = await self.assigned_software.compatibility.filter(id=self.hardware.id).exists()
84
+ if not is_compatible:
85
+ raise ValidationError("The assigned software is not compatible with the device's hardware.")
86
+
76
87
  is_new = self._saved_in_db is False
77
88
  await super().save(*args, **kwargs)
78
89
  if is_new:
@@ -127,12 +138,12 @@ class Software(Model):
127
138
  return None
128
139
  return sorted(
129
140
  updates,
130
- key=lambda x: semver.Version.parse(x.version),
141
+ key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
131
142
  reverse=True,
132
143
  )[0]
133
144
 
134
145
  @property
135
- def path(self):
146
+ def path(self) -> Path:
136
147
  return Path(url2pathname(unquote(urlparse(self.uri).path)))
137
148
 
138
149
  @property
@@ -145,3 +156,7 @@ class Software(Model):
145
156
  return self.path.name
146
157
  else:
147
158
  return self.uri
159
+
160
+ @property
161
+ def parsed_version(self) -> Version:
162
+ return semver.Version.parse(self.version, optional_minor_and_patch=True)
@@ -19,7 +19,7 @@ class RealtimeLogModel(BaseModel):
19
19
 
20
20
  @router.websocket(
21
21
  "/{dev_id}",
22
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
+ dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
23
23
  )
24
24
  async def device_logs(websocket: WebSocket, dev_id: str):
25
25
  await websocket.accept()
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from enum import Enum, IntEnum, StrEnum
5
+ from typing import Annotated
6
+
7
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, computed_field
8
+
9
+ from goosebit.db.models import UpdateModeEnum, UpdateStateEnum
10
+ from goosebit.schema.software import HardwareSchema, SoftwareSchema
11
+ from goosebit.updater.manager import DeviceUpdateManager
12
+
13
+
14
+ class ConvertableEnum(StrEnum):
15
+ @classmethod
16
+ def convert(cls, value: IntEnum):
17
+ return cls(str(value))
18
+
19
+
20
+ def enum_factory(name: str, base: type[Enum]) -> type[ConvertableEnum]:
21
+ enum_dict = {item.name: str(item) for item in base}
22
+ return ConvertableEnum(name, enum_dict) # type: ignore
23
+
24
+
25
+ UpdateStateSchema = enum_factory("UpdateStateSchema", UpdateStateEnum)
26
+ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
27
+
28
+
29
+ class DeviceSchema(BaseModel):
30
+ model_config = ConfigDict(from_attributes=True)
31
+
32
+ uuid: str
33
+ name: str | None
34
+ sw_version: str | None
35
+
36
+ assigned_software: SoftwareSchema | None = Field(exclude=True)
37
+ hardware: HardwareSchema | None = Field(exclude=True)
38
+
39
+ feed: str
40
+ progress: int | None
41
+ last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)] # type: ignore[valid-type]
42
+ update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)] # type: ignore[valid-type]
43
+ force_update: bool
44
+ last_ip: str | None
45
+ last_seen: Annotated[
46
+ int | None, BeforeValidator(lambda last_seen: round(time.time() - last_seen) if last_seen is not None else None)
47
+ ]
48
+
49
+ @computed_field # type: ignore[misc]
50
+ @property
51
+ def online(self) -> bool | None:
52
+ return self.last_seen < (self.poll_seconds + 10) if self.last_seen is not None else None
53
+
54
+ @computed_field # type: ignore[misc]
55
+ @property
56
+ def sw_target_version(self) -> str | None:
57
+ return self.assigned_software.version if self.assigned_software is not None else None
58
+
59
+ @computed_field # type: ignore[misc]
60
+ @property
61
+ def sw_assigned(self) -> int | None:
62
+ return self.assigned_software.id if self.assigned_software is not None else None
63
+
64
+ @computed_field # type: ignore[misc]
65
+ @property
66
+ def hw_model(self) -> str | None:
67
+ return self.hardware.model if self.hardware is not None else None
68
+
69
+ @computed_field # type: ignore[misc]
70
+ @property
71
+ def hw_revision(self) -> str | None:
72
+ return self.hardware.revision if self.hardware is not None else None
73
+
74
+ @computed_field # type: ignore[misc]
75
+ @property
76
+ def poll_seconds(self) -> int:
77
+ return DeviceUpdateManager(self.uuid).poll_seconds