goosebit 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. goosebit/__init__.py +41 -7
  2. goosebit/api/telemetry/metrics.py +1 -5
  3. goosebit/api/v1/devices/device/responses.py +1 -0
  4. goosebit/api/v1/devices/device/routes.py +8 -8
  5. goosebit/api/v1/devices/requests.py +20 -0
  6. goosebit/api/v1/devices/routes.py +68 -8
  7. goosebit/api/v1/download/routes.py +14 -3
  8. goosebit/api/v1/rollouts/routes.py +5 -4
  9. goosebit/api/v1/routes.py +2 -1
  10. goosebit/api/v1/settings/routes.py +14 -0
  11. goosebit/api/v1/settings/users/__init__.py +1 -0
  12. goosebit/api/v1/settings/users/requests.py +16 -0
  13. goosebit/api/v1/settings/users/responses.py +7 -0
  14. goosebit/api/v1/settings/users/routes.py +56 -0
  15. goosebit/api/v1/software/routes.py +18 -14
  16. goosebit/auth/__init__.py +49 -13
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  20. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  21. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  22. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  23. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  24. goosebit/db/models.py +19 -8
  25. goosebit/db/pg_ssl_context.py +51 -0
  26. goosebit/device_manager.py +262 -0
  27. goosebit/plugins/__init__.py +32 -0
  28. goosebit/schema/devices.py +8 -5
  29. goosebit/schema/plugins.py +67 -0
  30. goosebit/schema/updates.py +15 -0
  31. goosebit/schema/users.py +9 -0
  32. goosebit/settings/__init__.py +0 -3
  33. goosebit/settings/schema.py +60 -14
  34. goosebit/storage/__init__.py +62 -0
  35. goosebit/storage/base.py +14 -0
  36. goosebit/storage/filesystem.py +111 -0
  37. goosebit/storage/s3.py +104 -0
  38. goosebit/ui/bff/common/columns.py +50 -0
  39. goosebit/ui/bff/common/responses.py +1 -0
  40. goosebit/ui/bff/devices/device/__init__.py +1 -0
  41. goosebit/ui/bff/devices/device/routes.py +17 -0
  42. goosebit/ui/bff/devices/requests.py +1 -0
  43. goosebit/ui/bff/devices/routes.py +49 -46
  44. goosebit/ui/bff/download/routes.py +14 -3
  45. goosebit/ui/bff/rollouts/routes.py +32 -4
  46. goosebit/ui/bff/routes.py +2 -1
  47. goosebit/ui/bff/settings/__init__.py +1 -0
  48. goosebit/ui/bff/settings/routes.py +20 -0
  49. goosebit/ui/bff/settings/users/__init__.py +1 -0
  50. goosebit/ui/bff/settings/users/responses.py +33 -0
  51. goosebit/ui/bff/settings/users/routes.py +80 -0
  52. goosebit/ui/bff/software/routes.py +40 -12
  53. goosebit/ui/nav.py +12 -2
  54. goosebit/ui/routes.py +66 -13
  55. goosebit/ui/static/js/devices.js +32 -24
  56. goosebit/ui/static/js/login.js +21 -5
  57. goosebit/ui/static/js/logs.js +7 -22
  58. goosebit/ui/static/js/rollouts.js +31 -30
  59. goosebit/ui/static/js/settings.js +322 -0
  60. goosebit/ui/static/js/setup.js +28 -0
  61. goosebit/ui/static/js/software.js +127 -121
  62. goosebit/ui/static/js/util.js +25 -4
  63. goosebit/ui/templates/__init__.py +10 -1
  64. goosebit/ui/templates/login.html.jinja +5 -0
  65. goosebit/ui/templates/nav.html.jinja +13 -5
  66. goosebit/ui/templates/rollouts.html.jinja +4 -22
  67. goosebit/ui/templates/settings.html.jinja +88 -0
  68. goosebit/ui/templates/setup.html.jinja +71 -0
  69. goosebit/ui/templates/software.html.jinja +0 -11
  70. goosebit/updater/controller/v1/routes.py +119 -77
  71. goosebit/updater/routes.py +83 -8
  72. goosebit/updates/__init__.py +24 -31
  73. goosebit/updates/swdesc.py +15 -8
  74. goosebit/users/__init__.py +63 -0
  75. goosebit/util/__init__.py +0 -0
  76. goosebit/util/path.py +42 -0
  77. goosebit/util/version.py +92 -0
  78. goosebit-0.2.7.dist-info/METADATA +280 -0
  79. goosebit-0.2.7.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/WHEEL +1 -1
  81. goosebit/realtime/logs.py +0 -42
  82. goosebit/realtime/routes.py +0 -13
  83. goosebit/updater/manager.py +0 -325
  84. goosebit-0.2.5.dist-info/METADATA +0 -189
  85. goosebit-0.2.5.dist-info/RECORD +0 -99
  86. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  87. {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/LICENSE +0 -0
  88. {goosebit-0.2.5.dist-info → goosebit-0.2.7.dist-info}/entry_points.txt +0 -0
@@ -31,11 +31,15 @@
31
31
  alt="gooseBit Logo" />
32
32
  </div>
33
33
  </div>
34
+ <div id="login_error" class="alert alert-danger d-none" role="alert">
35
+ <!-- Error message will be inserted here -->
36
+ </div>
34
37
  <form method="post" id="login_form">
35
38
  <div class="form-outline form-white mb-4">
36
39
  <input type="email"
37
40
  id="username"
38
41
  name="username"
42
+ autocomplete="username"
39
43
  placeholder="Email"
40
44
  class="form-control form-control-lg" />
41
45
  </div>
@@ -43,6 +47,7 @@
43
47
  <input type="password"
44
48
  id="password"
45
49
  name="password"
50
+ autocomplete="current-password"
46
51
  placeholder="Password"
47
52
  class="form-control form-control-lg" />
48
53
  </div>
@@ -1,7 +1,7 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <script>const PERMISSIONS = {{request.user.get_json_permissions() | safe}};</script>
4
+ <script>const PERMISSIONS = {{request.user.permissions | safe}};</script>
5
5
  <meta charset="utf-8" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>{{ title }}</title>
@@ -84,6 +84,7 @@
84
84
  width="30px"
85
85
  alt="gooseBit logo" />
86
86
  gooseBit
87
+ <small class="text-muted">v{{ request.app.version }}</small>
87
88
  </a>
88
89
  <button class="navbar-toggler"
89
90
  type="button"
@@ -97,14 +98,21 @@
97
98
  <div class="collapse navbar-collapse" id="navbar">
98
99
  <div class="navbar-nav">
99
100
  {% for nav_item in request.nav %}
100
- {% if compare_permissions(nav_item.permissions, request.user.permissions) %}
101
- <a class="nav-link{% if request.url == request.url_for(nav_item.function) %} active{% endif %}"
102
- href="{{ request.url_for(nav_item.function) }}">{{ nav_item.text }}</a>
101
+ {% if nav_item.show %}
102
+ {% if compare_permissions(nav_item.permissions, request.user.permissions) %}
103
+ <a class="nav-link{% if request.url == request.url_for(nav_item.function) %} active{% endif %}"
104
+ href="{{ request.url_for(nav_item.function) }}">{{ nav_item.text }}</a>
105
+ {% endif %}
103
106
  {% endif %}
104
107
  {% endfor %}
105
108
  </div>
106
109
  <div class="navbar-nav d-flex flex-fill justify-content-end">
107
- <a class="nav-link" href="{{ request.url_for('logout') }}">Logout<i class="bi bi-box-arrow-right ps-2"></i></a>
110
+ {% if compare_permissions("goosebit.settings", request.user.permissions) %}
111
+ <a class="nav-link ps-3 me-2 border-start"
112
+ href="{{ request.url_for('settings_ui') }}">Settings<i class="bi bi-gear ps-2"></i></a>
113
+ {% endif %}
114
+ <a class="nav-link ps-3 border-start"
115
+ href="{{ request.url_for('logout') }}">Logout<i class="bi bi-box-arrow-right ps-2"></i></a>
108
116
  </div>
109
117
  </div>
110
118
  </div>
@@ -4,28 +4,13 @@
4
4
  <div class="row p-2 d-flex justify-content-center">
5
5
  <div class="col">
6
6
  <table id="rollout-table" class="table table-hover">
7
- <thead>
8
- <tr>
9
- <th>Id</th>
10
- <th>Created</th>
11
- <th>Name</th>
12
- <th>Feed</th>
13
- <th>Software File</th>
14
- <th>Software Version</th>
15
- <th>Paused</th>
16
- <th>Success Count</th>
17
- <th>Failure Count</th>
18
- </tr>
19
- </thead>
20
- <tbody id="rollouts-list">
21
- </tbody>
22
7
  </table>
23
8
  </div>
24
9
  </div>
25
10
  </div>
26
11
  {% if compare_permissions(["rollout.write"], request.user.permissions) %}
27
- <div class="modal" id="rollout-create-modal">
28
- <div class="modal-dialog modal-lg">
12
+ <div class="modal modal-lg fade" id="rollout-create-modal">
13
+ <div class="modal-dialog modal-dialog-centered modal-xl">
29
14
  <div class="modal-content">
30
15
  <div class="modal-header">
31
16
  <h5 class="modal-title">Create Rollout</h5>
@@ -46,7 +31,7 @@
46
31
  <input class="form-control" id="rollout-selected-feed" required />
47
32
  <div class="invalid-feedback">Feed missing. Use "default" if working with a single feed.</div>
48
33
  </div>
49
- <div class="form-group mb-3">
34
+ <div class="form-group">
50
35
  <div class="input-group mb-3 has-validation">
51
36
  <span class="input-group-text">Software</span>
52
37
  <select class="form-control"
@@ -59,10 +44,7 @@
59
44
  required></select>
60
45
  <div class="invalid-feedback">Software missing.</div>
61
46
  </div>
62
- </div>
63
- <div class="modal-footer">
64
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
65
- <button type="submit" class="btn btn-outline-light">Save changes</button>
47
+ <button type="submit" class="btn btn-outline-light w-100">Save changes</button>
66
48
  </div>
67
49
  </div>
68
50
  </form>
@@ -0,0 +1,88 @@
1
+ {% extends "nav.html.jinja" %}
2
+ {% block content %}
3
+ <style>
4
+ input[type="checkbox"].ignore-validation:valid,
5
+ input[type="checkbox"].ignore-validation:invalid {
6
+ border-color: var(--bs-border-color);
7
+ }
8
+ </style>
9
+ <div class="container-fluid">
10
+ <ul class="nav nav-underline nav-fill my-2">
11
+ <li class="nav-item" role="presentation">
12
+ <button class="nav-link active"
13
+ id="settings-users-tab"
14
+ data-bs-toggle="tab"
15
+ data-bs-target="#settings-users-panel"
16
+ type="button"
17
+ role="tab">Users</button>
18
+ </li>
19
+ </ul>
20
+ <div class="tab-content mb-2">
21
+ <div class="tab-pane fade show active"
22
+ id="settings-users-panel"
23
+ role="tabpanel">
24
+ <table id="users-table" class="table table-hover">
25
+ </table>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ {% if compare_permissions(["settings.write"], request.user.permissions) %}
30
+ <div class="modal modal-lg fade" id="create-user-modal">
31
+ <div class="modal-dialog modal-dialog-scrollable modal-dialog-centered modal-xl">
32
+ <div class="modal-content">
33
+ <div class="modal-header">
34
+ <h5 class="modal-title">Create User</h5>
35
+ <button type="button"
36
+ class="btn-close"
37
+ data-bs-dismiss="modal"
38
+ aria-label="Close"></button>
39
+ </div>
40
+ <form id="create-user-form" class="needs-validation" novalidate>
41
+ <div class="modal-body">
42
+ <div class="input-group mb-3 has-validation">
43
+ <span class="input-group-text">Username</span>
44
+ <input class="form-control" type="text" id="create-user-username" required />
45
+ <div class="invalid-feedback">Username missing.</div>
46
+ </div>
47
+ <div class="input-group mb-3 has-validation">
48
+ <span class="input-group-text">Password</span>
49
+ <input class="form-control" type="text" id="create-user-password" required />
50
+ <div class="invalid-feedback">Password missing.</div>
51
+ </div>
52
+ <div class="input-group mb-3 has-validation">
53
+ <input type="checkbox"
54
+ id="create-user-permissions-validator"
55
+ required
56
+ style="display:none">
57
+ <div class="col" id="create-user-permissions"></div>
58
+ <div class="invalid-feedback">Please set at least one permission.</div>
59
+ </div>
60
+ <input class="btn btn-outline-light w-100"
61
+ id="create-user-submit"
62
+ type="submit"
63
+ value="Create User" />
64
+ </div>
65
+ </form>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ {% else %}
70
+ <div class="modal modal-lg fade" id="upload-modal">
71
+ <div class="modal-dialog modal-dialog-centered modal-xl">
72
+ <div class="modal-content">
73
+ <div class="modal-header">
74
+ Unavailable
75
+ <button type="button"
76
+ class="btn-close"
77
+ data-bs-dismiss="modal"
78
+ aria-label="Close"></button>
79
+ </div>
80
+ <div class="modal-body">
81
+ <div class="alert alert-warning m-0" role="alert">You do not have permission to add users.</div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ {% endif %}
87
+ <script src="{{ url_for('static', path='js/settings.js') }}"></script>
88
+ {% endblock content %}
@@ -0,0 +1,71 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Login</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
8
+ rel="stylesheet"
9
+ integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
10
+ crossorigin="anonymous" />
11
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
12
+ integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
13
+ crossorigin="anonymous"></script>
14
+ <link rel="stylesheet"
15
+ href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
16
+ <link rel="icon" href="{{ url_for('static', path='favicon.svg') }}" />
17
+ </head>
18
+ <body data-bs-theme="dark">
19
+ <div class="container py-2">
20
+ <div class="row d-flex justify-content-center align-items-center h-100">
21
+ <div class="col-12 col-md-8 col-lg-6 col-xl-5">
22
+ <div class="mb-md-5 mt-md-4 pb-5">
23
+ <div class="row mb-2 p-2">
24
+ <div class="col d-flex align-items-center">
25
+ <h2 class="fw-bold m-0 text-uppercase">gooseBit</h2>
26
+ </div>
27
+ <div class="col d-flex justify-content-end px-3">
28
+ <img src="{{ request.url_for('static', path='svg/goosebit-logo.svg') }}"
29
+ height="35px"
30
+ width="35px"
31
+ alt="gooseBit Logo" />
32
+ </div>
33
+ </div>
34
+ <div class="row mb-2 p-2">
35
+ <div class="col d-flex align-items-center">
36
+ <p class="m-0">Welcome to gooseBit. Please create an admin user.</p>
37
+ </div>
38
+ </div>
39
+ <form method="post" id="setup_form">
40
+ <div class="form-outline form-white mb-4">
41
+ <input type="email"
42
+ id="username"
43
+ name="username"
44
+ placeholder="Email"
45
+ class="form-control form-control-lg" />
46
+ </div>
47
+ <div class="form-outline form-white mb-4">
48
+ <input type="password"
49
+ id="password"
50
+ name="password"
51
+ placeholder="Password"
52
+ autocomplete="new-password"
53
+ class="form-control form-control-lg" />
54
+ </div>
55
+ <div class="form-outline form-white mb-4">
56
+ <input type="password"
57
+ id="password_confirm"
58
+ name="password_confirm"
59
+ placeholder="Confirm Password"
60
+ autocomplete="new-password"
61
+ class="form-control form-control-lg" />
62
+ </div>
63
+ <button class="btn btn-outline-light btn-lg px-5 w-100" type="submit">Setup</button>
64
+ </form>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ <script src="{{ url_for('static', path='js/setup.js') }}"></script>
70
+ </body>
71
+ </html>
@@ -4,17 +4,6 @@
4
4
  <div class="row p-2 pt-4 g-4 d-flex justify-content-center">
5
5
  <div class="col">
6
6
  <table id="software-table" class="table table-hover">
7
- <thead>
8
- <tr>
9
- <th>ID</th>
10
- <th>Name</th>
11
- <th>Version</th>
12
- <th>Compatibility</th>
13
- <th>Size</th>
14
- </tr>
15
- </thead>
16
- <tbody id="software-list">
17
- </tbody>
18
7
  </table>
19
8
  </div>
20
9
  </div>
@@ -2,11 +2,17 @@ import logging
2
2
 
3
3
  from fastapi import APIRouter, Depends, HTTPException
4
4
  from fastapi.requests import Request
5
- from fastapi.responses import FileResponse, Response
5
+ from fastapi.responses import (
6
+ FileResponse,
7
+ RedirectResponse,
8
+ Response,
9
+ StreamingResponse,
10
+ )
6
11
 
7
- from goosebit.db.models import Software, UpdateStateEnum
12
+ from goosebit.db.models import Device, Software, UpdateStateEnum
13
+ from goosebit.device_manager import DeviceManager, HandlingType, get_device
8
14
  from goosebit.settings import config
9
- from goosebit.updater.manager import HandlingType, UpdateManager, get_update_manager
15
+ from goosebit.storage import storage
10
16
  from goosebit.updates import generate_chunk
11
17
 
12
18
  from .schema import (
@@ -22,53 +28,64 @@ router = APIRouter(prefix="/v1")
22
28
 
23
29
 
24
30
  @router.get("/{dev_id}")
25
- async def polling(request: Request, dev_id: str, updater: UpdateManager = Depends(get_update_manager)):
31
+ async def polling(request: Request, device: Device = Depends(get_device)):
26
32
  links: dict[str, dict[str, str]] = {}
27
33
 
28
- device = await updater.get_device()
29
-
30
34
  if device is None:
31
35
  raise HTTPException(404)
32
36
 
37
+ sleep = config.poll_time
38
+
33
39
  if device.last_state == UpdateStateEnum.UNKNOWN:
34
- # device registration
35
- sleep = config.poll_time_registration
40
+ # device registration: force device to poll again in 10s. After registration, an update might be available
41
+ sleep = "00:00:10"
36
42
  links["configData"] = {
37
43
  "href": str(
38
44
  request.url_for(
39
45
  "config_data",
40
- dev_id=dev_id,
46
+ dev_id=device.id,
41
47
  )
42
48
  )
43
49
  }
44
- logger.info(f"Skip: registration required, device={updater.dev_id}")
50
+ logger.info(f"Skip: registration required, device={device.id}")
45
51
 
46
52
  elif device.last_state == UpdateStateEnum.ERROR and not device.force_update:
47
- sleep = config.poll_time_default
48
- logger.info(f"Skip: device in error state, device={updater.dev_id}")
53
+ logger.info(f"Skip: device in error state, device={device.id}")
49
54
 
50
55
  else:
51
56
  # provide update if available. Note: this is also required while in state "running", otherwise swupdate
52
57
  # won't confirm a successful testing (might be a bug/problem in swupdate)
53
- handling_type, software = await updater.get_update()
58
+ handling_type, software = await DeviceManager.get_update(device)
54
59
  if handling_type != HandlingType.SKIP and software is not None:
55
- sleep = config.poll_time_updating
56
- links["deploymentBase"] = {
57
- "href": str(
58
- request.url_for(
59
- "deployment_base",
60
- dev_id=dev_id,
61
- action_id=software.id,
60
+ number_of_running = await Device.filter(last_state=UpdateStateEnum.RUNNING).count()
61
+ if number_of_running < config.max_concurrent_updates or device.last_state == UpdateStateEnum.RUNNING:
62
+ links["deploymentBase"] = {
63
+ "href": str(
64
+ request.url_for(
65
+ "deployment_base",
66
+ dev_id=device.id,
67
+ action_id=software.id,
68
+ )
62
69
  )
63
- )
64
- }
65
- logger.info(f"Forced: update available, device={updater.dev_id}")
70
+ }
71
+ logger.info(f"Forced: update available, device={device.id}")
66
72
  else:
67
- sleep = config.poll_time_default
68
-
69
- # update poll time on manager so that UI can properly display if device is overdue
70
- updater.poll_time = sleep
71
-
73
+ number_of_running = await Device.filter(last_state=UpdateStateEnum.RUNNING).count()
74
+ if number_of_running < config.max_concurrent_updates or device.last_state == UpdateStateEnum.RUNNING:
75
+ plugin_sources = await DeviceManager.get_alt_src_updates(request, device)
76
+ for handling_type, _ in plugin_sources:
77
+ if handling_type == HandlingType.SKIP:
78
+ continue
79
+ links["deploymentBase"] = {
80
+ "href": str(
81
+ request.url_for(
82
+ "deployment_base",
83
+ dev_id=device.id,
84
+ action_id=-1, # custom plugin
85
+ )
86
+ )
87
+ }
88
+ break
72
89
  return {
73
90
  "config": {"polling": {"sleep": sleep}},
74
91
  "_links": links,
@@ -76,9 +93,9 @@ async def polling(request: Request, dev_id: str, updater: UpdateManager = Depend
76
93
 
77
94
 
78
95
  @router.put("/{dev_id}/configData")
79
- async def config_data(_: Request, cfg: ConfigDataSchema, updater: UpdateManager = Depends(get_update_manager)):
80
- await updater.update_config_data(**cfg.data)
81
- logger.info(f"Updating config data, device={updater.dev_id}")
96
+ async def config_data(_: Request, cfg: ConfigDataSchema, device: Device = Depends(get_device)):
97
+ await DeviceManager.update_config_data(device, **cfg.data)
98
+ logger.info(f"Updating config data, device={device.id}")
82
99
  return {"success": True, "message": "Updated swupdate data."}
83
100
 
84
101
 
@@ -86,46 +103,56 @@ async def config_data(_: Request, cfg: ConfigDataSchema, updater: UpdateManager
86
103
  async def deployment_base(
87
104
  request: Request,
88
105
  action_id: int,
89
- updater: UpdateManager = Depends(get_update_manager),
106
+ device: Device = Depends(get_device),
90
107
  ):
91
- handling_type, software = await updater.get_update()
92
-
93
- logger.info(f"Request deployment base, device={updater.dev_id}")
94
-
95
- return {
96
- "id": str(action_id),
97
- "deployment": {
98
- "download": str(handling_type),
99
- "update": str(handling_type),
100
- "chunks": await generate_chunk(request, updater),
101
- },
102
- }
108
+ handling_type, software = await DeviceManager.get_update(device)
109
+
110
+ logger.info(f"Request deployment base, device={device.id}")
111
+ if not handling_type == HandlingType.SKIP:
112
+ return {
113
+ "id": str(action_id),
114
+ "deployment": {
115
+ "download": str(handling_type),
116
+ "update": str(handling_type),
117
+ "chunks": [chunk.model_dump(by_alias=True) for chunk in (await generate_chunk(request, device))],
118
+ },
119
+ }
120
+ else:
121
+ plugin_sources = await DeviceManager.get_alt_src_updates(request, device)
122
+ for handling_type, chunk in plugin_sources:
123
+ if handling_type == HandlingType.SKIP or chunk is None:
124
+ continue
125
+ return {
126
+ "id": str(action_id),
127
+ "deployment": {
128
+ "download": str(handling_type),
129
+ "update": str(handling_type),
130
+ "chunks": [chunk.model_dump(by_alias=True)],
131
+ },
132
+ }
103
133
 
104
134
 
105
135
  @router.post("/{dev_id}/deploymentBase/{action_id}/feedback")
106
- async def deployment_feedback(
107
- _: Request, data: FeedbackSchema, action_id: int, updater: UpdateManager = Depends(get_update_manager)
108
- ):
136
+ async def deployment_feedback(_: Request, data: FeedbackSchema, action_id: int, device: Device = Depends(get_device)):
109
137
  if data.status.execution == FeedbackStatusExecutionState.PROCEEDING:
110
- device = await updater.get_device()
111
138
  if device and device.last_state != UpdateStateEnum.RUNNING:
112
- await updater.clear_log()
113
- await updater.update_device_state(UpdateStateEnum.RUNNING)
139
+ await DeviceManager.deployment_action_start(device)
140
+ await DeviceManager.update_device_state(device, UpdateStateEnum.RUNNING)
114
141
 
115
- logger.debug(f"Installation in progress, device={updater.dev_id}")
142
+ logger.debug(f"Installation in progress, device={device.id}")
116
143
 
117
144
  elif data.status.execution == FeedbackStatusExecutionState.CLOSED:
118
- await updater.update_force_update(False)
145
+ await DeviceManager.update_force_update(device, False)
119
146
 
120
147
  reported_software = await Software.get_or_none(id=action_id)
121
148
 
122
149
  # From hawkBit docu: DDI defines also a status NONE which will not be interpreted by the update server
123
150
  # and handled like SUCCESS.
124
151
  if data.status.result.finished in [FeedbackStatusResultFinished.SUCCESS, FeedbackStatusResultFinished.NONE]:
125
- await updater.deployment_action_success()
126
- await updater.update_device_state(UpdateStateEnum.FINISHED)
152
+ await DeviceManager.deployment_action_success(device)
153
+ await DeviceManager.update_device_state(device, UpdateStateEnum.FINISHED)
127
154
 
128
- rollout = await updater.get_rollout()
155
+ rollout = await DeviceManager.get_rollout(device)
129
156
  if rollout:
130
157
  if rollout.software == reported_software:
131
158
  rollout.success_count += 1
@@ -133,16 +160,20 @@ async def deployment_feedback(
133
160
  else:
134
161
  # edge case where device update mode got changed while update was running
135
162
  logging.warning(
136
- f"Updating rollout success stats failed, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
163
+ f"Updating rollout success stats failed, action_id={action_id}, device={device.id}"
164
+ # noqa: E501
137
165
  )
138
166
 
139
- await updater.update_sw_version(reported_software.version)
140
- logger.debug(f"Installation successful, software={reported_software.version}, device={updater.dev_id}")
167
+ if reported_software:
168
+ await DeviceManager.update_sw_version(device, reported_software.version)
169
+
170
+ software_version = reported_software.version if reported_software else None
171
+ logger.debug(f"Installation successful, software={software_version}, device={device.id}")
141
172
 
142
173
  elif data.status.result.finished == FeedbackStatusResultFinished.FAILURE:
143
- await updater.update_device_state(UpdateStateEnum.ERROR)
174
+ await DeviceManager.update_device_state(device, UpdateStateEnum.ERROR)
144
175
 
145
- rollout = await updater.get_rollout()
176
+ rollout = await DeviceManager.get_rollout(device)
146
177
  if rollout:
147
178
  if rollout.software == reported_software:
148
179
  rollout.failure_count += 1
@@ -150,28 +181,28 @@ async def deployment_feedback(
150
181
  else:
151
182
  # edge case where device update mode got changed while update was running
152
183
  logging.warning(
153
- f"Updating rollout failure stats failed, software={reported_software.id}, device={updater.dev_id}" # noqa: E501
184
+ f"Updating rollout failure stats failed, action_id={action_id}, device={device.id}"
185
+ # noqa: E501
154
186
  )
155
187
 
156
- logger.debug(f"Installation failed, software={reported_software.version}, device={updater.dev_id}")
188
+ software_version = reported_software.version if reported_software else None
189
+ logger.debug(f"Installation failed, software={software_version}, device={device.id}")
157
190
  else:
158
- logging.error(
159
- f"Device reported unhandled execution state, state={data.status.execution}, device={updater.dev_id}"
160
- )
191
+ logging.error(f"Device reported unhandled execution state, state={data.status.execution}, device={device.id}")
161
192
 
162
193
  try:
163
194
  log = data.status.details
164
195
  if log is not None:
165
- await updater.update_log("\n".join(log))
196
+ await DeviceManager.update_log(device, "\n".join(log))
166
197
  except AttributeError:
167
- logging.warning(f"No details to update device update log, device={updater.dev_id}")
198
+ logging.warning(f"No details to update device update log, device={device.id}")
168
199
 
169
200
  return {"id": str(action_id)}
170
201
 
171
202
 
172
203
  @router.head("/{dev_id}/download")
173
- async def download_artifact_head(_: Request, updater: UpdateManager = Depends(get_update_manager)):
174
- _, software = await updater.get_update()
204
+ async def download_artifact_head(_: Request, device: Device = Depends(get_device)):
205
+ _, software = await DeviceManager.get_update(device)
175
206
  if software is None:
176
207
  raise HTTPException(404)
177
208
 
@@ -181,15 +212,26 @@ async def download_artifact_head(_: Request, updater: UpdateManager = Depends(ge
181
212
 
182
213
 
183
214
  @router.get("/{dev_id}/download")
184
- async def download_artifact(_: Request, updater: UpdateManager = Depends(get_update_manager)):
185
- _, software = await updater.get_update()
215
+ async def download_artifact(_: Request, device: Device = Depends(get_device)):
216
+ _, software = await DeviceManager.get_update(device)
186
217
  if software is None:
187
218
  raise HTTPException(404)
188
219
 
189
- assert software.local, "device requests local software to download"
220
+ if software.local:
221
+ return FileResponse(
222
+ software.path,
223
+ media_type="application/octet-stream",
224
+ filename=software.path.name,
225
+ )
190
226
 
191
- return FileResponse(
192
- software.path,
193
- media_type="application/octet-stream",
194
- filename=software.path.name,
195
- )
227
+ try:
228
+ url = await storage.get_download_url(software.uri)
229
+ return RedirectResponse(url=url)
230
+ except ValueError:
231
+ # Fallback to streaming if redirect fails.
232
+ file_stream = storage.get_file_stream(software.uri)
233
+ return StreamingResponse(
234
+ file_stream,
235
+ media_type="application/octet-stream",
236
+ headers={"Content-Disposition": f"attachment; filename={software.path.name}"},
237
+ )