goosebit 0.1.2__py3-none-any.whl → 0.2.1__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 (105) hide show
  1. goosebit/__init__.py +50 -19
  2. goosebit/__main__.py +7 -0
  3. goosebit/api/responses.py +5 -0
  4. goosebit/api/routes.py +5 -15
  5. goosebit/api/telemetry/__init__.py +1 -0
  6. goosebit/{telemetry/__init__.py → api/telemetry/metrics.py} +9 -3
  7. goosebit/api/telemetry/prometheus/__init__.py +2 -0
  8. goosebit/api/telemetry/prometheus/readers.py +3 -0
  9. goosebit/api/telemetry/prometheus/routes.py +18 -0
  10. goosebit/api/telemetry/routes.py +9 -0
  11. goosebit/api/v1/__init__.py +1 -0
  12. goosebit/api/v1/devices/__init__.py +1 -0
  13. goosebit/api/v1/devices/device/__init__.py +1 -0
  14. goosebit/api/v1/devices/device/responses.py +13 -0
  15. goosebit/api/v1/devices/device/routes.py +27 -0
  16. goosebit/api/v1/devices/requests.py +7 -0
  17. goosebit/api/v1/devices/responses.py +16 -0
  18. goosebit/api/v1/devices/routes.py +35 -0
  19. goosebit/api/v1/download/__init__.py +1 -0
  20. goosebit/api/v1/download/routes.py +22 -0
  21. goosebit/api/v1/rollouts/__init__.py +1 -0
  22. goosebit/api/v1/rollouts/requests.py +16 -0
  23. goosebit/api/v1/rollouts/responses.py +19 -0
  24. goosebit/api/v1/rollouts/routes.py +50 -0
  25. goosebit/api/v1/routes.py +9 -0
  26. goosebit/api/v1/software/__init__.py +1 -0
  27. goosebit/api/v1/software/requests.py +5 -0
  28. goosebit/api/v1/software/responses.py +16 -0
  29. goosebit/api/v1/software/routes.py +77 -0
  30. goosebit/auth/__init__.py +101 -101
  31. goosebit/db/__init__.py +11 -0
  32. goosebit/db/config.py +10 -0
  33. goosebit/db/migrations/models/0_20240830054046_init.py +136 -0
  34. goosebit/{models.py → db/models.py} +17 -10
  35. goosebit/realtime/logs.py +4 -3
  36. goosebit/realtime/routes.py +2 -2
  37. goosebit/schema/__init__.py +0 -0
  38. goosebit/schema/devices.py +73 -0
  39. goosebit/schema/rollouts.py +31 -0
  40. goosebit/schema/software.py +37 -0
  41. goosebit/settings/__init__.py +17 -0
  42. goosebit/settings/const.py +21 -0
  43. goosebit/settings/schema.py +86 -0
  44. goosebit/ui/bff/__init__.py +1 -0
  45. goosebit/ui/bff/devices/__init__.py +1 -0
  46. goosebit/ui/bff/devices/requests.py +12 -0
  47. goosebit/ui/bff/devices/responses.py +39 -0
  48. goosebit/ui/bff/devices/routes.py +72 -0
  49. goosebit/ui/bff/download/__init__.py +1 -0
  50. goosebit/ui/bff/download/routes.py +22 -0
  51. goosebit/ui/bff/rollouts/__init__.py +1 -0
  52. goosebit/ui/bff/rollouts/responses.py +37 -0
  53. goosebit/ui/bff/rollouts/routes.py +52 -0
  54. goosebit/ui/bff/routes.py +11 -0
  55. goosebit/ui/bff/software/__init__.py +1 -0
  56. goosebit/ui/bff/software/responses.py +37 -0
  57. goosebit/ui/bff/software/routes.py +83 -0
  58. goosebit/ui/nav.py +16 -0
  59. goosebit/ui/routes.py +29 -66
  60. goosebit/ui/static/favicon.ico +0 -0
  61. goosebit/ui/static/favicon.svg +1 -1
  62. goosebit/ui/static/js/devices.js +47 -71
  63. goosebit/ui/static/js/index.js +4 -9
  64. goosebit/ui/static/js/login.js +23 -0
  65. goosebit/ui/static/js/logs.js +1 -1
  66. goosebit/ui/static/js/rollouts.js +33 -19
  67. goosebit/ui/static/js/{firmware.js → software.js} +87 -86
  68. goosebit/ui/static/js/util.js +60 -6
  69. goosebit/ui/static/svg/goosebit-logo.svg +1 -1
  70. goosebit/ui/templates/__init__.py +9 -1
  71. goosebit/ui/templates/devices.html.jinja +75 -0
  72. goosebit/ui/templates/index.html.jinja +25 -0
  73. goosebit/ui/templates/login.html.jinja +57 -0
  74. goosebit/ui/templates/logs.html.jinja +31 -0
  75. goosebit/ui/templates/nav.html.jinja +84 -0
  76. goosebit/ui/templates/rollouts.html.jinja +93 -0
  77. goosebit/ui/templates/software.html.jinja +139 -0
  78. goosebit/updater/controller/v1/routes.py +101 -96
  79. goosebit/updater/controller/v1/schema.py +56 -0
  80. goosebit/updater/manager.py +65 -65
  81. goosebit/updater/routes.py +3 -11
  82. goosebit/updates/__init__.py +91 -32
  83. goosebit/updates/swdesc.py +2 -7
  84. goosebit-0.2.1.dist-info/METADATA +173 -0
  85. goosebit-0.2.1.dist-info/RECORD +95 -0
  86. goosebit/api/devices.py +0 -136
  87. goosebit/api/download.py +0 -34
  88. goosebit/api/firmware.py +0 -57
  89. goosebit/api/helper.py +0 -30
  90. goosebit/api/rollouts.py +0 -87
  91. goosebit/db.py +0 -37
  92. goosebit/permissions.py +0 -75
  93. goosebit/settings.py +0 -64
  94. goosebit/telemetry/prometheus.py +0 -10
  95. goosebit/ui/templates/devices.html +0 -115
  96. goosebit/ui/templates/firmware.html +0 -163
  97. goosebit/ui/templates/index.html +0 -23
  98. goosebit/ui/templates/login.html +0 -65
  99. goosebit/ui/templates/logs.html +0 -36
  100. goosebit/ui/templates/nav.html +0 -117
  101. goosebit/ui/templates/rollouts.html +0 -76
  102. goosebit-0.1.2.dist-info/METADATA +0 -123
  103. goosebit-0.1.2.dist-info/RECORD +0 -51
  104. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/LICENSE +0 -0
  105. {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/WHEEL +0 -0
goosebit/__init__.py CHANGED
@@ -1,19 +1,18 @@
1
+ import importlib.metadata
1
2
  from contextlib import asynccontextmanager
2
3
  from typing import Annotated
3
4
 
4
5
  from fastapi import Depends, FastAPI
6
+ from fastapi.openapi.docs import get_swagger_ui_html
5
7
  from fastapi.requests import Request
6
8
  from fastapi.responses import RedirectResponse
7
9
  from fastapi.security import OAuth2PasswordRequestForm
8
10
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
9
11
 
10
- from goosebit import api, db, realtime, telemetry, ui, updater
11
- from goosebit.auth import (
12
- authenticate_user,
13
- auto_redirect,
14
- create_session,
15
- get_current_user,
16
- )
12
+ from goosebit import api, db, realtime, ui, updater
13
+ from goosebit.api.telemetry import metrics
14
+ from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
15
+ from goosebit.ui.nav import nav
17
16
  from goosebit.ui.static import static
18
17
  from goosebit.ui.templates import templates
19
18
 
@@ -21,12 +20,30 @@ from goosebit.ui.templates import templates
21
20
  @asynccontextmanager
22
21
  async def lifespan(_: FastAPI):
23
22
  await db.init()
24
- await telemetry.init()
23
+ await metrics.init()
25
24
  yield
26
25
  await db.close()
27
26
 
28
27
 
29
- app = FastAPI(lifespan=lifespan)
28
+ app = FastAPI(
29
+ title="gooseBit",
30
+ summary="A simplistic, opinionated remote update server implementing hawkBit™'s DDI API.",
31
+ version=importlib.metadata.version("goosebit"),
32
+ lifespan=lifespan,
33
+ license_info={
34
+ "name": "Apache 2.0",
35
+ "identifier": "Apache-2.0",
36
+ },
37
+ redoc_url=None,
38
+ docs_url=None,
39
+ openapi_tags=[
40
+ {
41
+ "name": "login",
42
+ "description": "API authentication. "
43
+ "Can be used in the `authorization` header, in the format `{token_type} {access_token}`.",
44
+ }
45
+ ],
46
+ )
30
47
  app.include_router(updater.router)
31
48
  app.include_router(ui.router)
32
49
  app.include_router(api.router)
@@ -37,7 +54,13 @@ Instrumentor.instrument_app(app)
37
54
 
38
55
  @app.middleware("http")
39
56
  async def attach_user(request: Request, call_next):
40
- request.scope["user"] = get_current_user(request)
57
+ request.scope["user"] = await get_user_from_request(request)
58
+ return await call_next(request)
59
+
60
+
61
+ @app.middleware("http")
62
+ async def attach_nav(request: Request, call_next):
63
+ request.scope["nav"] = nav.get()
41
64
  return await call_next(request)
42
65
 
43
66
 
@@ -46,20 +69,28 @@ def root_redirect(request: Request):
46
69
  return RedirectResponse(request.url_for("ui_root"))
47
70
 
48
71
 
49
- @app.get("/login", dependencies=[Depends(auto_redirect)], include_in_schema=False)
50
- async def login_ui(request: Request):
51
- return templates.TemplateResponse(request, "login.html")
72
+ @app.get("/login", include_in_schema=False, dependencies=[Depends(redirect_if_authenticated)])
73
+ async def login_get(request: Request):
74
+ return templates.TemplateResponse(request, "login.html.jinja")
52
75
 
53
76
 
54
- @app.post("/login", include_in_schema=False, dependencies=[Depends(authenticate_user)])
55
- async def login(request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
56
- resp = RedirectResponse(request.url_for("ui_root"), status_code=302)
57
- resp.set_cookie(key="session_id", value=create_session(form_data.username))
58
- return resp
77
+ @app.post("/login", tags=["login"])
78
+ async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
79
+ return {"access_token": login_user(form_data.username, form_data.password), "token_type": "bearer"}
59
80
 
60
81
 
61
82
  @app.get("/logout", include_in_schema=False)
62
83
  async def logout(request: Request):
63
- resp = RedirectResponse(request.url_for("login_ui"), status_code=302)
84
+ resp = RedirectResponse(request.url_for("login_get"), status_code=302)
64
85
  resp.delete_cookie(key="session_id")
65
86
  return resp
87
+
88
+
89
+ @app.get("/docs")
90
+ async def swagger_docs(request: Request):
91
+ return get_swagger_ui_html(
92
+ title="gooseBit docs",
93
+ openapi_url="/openapi.json",
94
+ swagger_favicon_url=str(request.url_for("static", path="/favicon.svg")),
95
+ swagger_ui_parameters={"operationsSorter": "alpha"},
96
+ )
goosebit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ import uvicorn
2
+
3
+ from goosebit import app
4
+ from goosebit.settings import config
5
+
6
+ if __name__ == "__main__":
7
+ uvicorn.run(app, host="0.0.0.0", port=config.port)
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class StatusResponse(BaseModel):
5
+ success: bool
goosebit/api/routes.py CHANGED
@@ -1,19 +1,9 @@
1
1
  from fastapi import APIRouter, Depends
2
2
 
3
- from goosebit.api import devices, download, firmware, rollouts
4
- from goosebit.auth import authenticate_api_session
3
+ from goosebit.auth import validate_current_user
5
4
 
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)
5
+ from . import telemetry, v1
11
6
 
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)
7
+ router = APIRouter(prefix="/api", dependencies=[Depends(validate_current_user)])
8
+ router.include_router(telemetry.router)
9
+ router.include_router(v1.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -2,13 +2,19 @@ from opentelemetry import metrics
2
2
  from opentelemetry.sdk.metrics import MeterProvider
3
3
  from opentelemetry.sdk.resources import SERVICE_NAME, Resource
4
4
 
5
- from goosebit import settings
5
+ from goosebit.settings import USERS, config
6
6
 
7
7
  from . import prometheus
8
8
 
9
+ readers = []
10
+
11
+ if config.metrics.prometheus.enable:
12
+ readers.append(prometheus.reader)
13
+
14
+
9
15
  resource = Resource(attributes={SERVICE_NAME: "goosebit"})
10
16
 
11
- provider = MeterProvider(resource=resource, metric_readers=[prometheus.reader])
17
+ provider = MeterProvider(resource=resource, metric_readers=readers)
12
18
  metrics.set_meter_provider(provider)
13
19
 
14
20
  meter = metrics.get_meter("goosebit.meter")
@@ -25,4 +31,4 @@ users_count = meter.create_gauge(
25
31
 
26
32
 
27
33
  async def init():
28
- users_count.set(len(settings.USERS))
34
+ users_count.set(len(USERS))
@@ -0,0 +1,2 @@
1
+ from .readers import reader # noqa: F401
2
+ from .routes import router # noqa: F401
@@ -0,0 +1,3 @@
1
+ from opentelemetry.exporter.prometheus import PrometheusMetricReader
2
+
3
+ reader = PrometheusMetricReader()
@@ -0,0 +1,18 @@
1
+ from fastapi import APIRouter, Header
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import Response
4
+ from prometheus_client import REGISTRY
5
+ from prometheus_client.exposition import _bake_output
6
+
7
+ router = APIRouter(prefix="/prometheus", tags=["prometheus"])
8
+
9
+
10
+ @router.get("/metrics")
11
+ async def metrics(
12
+ request: Request, accept: list[str] = Header(None), accept_encoding: list[str] = Header(None)
13
+ ) -> Response:
14
+ status, http_headers, output = _bake_output(
15
+ REGISTRY, ",".join(accept), ",".join(accept_encoding), request.query_params, False
16
+ )
17
+ headers = {h[0]: h[1] for h in http_headers}
18
+ return Response(content=output, headers=headers)
@@ -0,0 +1,9 @@
1
+ from fastapi import APIRouter
2
+
3
+ from goosebit.settings import config
4
+
5
+ from . import prometheus
6
+
7
+ router = APIRouter(prefix="/telemetry")
8
+ if config.metrics.prometheus.enable:
9
+ router.include_router(prometheus.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1 @@
1
+ from .routes import router # noqa : F401
@@ -0,0 +1 @@
1
+ from .routes import router # noqa : F401
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.schema.devices import DeviceSchema
6
+
7
+
8
+ class DeviceLogResponse(BaseModel):
9
+ log: str | None
10
+
11
+
12
+ class DeviceResponse(DeviceSchema):
13
+ pass
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, Security
4
+ from fastapi.requests import Request
5
+
6
+ from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse
7
+ from goosebit.auth import validate_user_permissions
8
+ from goosebit.updater.manager import UpdateManager, get_update_manager
9
+
10
+ router = APIRouter(prefix="/{dev_id}")
11
+
12
+
13
+ @router.get(
14
+ "",
15
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
16
+ )
17
+ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
+ return await DeviceResponse.convert(await updater.get_device())
19
+
20
+
21
+ @router.get(
22
+ "/log",
23
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
24
+ )
25
+ async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
26
+ device = await updater.get_device()
27
+ return DeviceLogResponse(log=device.last_log)
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class DevicesDeleteRequest(BaseModel):
7
+ devices: list[str]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from goosebit.db.models import Device
8
+ from goosebit.schema.devices import DeviceSchema
9
+
10
+
11
+ class DevicesResponse(BaseModel):
12
+ devices: list[DeviceSchema]
13
+
14
+ @classmethod
15
+ async def convert(cls, devices: list[Device]):
16
+ return cls(devices=await asyncio.gather(*[DeviceSchema.convert(d) for d in devices]))
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Security
4
+ from fastapi.requests import Request
5
+
6
+ from goosebit.api.responses import StatusResponse
7
+ from goosebit.auth import validate_user_permissions
8
+ from goosebit.db.models import Device
9
+ from goosebit.updater.manager import delete_devices
10
+
11
+ from . import device
12
+ from .requests import DevicesDeleteRequest
13
+ from .responses import DevicesResponse
14
+
15
+ router = APIRouter(prefix="/devices", tags=["devices"])
16
+
17
+
18
+ @router.get(
19
+ "",
20
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
21
+ )
22
+ async def devices_get(_: Request) -> DevicesResponse:
23
+ return await DevicesResponse.convert(await Device.all().prefetch_related("assigned_software", "hardware"))
24
+
25
+
26
+ @router.delete(
27
+ "",
28
+ dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
29
+ )
30
+ async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
31
+ await delete_devices(config.devices)
32
+ return StatusResponse(success=True)
33
+
34
+
35
+ router.include_router(device.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa : F401
@@ -0,0 +1,22 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import FileResponse, RedirectResponse
4
+
5
+ from goosebit.db.models import Software
6
+
7
+ router = APIRouter(prefix="/download", tags=["download"])
8
+
9
+
10
+ @router.get("/{file_id}")
11
+ async def download_file(_: Request, file_id: int):
12
+ software = await Software.get_or_none(id=file_id)
13
+ if software is None:
14
+ raise HTTPException(404)
15
+ if software.local:
16
+ return FileResponse(
17
+ software.path,
18
+ media_type="application/octet-stream",
19
+ filename=software.path.name,
20
+ )
21
+ else:
22
+ return RedirectResponse(url=software.uri)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class RolloutsPutRequest(BaseModel):
5
+ name: str
6
+ feed: str
7
+ software_id: int
8
+
9
+
10
+ class RolloutsPatchRequest(BaseModel):
11
+ ids: list[int]
12
+ paused: bool
13
+
14
+
15
+ class RolloutsDeleteRequest(BaseModel):
16
+ ids: list[int]
@@ -0,0 +1,19 @@
1
+ import asyncio
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.api.responses import StatusResponse
6
+ from goosebit.db.models import Rollout
7
+ from goosebit.schema.rollouts import RolloutSchema
8
+
9
+
10
+ class RolloutsPutResponse(StatusResponse):
11
+ id: int
12
+
13
+
14
+ class RolloutsResponse(BaseModel):
15
+ 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]))
@@ -0,0 +1,50 @@
1
+ from fastapi import APIRouter, Security
2
+ from fastapi.requests import Request
3
+
4
+ from goosebit.api.responses import StatusResponse
5
+ from goosebit.auth import validate_user_permissions
6
+ from goosebit.db.models import Rollout
7
+
8
+ from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
9
+ from .responses import RolloutsPutResponse, RolloutsResponse
10
+
11
+ router = APIRouter(prefix="/rollouts", tags=["rollouts"])
12
+
13
+
14
+ @router.get(
15
+ "",
16
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
+ )
18
+ async def rollouts_get(_: Request) -> RolloutsResponse:
19
+ return await RolloutsResponse.convert(await Rollout.all().prefetch_related("software"))
20
+
21
+
22
+ @router.post(
23
+ "",
24
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
25
+ )
26
+ async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
27
+ rollout = await Rollout.create(
28
+ name=rollout.name,
29
+ feed=rollout.feed,
30
+ software_id=rollout.software_id,
31
+ )
32
+ return RolloutsPutResponse(success=True, id=rollout.id)
33
+
34
+
35
+ @router.patch(
36
+ "",
37
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
38
+ )
39
+ async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusResponse:
40
+ await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused)
41
+ return StatusResponse(success=True)
42
+
43
+
44
+ @router.delete(
45
+ "",
46
+ dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])],
47
+ )
48
+ async def rollouts_delete(_: Request, rollouts: RolloutsDeleteRequest) -> StatusResponse:
49
+ await Rollout.filter(id__in=rollouts.ids).delete()
50
+ return StatusResponse(success=True)
@@ -0,0 +1,9 @@
1
+ from fastapi import APIRouter
2
+
3
+ from . import devices, download, rollouts, software
4
+
5
+ router = APIRouter(prefix="/v1")
6
+ router.include_router(software.router)
7
+ router.include_router(devices.router)
8
+ router.include_router(rollouts.router)
9
+ router.include_router(download.router)
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class SoftwareDeleteRequest(BaseModel):
5
+ software_ids: list[int]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from goosebit.db.models import Software
8
+ from goosebit.schema.software import SoftwareSchema
9
+
10
+
11
+ class SoftwareResponse(BaseModel):
12
+ software: list[SoftwareSchema]
13
+
14
+ @classmethod
15
+ async def convert(cls, software: list[Software]):
16
+ return cls(software=await asyncio.gather(*[SoftwareSchema.convert(f) for f in software]))
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+
3
+ import aiofiles
4
+ from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile
5
+ from fastapi.requests import Request
6
+
7
+ from goosebit.api.responses import StatusResponse
8
+ from goosebit.auth import validate_user_permissions
9
+ from goosebit.db.models import Rollout, Software
10
+ from goosebit.settings import config
11
+ from goosebit.updates import create_software_update
12
+
13
+ from .requests import SoftwareDeleteRequest
14
+ from .responses import SoftwareResponse
15
+
16
+ router = APIRouter(prefix="/software", tags=["software"])
17
+
18
+
19
+ @router.get(
20
+ "",
21
+ dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
22
+ )
23
+ async def software_get(_: Request) -> SoftwareResponse:
24
+ return await SoftwareResponse.convert(await Software.all().prefetch_related("compatibility"))
25
+
26
+
27
+ @router.delete(
28
+ "",
29
+ dependencies=[Security(validate_user_permissions, scopes=["software.delete"])],
30
+ )
31
+ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> StatusResponse:
32
+ success = False
33
+ for software_id in delete_req.software_ids:
34
+ software = await Software.get_or_none(id=software_id)
35
+
36
+ if software is None:
37
+ continue
38
+
39
+ rollout_count = await Rollout.filter(software=software).count()
40
+ if rollout_count > 0:
41
+ raise HTTPException(409, "Software is referenced by rollout")
42
+
43
+ if software.local:
44
+ path = software.path
45
+ if path.exists():
46
+ path.unlink()
47
+
48
+ await software.delete()
49
+ success = True
50
+ return StatusResponse(success=success)
51
+
52
+
53
+ @router.post(
54
+ "",
55
+ dependencies=[Security(validate_user_permissions, scopes=["software.write"])],
56
+ )
57
+ async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)):
58
+ if url is not None:
59
+ # remote file
60
+ software = await Software.get_or_none(uri=url)
61
+ if software is not None:
62
+ rollout_count = await Rollout.filter(software=software).count()
63
+ if rollout_count == 0:
64
+ await software.delete()
65
+ else:
66
+ raise HTTPException(409, "Software with same URL already exists and is referenced by rollout")
67
+
68
+ software = await create_software_update(url, None)
69
+ else:
70
+ # 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))
76
+
77
+ return {"id": software.id}