goosebit 0.1.0__tar.gz → 0.1.2__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 (85) hide show
  1. goosebit-0.1.2/PKG-INFO +123 -0
  2. goosebit-0.1.2/README.md +94 -0
  3. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/__init__.py +8 -5
  4. goosebit-0.1.2/goosebit/api/__init__.py +1 -0
  5. goosebit-0.1.2/goosebit/api/devices.py +136 -0
  6. goosebit-0.1.2/goosebit/api/download.py +34 -0
  7. goosebit-0.1.2/goosebit/api/firmware.py +57 -0
  8. goosebit-0.1.2/goosebit/api/helper.py +30 -0
  9. goosebit-0.1.2/goosebit/api/rollouts.py +87 -0
  10. goosebit-0.1.2/goosebit/api/routes.py +19 -0
  11. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/auth/__init__.py +37 -21
  12. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/db.py +5 -0
  13. goosebit-0.1.2/goosebit/models.py +140 -0
  14. goosebit-0.1.2/goosebit/permissions.py +75 -0
  15. goosebit-0.1.2/goosebit/realtime/__init__.py +1 -0
  16. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/realtime/logs.py +4 -6
  17. goosebit-0.1.2/goosebit/settings.py +64 -0
  18. goosebit-0.1.2/goosebit/telemetry/__init__.py +28 -0
  19. goosebit-0.1.2/goosebit/telemetry/prometheus.py +10 -0
  20. goosebit-0.1.2/goosebit/ui/__init__.py +1 -0
  21. goosebit-0.1.2/goosebit/ui/routes.py +101 -0
  22. goosebit-0.1.2/goosebit/ui/static/js/devices.js +322 -0
  23. goosebit-0.1.2/goosebit/ui/static/js/firmware.js +277 -0
  24. goosebit-0.1.2/goosebit/ui/static/js/index.js +160 -0
  25. goosebit-0.1.2/goosebit/ui/static/js/logs.js +25 -0
  26. goosebit-0.1.2/goosebit/ui/static/js/rollouts.js +198 -0
  27. goosebit-0.1.2/goosebit/ui/static/js/util.js +66 -0
  28. goosebit-0.1.2/goosebit/ui/templates/devices.html +115 -0
  29. goosebit-0.1.2/goosebit/ui/templates/firmware.html +163 -0
  30. goosebit-0.1.2/goosebit/ui/templates/index.html +23 -0
  31. goosebit-0.1.2/goosebit/ui/templates/login.html +65 -0
  32. goosebit-0.1.2/goosebit/ui/templates/logs.html +36 -0
  33. goosebit-0.1.2/goosebit/ui/templates/nav.html +117 -0
  34. goosebit-0.1.2/goosebit/ui/templates/rollouts.html +76 -0
  35. goosebit-0.1.2/goosebit/updater/__init__.py +1 -0
  36. goosebit-0.1.2/goosebit/updater/controller/__init__.py +1 -0
  37. goosebit-0.1.2/goosebit/updater/controller/v1/__init__.py +1 -0
  38. goosebit-0.1.2/goosebit/updater/controller/v1/routes.py +180 -0
  39. goosebit-0.1.2/goosebit/updater/manager.py +349 -0
  40. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/updater/routes.py +7 -8
  41. goosebit-0.1.2/goosebit/updates/__init__.py +70 -0
  42. goosebit-0.1.2/goosebit/updates/swdesc.py +83 -0
  43. {goosebit-0.1.0 → goosebit-0.1.2}/pyproject.toml +26 -7
  44. goosebit-0.1.0/PKG-INFO +0 -37
  45. goosebit-0.1.0/README.md +0 -16
  46. goosebit-0.1.0/goosebit/api/__init__.py +0 -1
  47. goosebit-0.1.0/goosebit/api/devices.py +0 -112
  48. goosebit-0.1.0/goosebit/api/download.py +0 -20
  49. goosebit-0.1.0/goosebit/api/firmware.py +0 -64
  50. goosebit-0.1.0/goosebit/api/routes.py +0 -11
  51. goosebit-0.1.0/goosebit/models.py +0 -21
  52. goosebit-0.1.0/goosebit/permissions.py +0 -55
  53. goosebit-0.1.0/goosebit/realtime/__init__.py +0 -1
  54. goosebit-0.1.0/goosebit/settings.py +0 -55
  55. goosebit-0.1.0/goosebit/ui/__init__.py +0 -1
  56. goosebit-0.1.0/goosebit/ui/routes.py +0 -104
  57. goosebit-0.1.0/goosebit/ui/static/js/devices.js +0 -370
  58. goosebit-0.1.0/goosebit/ui/static/js/firmware.js +0 -131
  59. goosebit-0.1.0/goosebit/ui/static/js/index.js +0 -161
  60. goosebit-0.1.0/goosebit/ui/static/js/logs.js +0 -18
  61. goosebit-0.1.0/goosebit/ui/templates/devices.html +0 -82
  62. goosebit-0.1.0/goosebit/ui/templates/firmware.html +0 -47
  63. goosebit-0.1.0/goosebit/ui/templates/index.html +0 -37
  64. goosebit-0.1.0/goosebit/ui/templates/login.html +0 -34
  65. goosebit-0.1.0/goosebit/ui/templates/logs.html +0 -21
  66. goosebit-0.1.0/goosebit/ui/templates/nav.html +0 -64
  67. goosebit-0.1.0/goosebit/updater/__init__.py +0 -1
  68. goosebit-0.1.0/goosebit/updater/controller/__init__.py +0 -1
  69. goosebit-0.1.0/goosebit/updater/controller/v1/__init__.py +0 -1
  70. goosebit-0.1.0/goosebit/updater/controller/v1/routes.py +0 -92
  71. goosebit-0.1.0/goosebit/updater/download/__init__.py +0 -1
  72. goosebit-0.1.0/goosebit/updater/download/routes.py +0 -6
  73. goosebit-0.1.0/goosebit/updater/download/v1/__init__.py +0 -1
  74. goosebit-0.1.0/goosebit/updater/download/v1/routes.py +0 -26
  75. goosebit-0.1.0/goosebit/updater/manager.py +0 -206
  76. goosebit-0.1.0/goosebit/updater/misc.py +0 -69
  77. goosebit-0.1.0/goosebit/updater/updates.py +0 -93
  78. {goosebit-0.1.0 → goosebit-0.1.2}/LICENSE +0 -0
  79. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/realtime/routes.py +0 -0
  80. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/ui/static/__init__.py +0 -0
  81. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/ui/static/favicon.ico +0 -0
  82. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/ui/static/favicon.svg +0 -0
  83. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/ui/static/svg/goosebit-logo.svg +0 -0
  84. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/ui/templates/__init__.py +0 -0
  85. {goosebit-0.1.0 → goosebit-0.1.2}/goosebit/updater/controller/routes.py +0 -0
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.1
2
+ Name: goosebit
3
+ Version: 0.1.2
4
+ Summary:
5
+ Author: Upstream Data
6
+ Author-email: brett@upstreamdata.ca
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: aerich (>=0.7.2,<0.8.0)
12
+ Requires-Dist: aiocache (>=0.12.2,<0.13.0)
13
+ Requires-Dist: aiofiles (>=24.1.0,<25.0.0)
14
+ Requires-Dist: argon2-cffi (>=23.1.0,<24.0.0)
15
+ Requires-Dist: fastapi[uvicorn] (>=0.111.0,<0.112.0)
16
+ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
17
+ Requires-Dist: jinja2 (>=3.1.4,<4.0.0)
18
+ Requires-Dist: joserfc (>=1.0.0,<2.0.0)
19
+ Requires-Dist: libconf (>=2.0.1,<3.0.0)
20
+ Requires-Dist: opentelemetry-distro (>=0.47b0,<0.48)
21
+ Requires-Dist: opentelemetry-exporter-prometheus (>=0.47b0,<0.48)
22
+ Requires-Dist: opentelemetry-instrumentation-fastapi (>=0.47b0,<0.48)
23
+ Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
24
+ Requires-Dist: semver (>=3.0.2,<4.0.0)
25
+ Requires-Dist: tortoise-orm (>=0.21.4,<0.22.0)
26
+ Requires-Dist: websockets (>=12.0,<13.0)
27
+ Description-Content-Type: text/markdown
28
+
29
+ # gooseBit
30
+
31
+ <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
32
+
33
+ ---
34
+
35
+ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
36
+
37
+ ## Setup
38
+
39
+ To set up, install the dependencies in `pyproject.toml` with `poetry install`. Then you can run gooseBit by running `main.py`.
40
+
41
+ ## Initial Startup
42
+
43
+ The first time you start gooseBit, you should change the default username and password inside `settings.yaml`.
44
+ The default login credentials for testing are `admin@goosebit.local`, `admin`.
45
+
46
+ ## Assumptions
47
+
48
+ - [SWUpdate](https://swupdate.org) used on device side.
49
+
50
+ ## Current Feature Set
51
+
52
+ ### Firmware repository
53
+
54
+ Uploading firmware images through frontend. All files should follow the format `{model}_{revision}_{version}`, where
55
+ `version` is either a semantic version or a datetime version in the format `YYYYMMDD-HHmmSS`.
56
+
57
+ ### Automatic device registration
58
+
59
+ First time a new device connects, its configuration data is requested. `hw_model` and `hw_revision` are captured from
60
+ the configuration data (both fall back to `default` if not provided) which allows to distinguish different device
61
+ types and their revisions.
62
+
63
+ ### Automatically update device to newest firmware
64
+
65
+ Once a device is registered it will get the newest available firmware from the repository based on model and revision.
66
+
67
+ ### Manually update device to specific firmware
68
+
69
+ Frontend allows to assign specific firmware to be rolled out.
70
+
71
+ ### Firmware rollout
72
+
73
+ Rollouts allow a fine-grained assignment of firmwares to devices. The reported device model and revision is combined
74
+ with the manually set feed and flavor values on a device to determine a matching rollout.
75
+
76
+ The feed is meant to model either different environments (like: dev, qa, live) or update channels (like. candidate,
77
+ fast, stable).
78
+
79
+ The flavor can be used for different type of builds (like: debug, prod).
80
+
81
+ ### Pause updates
82
+
83
+ Device can be pinned to its current firmware.
84
+
85
+ ### Realtime update logs
86
+
87
+ While an update is running, the update logs are captured and visualized in the frontend.
88
+
89
+ ## Development
90
+
91
+ ### Code formatting and linting
92
+
93
+ Code is formatted using different tools
94
+
95
+ - black and isort for `*.py`
96
+ - biomejs for `*.js`, `*.json`
97
+ - prettier for `*.html`, `*.md`, `*.yml`, `*.yaml`
98
+
99
+ Code is linted using different tools as well
100
+
101
+ - flake8 for `*.py`
102
+ - biomejs for `*.js`
103
+
104
+ Best to have pre-commit install git hooks that run all those tools before a commit:
105
+
106
+ ```bash
107
+ poetry run pre-commit install
108
+ ```
109
+
110
+ To manually apply the hooks to all files use:
111
+
112
+ ```bash
113
+ pre-commit run --all-files
114
+ ```
115
+
116
+ ### Testing
117
+
118
+ Tests are implemented using pytest. To run all tests
119
+
120
+ ```bash
121
+ poetry run pytest
122
+ ```
123
+
@@ -0,0 +1,94 @@
1
+ # gooseBit
2
+
3
+ <img src="docs/img/goosebit-logo.png" style="width: 100px; height: 100px; display: block;">
4
+
5
+ ---
6
+
7
+ A simplistic, opinionated remote update server implementing hawkBit™'s [DDI API](https://eclipse.dev/hawkbit/apis/ddi_api/).
8
+
9
+ ## Setup
10
+
11
+ To set up, install the dependencies in `pyproject.toml` with `poetry install`. Then you can run gooseBit by running `main.py`.
12
+
13
+ ## Initial Startup
14
+
15
+ The first time you start gooseBit, you should change the default username and password inside `settings.yaml`.
16
+ The default login credentials for testing are `admin@goosebit.local`, `admin`.
17
+
18
+ ## Assumptions
19
+
20
+ - [SWUpdate](https://swupdate.org) used on device side.
21
+
22
+ ## Current Feature Set
23
+
24
+ ### Firmware repository
25
+
26
+ Uploading firmware images through frontend. All files should follow the format `{model}_{revision}_{version}`, where
27
+ `version` is either a semantic version or a datetime version in the format `YYYYMMDD-HHmmSS`.
28
+
29
+ ### Automatic device registration
30
+
31
+ First time a new device connects, its configuration data is requested. `hw_model` and `hw_revision` are captured from
32
+ the configuration data (both fall back to `default` if not provided) which allows to distinguish different device
33
+ types and their revisions.
34
+
35
+ ### Automatically update device to newest firmware
36
+
37
+ Once a device is registered it will get the newest available firmware from the repository based on model and revision.
38
+
39
+ ### Manually update device to specific firmware
40
+
41
+ Frontend allows to assign specific firmware to be rolled out.
42
+
43
+ ### Firmware rollout
44
+
45
+ Rollouts allow a fine-grained assignment of firmwares to devices. The reported device model and revision is combined
46
+ with the manually set feed and flavor values on a device to determine a matching rollout.
47
+
48
+ The feed is meant to model either different environments (like: dev, qa, live) or update channels (like. candidate,
49
+ fast, stable).
50
+
51
+ The flavor can be used for different type of builds (like: debug, prod).
52
+
53
+ ### Pause updates
54
+
55
+ Device can be pinned to its current firmware.
56
+
57
+ ### Realtime update logs
58
+
59
+ While an update is running, the update logs are captured and visualized in the frontend.
60
+
61
+ ## Development
62
+
63
+ ### Code formatting and linting
64
+
65
+ Code is formatted using different tools
66
+
67
+ - black and isort for `*.py`
68
+ - biomejs for `*.js`, `*.json`
69
+ - prettier for `*.html`, `*.md`, `*.yml`, `*.yaml`
70
+
71
+ Code is linted using different tools as well
72
+
73
+ - flake8 for `*.py`
74
+ - biomejs for `*.js`
75
+
76
+ Best to have pre-commit install git hooks that run all those tools before a commit:
77
+
78
+ ```bash
79
+ poetry run pre-commit install
80
+ ```
81
+
82
+ To manually apply the hooks to all files use:
83
+
84
+ ```bash
85
+ pre-commit run --all-files
86
+ ```
87
+
88
+ ### Testing
89
+
90
+ Tests are implemented using pytest. To run all tests
91
+
92
+ ```bash
93
+ poetry run pytest
94
+ ```
@@ -5,8 +5,9 @@ from fastapi import Depends, FastAPI
5
5
  from fastapi.requests import Request
6
6
  from fastapi.responses import RedirectResponse
7
7
  from fastapi.security import OAuth2PasswordRequestForm
8
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
8
9
 
9
- from goosebit import api, db, realtime, ui, updater
10
+ from goosebit import api, db, realtime, telemetry, ui, updater
10
11
  from goosebit.auth import (
11
12
  authenticate_user,
12
13
  auto_redirect,
@@ -20,6 +21,7 @@ from goosebit.ui.templates import templates
20
21
  @asynccontextmanager
21
22
  async def lifespan(_: FastAPI):
22
23
  await db.init()
24
+ await telemetry.init()
23
25
  yield
24
26
  await db.close()
25
27
 
@@ -30,6 +32,7 @@ app.include_router(ui.router)
30
32
  app.include_router(api.router)
31
33
  app.include_router(realtime.router)
32
34
  app.mount("/static", static, name="static")
35
+ Instrumentor.instrument_app(app)
33
36
 
34
37
 
35
38
  @app.middleware("http")
@@ -45,18 +48,18 @@ def root_redirect(request: Request):
45
48
 
46
49
  @app.get("/login", dependencies=[Depends(auto_redirect)], include_in_schema=False)
47
50
  async def login_ui(request: Request):
48
- return templates.TemplateResponse("login.html", context={"request": request})
51
+ return templates.TemplateResponse(request, "login.html")
49
52
 
50
53
 
51
54
  @app.post("/login", include_in_schema=False, dependencies=[Depends(authenticate_user)])
52
- async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
53
- resp = RedirectResponse("/ui/home", status_code=302)
55
+ async def login(request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
56
+ resp = RedirectResponse(request.url_for("ui_root"), status_code=302)
54
57
  resp.set_cookie(key="session_id", value=create_session(form_data.username))
55
58
  return resp
56
59
 
57
60
 
58
61
  @app.get("/logout", include_in_schema=False)
59
62
  async def logout(request: Request):
60
- resp = RedirectResponse("/login", status_code=302)
63
+ resp = RedirectResponse(request.url_for("login_ui"), status_code=302)
61
64
  resp.delete_cookie(key="session_id")
62
65
  return resp
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,136 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ from fastapi import APIRouter, Security
5
+ from fastapi.requests import Request
6
+ from pydantic import BaseModel
7
+ from tortoise.expressions import Q
8
+
9
+ from goosebit.api.helper import filter_data
10
+ from goosebit.auth import validate_user_permissions
11
+ from goosebit.models import Device, Firmware, UpdateModeEnum, UpdateStateEnum
12
+ from goosebit.permissions import Permissions
13
+ from goosebit.updater.manager import delete_devices, get_update_manager
14
+
15
+ router = APIRouter(prefix="/devices")
16
+
17
+
18
+ @router.get(
19
+ "/all",
20
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
21
+ )
22
+ async def devices_get_all(request: Request) -> dict[str, int | list[Any] | Any]:
23
+ query = Device.all().prefetch_related("assigned_firmware", "hardware")
24
+
25
+ def search_filter(search_value):
26
+ return (
27
+ Q(uuid__icontains=search_value)
28
+ | Q(name__icontains=search_value)
29
+ | Q(feed__icontains=search_value)
30
+ | Q(flavor__icontains=search_value)
31
+ | Q(update_mode__icontains=UpdateModeEnum.from_str(search_value))
32
+ | Q(last_state__icontains=UpdateStateEnum.from_str(search_value))
33
+ )
34
+
35
+ async def parse(device: Device) -> dict:
36
+ manager = await get_update_manager(device.uuid)
37
+ _, target_firmware = await manager.get_update()
38
+ last_seen = device.last_seen
39
+ if last_seen is not None:
40
+ last_seen = round(time.time() - device.last_seen)
41
+ return {
42
+ "uuid": device.uuid,
43
+ "name": device.name,
44
+ "fw_installed_version": device.fw_version,
45
+ "fw_target_version": (target_firmware.version if target_firmware is not None else None),
46
+ "fw_assigned": (device.assigned_firmware.id if device.assigned_firmware is not None else None),
47
+ "hw_model": device.hardware.model,
48
+ "hw_revision": device.hardware.revision,
49
+ "feed": device.feed,
50
+ "flavor": device.flavor,
51
+ "progress": device.progress,
52
+ "state": str(device.last_state),
53
+ "update_mode": str(device.update_mode),
54
+ "force_update": device.force_update,
55
+ "last_ip": device.last_ip,
56
+ "last_seen": last_seen,
57
+ "online": (last_seen < manager.poll_seconds if last_seen is not None else None),
58
+ }
59
+
60
+ total_records = await Device.all().count()
61
+ return await filter_data(request, query, search_filter, parse, total_records)
62
+
63
+
64
+ class UpdateDevicesModel(BaseModel):
65
+ devices: list[str]
66
+ firmware: str | None = None
67
+ name: str | None = None
68
+ pinned: bool | None = None
69
+ feed: str | None = None
70
+ flavor: str | None = None
71
+
72
+
73
+ @router.post(
74
+ "/update",
75
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])],
76
+ )
77
+ async def devices_update(_: Request, config: UpdateDevicesModel) -> dict:
78
+ for uuid in config.devices:
79
+ updater = await get_update_manager(uuid)
80
+ if config.firmware is not None:
81
+ if config.firmware == "rollout":
82
+ await updater.update_update(UpdateModeEnum.ROLLOUT, None)
83
+ elif config.firmware == "latest":
84
+ await updater.update_update(UpdateModeEnum.LATEST, None)
85
+ else:
86
+ firmware = await Firmware.get_or_none(id=config.firmware)
87
+ await updater.update_update(UpdateModeEnum.ASSIGNED, firmware)
88
+ if config.pinned is not None:
89
+ await updater.update_update(UpdateModeEnum.PINNED, None)
90
+ if config.name is not None:
91
+ await updater.update_name(config.name)
92
+ if config.feed is not None:
93
+ await updater.update_feed(config.feed)
94
+ if config.flavor is not None:
95
+ await updater.update_flavor(config.flavor)
96
+ return {"success": True}
97
+
98
+
99
+ class ForceUpdateModel(BaseModel):
100
+ devices: list[str]
101
+
102
+
103
+ @router.post(
104
+ "/force_update",
105
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])],
106
+ )
107
+ async def devices_force_update(_: Request, config: ForceUpdateModel) -> dict:
108
+ for uuid in config.devices:
109
+ updater = await get_update_manager(uuid)
110
+ await updater.update_force_update(True)
111
+ return {"success": True}
112
+
113
+
114
+ @router.get(
115
+ "/logs/{dev_id}",
116
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
117
+ )
118
+ async def device_logs(_: Request, dev_id: str) -> str:
119
+ updater = await get_update_manager(dev_id)
120
+ device = await updater.get_device()
121
+ if device.last_log is not None:
122
+ return device.last_log
123
+ return "No logs found."
124
+
125
+
126
+ class DeleteModel(BaseModel):
127
+ devices: list[str]
128
+
129
+
130
+ @router.post(
131
+ "/delete",
132
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.DELETE])],
133
+ )
134
+ async def devices_delete(_: Request, config: DeleteModel) -> dict:
135
+ await delete_devices(config.devices)
136
+ return {"success": True}
@@ -0,0 +1,34 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import FileResponse, RedirectResponse
4
+ from starlette.responses import Response
5
+
6
+ from goosebit.models import Firmware
7
+
8
+ router = APIRouter(prefix="/download")
9
+
10
+
11
+ @router.head("/{file_id}")
12
+ async def download_file_head(_: Request, file_id: int):
13
+ firmware = await Firmware.get_or_none(id=file_id)
14
+ if firmware is None:
15
+ raise HTTPException(404)
16
+
17
+ response = Response()
18
+ response.headers["Content-Length"] = str(firmware.size)
19
+ return response
20
+
21
+
22
+ @router.get("/{file_id}")
23
+ async def download_file(_: Request, file_id: int):
24
+ firmware = await Firmware.get_or_none(id=file_id)
25
+ if firmware is None:
26
+ raise HTTPException(404)
27
+ if firmware.local:
28
+ return FileResponse(
29
+ firmware.path,
30
+ media_type="application/octet-stream",
31
+ filename=firmware.path.name,
32
+ )
33
+ else:
34
+ return RedirectResponse(url=firmware.uri)
@@ -0,0 +1,57 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, Body, Security
4
+ from fastapi.requests import Request
5
+ from tortoise.expressions import Q
6
+
7
+ from goosebit.api.helper import filter_data
8
+ from goosebit.auth import validate_user_permissions
9
+ from goosebit.models import Firmware
10
+ from goosebit.permissions import Permissions
11
+
12
+ router = APIRouter(prefix="/firmware")
13
+
14
+
15
+ @router.get(
16
+ "/all",
17
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])],
18
+ )
19
+ async def firmware_get_all(
20
+ request: Request,
21
+ ) -> dict[str, int | list[dict[str, list[Any] | Any]]]:
22
+ query = Firmware.all()
23
+
24
+ def search_filter(search_value):
25
+ return Q(uri__icontains=search_value) | Q(version__icontains=search_value)
26
+
27
+ async def parse(f):
28
+ return {
29
+ "id": f.id,
30
+ "name": f.path.name,
31
+ "size": f.size,
32
+ "hash": f.hash,
33
+ "version": f.version,
34
+ "compatibility": list(await f.compatibility.all().values()),
35
+ }
36
+
37
+ total_records = await Firmware.all().count()
38
+ return await filter_data(request, query, search_filter, parse, total_records)
39
+
40
+
41
+ @router.post(
42
+ "/delete",
43
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.DELETE])],
44
+ )
45
+ async def firmware_delete(_: Request, files: list[int] = Body()) -> dict:
46
+ success = False
47
+ for f_id in files:
48
+ firmware = await Firmware.get_or_none(id=f_id)
49
+ if firmware is None:
50
+ continue
51
+ if firmware.local:
52
+ path = firmware.path
53
+ if path.exists():
54
+ path.unlink()
55
+ await firmware.delete()
56
+ success = True
57
+ return {"success": success}
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+
3
+
4
+ async def filter_data(request, query, search_filter, parse, total_records):
5
+ params = request.query_params
6
+
7
+ draw = int(params.get("draw", 1))
8
+ start = int(params.get("start", 0))
9
+ length = int(params.get("length", 10))
10
+ search_value = params.get("search[value]", None)
11
+ order_column_index = params.get("order[0][column]", None)
12
+ order_column = params.get(f"columns[{order_column_index}][data]", None)
13
+ order_dir = params.get("order[0][dir]", None)
14
+
15
+ if search_value:
16
+ query = query.filter(search_filter(search_value))
17
+
18
+ if order_column:
19
+ query = query.order_by(f"{"-" if order_dir == "desc" else ""}{order_column}")
20
+
21
+ filtered_records = await query.count()
22
+ rollouts = await query.offset(start).limit(length).all()
23
+ data = list(await asyncio.gather(*[parse(r) for r in rollouts]))
24
+
25
+ return {
26
+ "draw": draw,
27
+ "recordsTotal": total_records,
28
+ "recordsFiltered": filtered_records,
29
+ "data": data,
30
+ }
@@ -0,0 +1,87 @@
1
+ from fastapi import APIRouter, Security
2
+ from fastapi.requests import Request
3
+ from pydantic import BaseModel
4
+ from tortoise.expressions import Q
5
+
6
+ from goosebit.api.helper import filter_data
7
+ from goosebit.auth import validate_user_permissions
8
+ from goosebit.models import Rollout
9
+ from goosebit.permissions import Permissions
10
+
11
+ router = APIRouter(prefix="/rollouts")
12
+
13
+
14
+ @router.get(
15
+ "/all",
16
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])],
17
+ )
18
+ async def rollouts_get_all(request: Request) -> dict[str, int | list[dict]]:
19
+ query = Rollout.all().prefetch_related("firmware")
20
+
21
+ def search_filter(search_value):
22
+ return Q(name__icontains=search_value) | Q(feed__icontains=search_value) | Q(flavor__icontains=search_value)
23
+
24
+ async def parse(rollout: Rollout) -> dict:
25
+ return {
26
+ "id": rollout.id,
27
+ "created_at": int(rollout.created_at.timestamp() * 1000),
28
+ "name": rollout.name,
29
+ "feed": rollout.feed,
30
+ "flavor": rollout.flavor,
31
+ "fw_file": rollout.firmware.path.name,
32
+ "fw_version": rollout.firmware.version,
33
+ "paused": rollout.paused,
34
+ "success_count": rollout.success_count,
35
+ "failure_count": rollout.failure_count,
36
+ }
37
+
38
+ total_records = await Rollout.all().count()
39
+ return await filter_data(request, query, search_filter, parse, total_records)
40
+
41
+
42
+ class CreateRolloutsModel(BaseModel):
43
+ name: str
44
+ feed: str
45
+ flavor: str
46
+ firmware_id: int
47
+
48
+
49
+ @router.post(
50
+ "/",
51
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])],
52
+ )
53
+ async def rollouts_create(_: Request, rollout: CreateRolloutsModel) -> dict:
54
+ rollout = await Rollout.create(
55
+ name=rollout.name,
56
+ feed=rollout.feed,
57
+ flavor=rollout.flavor,
58
+ firmware_id=rollout.firmware_id,
59
+ )
60
+ return {"success": True, "id": rollout.id}
61
+
62
+
63
+ class UpdateRolloutsModel(BaseModel):
64
+ ids: list[int]
65
+ paused: bool
66
+
67
+
68
+ @router.post(
69
+ "/update",
70
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])],
71
+ )
72
+ async def rollouts_update(_: Request, rollouts: UpdateRolloutsModel) -> dict:
73
+ await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused)
74
+ return {"success": True}
75
+
76
+
77
+ class DeleteRolloutsModel(BaseModel):
78
+ ids: list[int]
79
+
80
+
81
+ @router.post(
82
+ "/delete",
83
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.DELETE])],
84
+ )
85
+ async def rollouts_delete(_: Request, rollouts: DeleteRolloutsModel) -> dict:
86
+ await Rollout.filter(id__in=rollouts.ids).delete()
87
+ return {"success": True}
@@ -0,0 +1,19 @@
1
+ from fastapi import APIRouter, Depends
2
+
3
+ from goosebit.api import devices, download, firmware, rollouts
4
+ from goosebit.auth import authenticate_api_session
5
+
6
+ # main router that requires authentication
7
+ main_router = APIRouter(prefix="/api", dependencies=[Depends(authenticate_api_session)], tags=["api"])
8
+ main_router.include_router(firmware.router)
9
+ main_router.include_router(devices.router)
10
+ main_router.include_router(rollouts.router)
11
+
12
+ # download router without authentication
13
+ download_router = APIRouter(prefix="/api", tags=["api"])
14
+ download_router.include_router(download.router)
15
+
16
+ # include both routers
17
+ router = APIRouter()
18
+ router.include_router(main_router)
19
+ router.include_router(download_router)