goosebit 0.1.0__py3-none-any.whl → 0.1.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.
@@ -15,6 +15,12 @@
15
15
  <th>
16
16
  UUID
17
17
  </th>
18
+ <th>
19
+ Model
20
+ </th>
21
+ <th>
22
+ Revision
23
+ </th>
18
24
  <th>
19
25
  Firmware
20
26
  </th>
@@ -24,6 +30,9 @@
24
30
  <th>
25
31
  Update File
26
32
  </th>
33
+ <th>
34
+ Progress
35
+ </th>
27
36
  <th>
28
37
  Last IP
29
38
  </th>
@@ -19,6 +19,8 @@
19
19
  </div>
20
20
  <h3>Upload Progress</h3>
21
21
  <div class="col">
22
+ <div id="upload-alerts">
23
+ </div>
22
24
  <div class="progress w-100" style=" height: 40px;" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
23
25
  <div class="progress-bar progress-bar-striped progress-bar-animated" id="upload-progress" style="width: 0%;">0%</div>
24
26
  </div>
@@ -18,6 +18,9 @@
18
18
  <th>
19
19
  Firmware
20
20
  </th>
21
+ <th>
22
+ Progress
23
+ </th>
21
24
  <th>
22
25
  Last IP
23
26
  </th>
@@ -7,6 +7,11 @@
7
7
  <div class="card-header">
8
8
  <h3>Logs - {{ device }}</h3>
9
9
  </div>
10
+ <div class="card-header">
11
+ <div class="progress m-2" role="progressbar" aria-label="Basic example" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
12
+ <div class="progress-bar progress-bar-striped progress-bar-animated" id="install-progress" style="width: 0%"></div>
13
+ </div>
14
+ </div>
10
15
  <div class="card-body">
11
16
  <pre id="device-log"></pre>
12
17
  </div>
@@ -34,7 +34,7 @@
34
34
  <div class="container-fluid">
35
35
  <a class="navbar-brand" href="/ui/home">
36
36
  <img src="{{ request.url_for('static', path='svg/goosebit-logo.svg') }}" class="me-2" style="height:30px; width: 30px;"/>
37
- GooseBit
37
+ gooseBit
38
38
  </a>
39
39
  <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
40
40
  <span class="navbar-toggler-icon"></span>
@@ -50,7 +50,9 @@
50
50
  {% if "devices.read" in request.user.permissions %}
51
51
  <a class="nav-link{% if request.url.path.endswith('devices') %} active{% endif %}" href="/ui/devices">Devices</a>
52
52
  {% endif %}
53
-
53
+ {% if "rollouts.read" in request.user.permissions %}
54
+ <a class="nav-link{% if request.url.path.endswith('rollouts') %} active{% endif %}" href="/ui/rollouts">Rollouts</a>
55
+ {% endif %}
54
56
  </div>
55
57
  <div class="navbar-nav d-flex flex-fill justify-content-end">
56
58
  <a class="nav-link" href="/logout">Logout<i class="bi bi-box-arrow-right ps-2"></i></a>
@@ -0,0 +1,53 @@
1
+ {% extends "nav.html" %}
2
+ {% block content %}
3
+ <div class="container-fluid">
4
+ <div class="row p-2 d-flex justify-content-center">
5
+ <div class="col">
6
+ <table id="rollout-table" class="table table-hover">
7
+ <thead>
8
+ <tr>
9
+ <th>
10
+ Id
11
+ </th>
12
+ <th>
13
+ Created
14
+ </th>
15
+ <th>
16
+ Name
17
+ </th>
18
+ <th>
19
+ Model
20
+ </th>
21
+ <th>
22
+ Revision
23
+ </th>
24
+ <th>
25
+ Feed
26
+ </th>
27
+ <th>
28
+ Flavour
29
+ </th>
30
+ <th>
31
+ Update File
32
+ </th>
33
+ <th>
34
+ Paused
35
+ </th>
36
+ <th>
37
+ Success Count
38
+ </th>
39
+ <th>
40
+ Failure Count
41
+ </th>
42
+ </tr>
43
+ </thead>
44
+ <tbody id="rollouts-list">
45
+
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ <script src="{{ url_for('static', path='js/rollouts.js') }}"></script>
53
+ {% endblock content %}
@@ -3,6 +3,7 @@ import json
3
3
  from fastapi import APIRouter, Depends
4
4
  from fastapi.requests import Request
5
5
 
6
+ from goosebit.settings import POLL_TIME_REGISTRATION
6
7
  from goosebit.updater.manager import UpdateManager, get_update_manager
7
8
 
8
9
  # v1 is hardware revision
@@ -16,17 +17,47 @@ async def polling(
16
17
  dev_id: str,
17
18
  updater: UpdateManager = Depends(get_update_manager),
18
19
  ):
19
- return {
20
- "config": {"polling": {"sleep": updater.poll_time}},
21
- "_links": {
22
- "deploymentBase": {
20
+ links = {}
21
+
22
+ sleep = updater.poll_time
23
+ last_state = updater.device.last_state
24
+
25
+ if last_state == "unknown":
26
+ # device registration
27
+ sleep = POLL_TIME_REGISTRATION
28
+ links["configData"] = {
29
+ "href": str(
30
+ request.url_for(
31
+ "config_data",
32
+ tenant=tenant,
33
+ dev_id=dev_id,
34
+ )
35
+ )
36
+ }
37
+
38
+ elif last_state == "error" and not updater.force_update:
39
+ # nothing to do
40
+ pass
41
+
42
+ else:
43
+ # provide update if available. Note: this is also required while in state "running", otherwise swupdate
44
+ # won't confirm a successful testing (might be a bug/problem in swupdate)
45
+ update = await updater.get_update_mode()
46
+ if update != "skip":
47
+ links["deploymentBase"] = {
23
48
  "href": str(
24
49
  request.url_for(
25
- "deployment_base", tenant=tenant, dev_id=dev_id, action_id=1
50
+ "deployment_base",
51
+ tenant=tenant,
52
+ dev_id=dev_id,
53
+ action_id=1,
26
54
  )
27
55
  )
28
- },
29
- },
56
+ }
57
+
58
+ return {
59
+ "config": {"polling": {"sleep": sleep}},
60
+ "_links": links,
30
61
  }
31
62
 
32
63
 
@@ -34,11 +65,12 @@ async def polling(
34
65
  async def config_data(
35
66
  request: Request,
36
67
  dev_id: str,
68
+ tenant: str,
37
69
  updater: UpdateManager = Depends(get_update_manager),
38
70
  ):
39
71
  data = await request.json()
40
72
  # TODO: make standard schema to deal with this
41
- print(data)
73
+ await updater.update_config_data(**data["data"])
42
74
  return {"success": True, "message": "Updated swupdate data."}
43
75
 
44
76
 
@@ -77,8 +109,46 @@ async def deployment_feedback(
77
109
  except json.JSONDecodeError:
78
110
  return
79
111
  try:
80
- state = data["status"]["result"]["finished"]
81
- await updater.update_device_state(state)
112
+ execution = data["status"]["execution"]
113
+
114
+ if execution == "proceeding":
115
+ await updater.update_device_state("running")
116
+
117
+ elif execution == "closed":
118
+ state = data["status"]["result"]["finished"]
119
+
120
+ updater.force_update = False
121
+ updater.update_complete = True
122
+
123
+ # From hawkBit docu: DDI defines also a status NONE which will not be interpreted by the update server
124
+ # and handled like SUCCESS.
125
+ if state == "success" or state == "none":
126
+ await updater.update_device_state("finished")
127
+
128
+ # not guaranteed to be the correct rollout - see next comment.
129
+ rollout = await updater.get_rollout()
130
+ if rollout:
131
+ file = rollout.fw_file
132
+ rollout.success_count += 1
133
+ await rollout.save()
134
+ else:
135
+ device = await updater.get_device()
136
+ file = device.fw_file
137
+
138
+ # setting the currently installed version based on the current assigned firmware / existing rollouts
139
+ # is problematic. Better to assign custom action_id for each update (rollout id? firmware id? new id?).
140
+ # Alternatively - but requires customization on the gateway side - use version reported by the gateway.
141
+ await updater.update_fw_version(file)
142
+
143
+ elif state == "failure":
144
+ await updater.update_device_state("error")
145
+
146
+ # not guaranteed to be the correct rollout - see comment above.
147
+ rollout = await updater.get_rollout()
148
+ if rollout:
149
+ rollout.failure_count += 1
150
+ await rollout.save()
151
+
82
152
  except KeyError:
83
153
  pass
84
154
 
@@ -1,9 +1,8 @@
1
- from fastapi import APIRouter, Depends
1
+ from fastapi import APIRouter
2
2
  from fastapi.requests import Request
3
3
  from fastapi.responses import FileResponse
4
4
 
5
- from goosebit.settings import TOKEN_SWU_DIR, UPDATES_DIR
6
- from goosebit.updater.manager import UpdateManager, get_update_manager
5
+ from goosebit.settings import UPDATES_DIR
7
6
 
8
7
  router = APIRouter(prefix="/v1")
9
8
 
@@ -12,15 +11,3 @@ router = APIRouter(prefix="/v1")
12
11
  async def download_file(request: Request, tenant: str, dev_id: str, file: str):
13
12
  filename = UPDATES_DIR.joinpath(file)
14
13
  return FileResponse(filename, media_type="application/octet-stream")
15
-
16
-
17
- @router.get("/{dev_id}/cfd_conf/{file}")
18
- async def download_cfd_conf(
19
- request: Request,
20
- tenant: str,
21
- dev_id: str,
22
- file: str,
23
- updater: UpdateManager = Depends(get_update_manager),
24
- ):
25
- filename = TOKEN_SWU_DIR.joinpath(dev_id, file)
26
- return FileResponse(filename, media_type="application/octet-stream")
@@ -1,21 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import re
4
5
  from abc import ABC, abstractmethod
5
6
  from contextlib import asynccontextmanager
6
- from typing import Callable
7
+ from datetime import datetime
8
+ from typing import Callable, Optional
7
9
 
8
- import aiofiles
9
-
10
- from goosebit.models import Device
11
- from goosebit.settings import POLL_TIME, SWUPDATE_FILES_DIR, TOKEN_SWU_DIR
12
- from goosebit.updater.misc import get_newest_fw
13
- from goosebit.updater.updates import FirmwareArtifact
10
+ from goosebit.models import Device, Rollout
11
+ from goosebit.settings import POLL_TIME, POLL_TIME_UPDATING
12
+ from goosebit.updates.artifacts import FirmwareArtifact
14
13
 
15
14
 
16
15
  class UpdateManager(ABC):
17
16
  def __init__(self, dev_id: str):
18
17
  self.dev_id = dev_id
18
+ self.config_data = {}
19
19
  self.device = None
20
20
  self.force_update = False
21
21
  self.update_complete = False
@@ -31,26 +31,34 @@ class UpdateManager(ABC):
31
31
  async def update_fw_version(self, version: str) -> None:
32
32
  return
33
33
 
34
- async def update_device_state(self, state: str) -> None:
34
+ async def update_hw_model(self, hw_model: str) -> None:
35
35
  return
36
36
 
37
- async def update_last_seen(self, last_seen: int) -> None:
37
+ async def update_hw_revision(self, hw_revision: str) -> None:
38
38
  return
39
39
 
40
- async def update_web_pwd(self, web_pwd: str) -> None:
40
+ async def update_device_state(self, state: str) -> None:
41
41
  return
42
42
 
43
- async def update_last_ip(self, last_ip: str) -> None:
43
+ async def update_last_seen(self, last_seen: int) -> None:
44
44
  return
45
45
 
46
- async def update_cfd_status(self, status: bool) -> None:
46
+ async def update_last_ip(self, last_ip: str) -> None:
47
47
  return
48
48
 
49
- async def create_cfd_token(self) -> str:
50
- return ""
49
+ async def get_rollout(self) -> Optional[Rollout]:
50
+ return None
51
51
 
52
- async def delete_cfd_token(self):
53
- return
52
+ async def update_config_data(self, **kwargs):
53
+ await self.update_hw_model(kwargs.get("hw_model") or "default")
54
+ await self.update_hw_revision(kwargs.get("hw_revision") or "default")
55
+
56
+ device = await self.get_device()
57
+ if device.last_state == "unknown":
58
+ await self.update_device_state("registered")
59
+ await self.save()
60
+
61
+ self.config_data.update(kwargs)
54
62
 
55
63
  @asynccontextmanager
56
64
  async def subscribe_log(self, callback: Callable):
@@ -64,14 +72,15 @@ class UpdateManager(ABC):
64
72
  finally:
65
73
  self.log_subscribers.remove(callback)
66
74
 
75
+ @property
76
+ def poll_seconds(self):
77
+ time_obj = datetime.strptime(self.poll_time, "%H:%M:%S")
78
+ return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second
79
+
67
80
  async def publish_log(self, log_data: str | None):
68
81
  for cb in self.log_subscribers:
69
82
  await cb(log_data)
70
83
 
71
- @property
72
- def cfd_provisioned(self) -> bool:
73
- return False
74
-
75
84
  @abstractmethod
76
85
  async def get_update_file(self) -> FirmwareArtifact: ...
77
86
 
@@ -85,10 +94,10 @@ class UpdateManager(ABC):
85
94
  class UnknownUpdateManager(UpdateManager):
86
95
  def __init__(self, dev_id: str):
87
96
  super().__init__(dev_id)
88
- self.poll_time = "00:00:05"
97
+ self.poll_time = POLL_TIME_UPDATING
89
98
 
90
99
  async def get_update_file(self) -> FirmwareArtifact:
91
- return FirmwareArtifact(get_newest_fw())
100
+ return FirmwareArtifact("latest")
92
101
 
93
102
  async def get_update_mode(self) -> str:
94
103
  return "forced"
@@ -111,6 +120,14 @@ class DeviceUpdateManager(UpdateManager):
111
120
  device = await self.get_device()
112
121
  device.fw_version = version
113
122
 
123
+ async def update_hw_model(self, hw_model: str) -> None:
124
+ device = await self.get_device()
125
+ device.hw_model = hw_model
126
+
127
+ async def update_hw_revision(self, hw_revision: str) -> None:
128
+ device = await self.get_device()
129
+ device.hw_revision = hw_revision
130
+
114
131
  async def update_device_state(self, state: str) -> None:
115
132
  device = await self.get_device()
116
133
  device.last_state = state
@@ -126,13 +143,33 @@ class DeviceUpdateManager(UpdateManager):
126
143
  else:
127
144
  device.last_ip = last_ip
128
145
 
146
+ async def get_rollout(self) -> Optional[Rollout]:
147
+ device = await self.get_device()
148
+
149
+ if device.fw_file == "none":
150
+ return (
151
+ await Rollout.filter(
152
+ hw_model=device.hw_model,
153
+ hw_revision=device.hw_revision,
154
+ feed=device.feed,
155
+ flavor=device.flavor,
156
+ )
157
+ .order_by("-created_at")
158
+ .first()
159
+ )
160
+
161
+ return None
162
+
129
163
  async def get_update_file(self) -> FirmwareArtifact:
130
164
  device = await self.get_device()
131
- file = FirmwareArtifact(device.fw_file)
165
+ file = device.fw_file
166
+
167
+ if file == "none":
168
+ rollout = await self.get_rollout()
169
+ if rollout and not rollout.paused:
170
+ file = rollout.fw_file
132
171
 
133
- if self.force_update:
134
- return file
135
- return file
172
+ return FirmwareArtifact(file, device.hw_model, device.hw_revision)
136
173
 
137
174
  async def get_update_mode(self) -> str:
138
175
  device = await self.get_device()
@@ -141,20 +178,19 @@ class DeviceUpdateManager(UpdateManager):
141
178
  if file.is_empty():
142
179
  mode = "skip"
143
180
  self.poll_time = POLL_TIME
144
- elif file.name == device.fw_version:
181
+ elif file.name == device.fw_version and not self.force_update:
182
+ mode = "skip"
183
+ self.poll_time = POLL_TIME
184
+ elif device.last_state == "error" and not self.force_update:
145
185
  mode = "skip"
146
186
  self.poll_time = POLL_TIME
147
187
  else:
148
188
  mode = "forced"
149
- self.poll_time = "00:00:05"
189
+ self.poll_time = POLL_TIME_UPDATING
150
190
 
151
- if self.force_update:
152
- mode = "forced"
153
- self.poll_time = "00:00:05"
154
-
155
- if mode == "forced" and self.update_complete:
156
- self.update_complete = False
157
- await self.clear_log()
191
+ if self.update_complete:
192
+ self.update_complete = False
193
+ await self.clear_log()
158
194
 
159
195
  return mode
160
196
 
@@ -162,6 +198,9 @@ class DeviceUpdateManager(UpdateManager):
162
198
  if log_data is None:
163
199
  return
164
200
  device = await self.get_device()
201
+ matches = re.findall(r"Downloaded (\d+)%", log_data)
202
+ if matches:
203
+ device.progress = matches[-1]
165
204
  if device.last_log is None:
166
205
  device.last_log = ""
167
206
  if log_data.startswith("Installing Update Chunk Artifacts."):
@@ -172,10 +211,12 @@ class DeviceUpdateManager(UpdateManager):
172
211
  if not log_data == "Skipped Update.":
173
212
  device.last_log += f"{log_data}\n"
174
213
  await self.publish_log(f"{log_data}\n")
214
+ await device.save()
175
215
 
176
216
  async def clear_log(self) -> None:
177
217
  device = await self.get_device()
178
218
  device.last_log = ""
219
+ await device.save()
179
220
  await self.publish_log(None)
180
221
 
181
222
 
goosebit/updater/misc.py CHANGED
@@ -1,11 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime
4
3
  import hashlib
5
4
  from pathlib import Path
5
+ from typing import Optional
6
6
 
7
7
  from goosebit.models import Device
8
- from goosebit.settings import UPDATES_DIR
8
+ from goosebit.settings import UPDATE_VERSION_PARSER, UPDATES_DIR
9
9
 
10
10
 
11
11
  def sha1_hash_file(file_path: Path):
@@ -14,46 +14,34 @@ def sha1_hash_file(file_path: Path):
14
14
  return sha1_hash.hexdigest()
15
15
 
16
16
 
17
- def get_newest_fw() -> str:
18
- fw_files = [f for f in UPDATES_DIR.iterdir() if f.suffix == ".swu"]
17
+ def get_newest_fw(hw_model: str, hw_revision: str) -> Optional[str]:
18
+ def filter_filename(filename, hw_model, hw_revision) -> bool:
19
+ image_data = filename.split("_")
20
+ assert len(image_data) == 3
21
+ model, revision, _ = image_data
22
+ return model == hw_model and revision == hw_revision
23
+
24
+ fw_files = [
25
+ f
26
+ for f in UPDATES_DIR.iterdir()
27
+ if f.suffix == ".swu" and filter_filename(f.name, hw_model, hw_revision)
28
+ ]
19
29
  if len(fw_files) == 0:
20
- return ""
30
+ return None
21
31
 
22
32
  return str(sorted(fw_files, key=lambda x: fw_sort_key(x), reverse=True)[0].name)
23
33
 
24
34
 
25
- def fw_sort_key(filename: Path) -> datetime.datetime:
26
- image_data = filename.stem.split("_")
27
- if len(image_data) == 3:
28
- tenant, date, time = image_data
29
- elif len(image_data) == 4:
30
- tenant, hw_version, date, time = image_data
31
- else:
32
- return datetime.datetime.now()
33
-
34
- return datetime.datetime.strptime(f"{date}_{time}", "%Y%m%d_%H%M%S")
35
+ def validate_filename(filename: str) -> bool:
36
+ try:
37
+ fw_sort_key(Path(filename))
38
+ return True
39
+ except ValueError:
40
+ return False
35
41
 
36
42
 
37
- def get_fw_components(filename: Path) -> dict:
38
- image_data = filename.stem.split("_")
39
- if len(image_data) == 3:
40
- tenant, date, time = image_data
41
- return {
42
- "date": datetime.datetime.strptime(f"{date}_{time}", "%Y%m%d_%H%M%S"),
43
- "day": date,
44
- "time": time,
45
- "tenant": tenant,
46
- "hw_version": 0,
47
- }
48
- elif len(image_data) == 4:
49
- tenant, hw_version, date, time = image_data
50
- return {
51
- "date": datetime.datetime.strptime(f"{date}_{time}", "%Y%m%d_%H%M%S"),
52
- "day": date,
53
- "time": time,
54
- "tenant": tenant,
55
- "hw_version": int(hw_version.upper().replace("V", "")),
56
- }
43
+ def fw_sort_key(filename: Path):
44
+ return UPDATE_VERSION_PARSER.parse(filename)
57
45
 
58
46
 
59
47
  async def get_device_by_uuid(dev_id: str) -> Device:
@@ -3,12 +3,14 @@ import time
3
3
  from fastapi import APIRouter, Depends, HTTPException
4
4
  from fastapi.requests import Request
5
5
 
6
+ from goosebit.settings import TENANT
7
+
6
8
  from . import controller, download
7
9
  from .manager import get_update_manager_sync
8
10
 
9
11
 
10
12
  async def verify_tenant(tenant: str):
11
- if not tenant == "loadsync":
13
+ if not tenant == TENANT:
12
14
  raise HTTPException(404)
13
15
  return tenant
14
16
 
File without changes
@@ -4,19 +4,20 @@ from typing import Optional
4
4
 
5
5
  from fastapi.requests import Request
6
6
 
7
- from goosebit.settings import TOKEN_SWU_DIR, UPDATES_DIR
7
+ from goosebit.settings import UPDATES_DIR
8
8
  from goosebit.updater.misc import get_newest_fw, sha1_hash_file
9
9
 
10
10
 
11
11
  class FirmwareArtifact:
12
- def __init__(self, file: str = None, dev_id: str = None):
12
+ def __init__(self, file: str = None, hw_model: str = None, hw_revision: str = None):
13
13
  if file == "latest":
14
- self.file = get_newest_fw()
14
+ self.file = get_newest_fw(hw_model, hw_revision)
15
15
  elif file == "pinned":
16
16
  self.file = None
17
+ elif file == "none":
18
+ self.file = None
17
19
  else:
18
20
  self.file = file
19
- self.dev_id = dev_id
20
21
 
21
22
  def __eq__(self, other):
22
23
  if isinstance(other, str):
@@ -31,19 +32,18 @@ class FirmwareArtifact:
31
32
  def file_exists(self) -> bool:
32
33
  if self.is_empty():
33
34
  return False
34
- if self.file == "cloudflared.swu":
35
- return True
36
35
  return self.path.exists()
37
36
 
38
37
  @property
39
- def name(self):
40
- if not self.is_empty():
41
- return self.file.split(".")[0]
38
+ def name(self) -> Optional[str]:
39
+ return self.file
42
40
 
43
41
  @property
44
42
  def version(self):
45
43
  if not self.is_empty():
46
- return "_".join(self.name.split("_")[2:])
44
+ image_data = self.name.split("_")
45
+ assert len(image_data) == 3
46
+ return "_".join(image_data[1:])
47
47
 
48
48
  @property
49
49
  def timestamp(self):
@@ -52,14 +52,10 @@ class FirmwareArtifact:
52
52
  @property
53
53
  def path(self) -> Optional[Path]:
54
54
  if not self.is_empty():
55
- if self.file == "cloudflared.swu":
56
- return TOKEN_SWU_DIR.joinpath(self.dev_id, self.file)
57
55
  return UPDATES_DIR.joinpath(self.file)
58
56
 
59
57
  @property
60
58
  def dl_endpoint(self):
61
- if self.file == "cloudflared.swu":
62
- return "download_cfd_conf"
63
59
  return "download_file"
64
60
 
65
61
  def generate_chunk(self, request: Request, tenant: str, dev_id: str) -> list: