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/auth/__init__.py CHANGED
@@ -1,139 +1,139 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
4
+ from typing import Annotated, Iterable
2
5
 
3
6
  from argon2.exceptions import VerifyMismatchError
4
7
  from fastapi import Depends, HTTPException
5
- from fastapi.requests import Request
6
- from fastapi.security import SecurityScopes
7
- from fastapi.websockets import WebSocket
8
+ from fastapi.requests import HTTPConnection, Request
9
+ from fastapi.security import OAuth2PasswordBearer, SecurityScopes
8
10
  from joserfc import jwt
9
11
  from joserfc.errors import BadSignatureError
10
12
 
11
- from goosebit.settings import PWD_CXT, SECRET, USERS
13
+ from goosebit.settings import PWD_CXT, USERS, config
14
+ from goosebit.settings.schema import User
12
15
 
13
16
  logger = logging.getLogger(__name__)
14
17
 
15
- async def authenticate_user(request: Request):
16
- form_data = await request.form()
17
- username = form_data.get("username")
18
- password = form_data.get("password")
18
+
19
+ oauth2_auth = OAuth2PasswordBearer(tokenUrl="login", auto_error=False)
20
+
21
+
22
+ async def session_auth(connection: HTTPConnection) -> str:
23
+ return connection.cookies.get("session_id")
24
+
25
+
26
+ def create_token(username: str) -> str:
27
+ return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
28
+
29
+
30
+ def get_user_from_token(token: str) -> User | None:
31
+ if token is None:
32
+ return
33
+ try:
34
+ token_data = jwt.decode(token, config.secret_key)
35
+ username = token_data.claims["username"]
36
+ return USERS.get(username)
37
+ except (BadSignatureError, LookupError, ValueError):
38
+ pass
39
+
40
+
41
+ def login_user(username: str, password: str) -> str:
19
42
  user = USERS.get(username)
20
43
  if user is None:
21
44
  raise HTTPException(
22
- status_code=302,
23
- headers={"location": str(request.url_for("login"))},
24
- detail="Invalid credentials",
45
+ status_code=401,
46
+ detail="Invalid username or password",
47
+ headers={"WWW-Authenticate": "Bearer"},
25
48
  )
26
49
  try:
27
- if not PWD_CXT.verify(user.hashed_pwd, password):
28
- raise HTTPException(
29
- status_code=302,
30
- headers={"location": str(request.url_for("login"))},
31
- detail="Invalid credentials",
32
- )
50
+ PWD_CXT.verify(user.hashed_pwd, password)
33
51
  except VerifyMismatchError:
34
52
  raise HTTPException(
35
- status_code=302,
36
- headers={"location": str(request.url_for("login"))},
37
- detail="Invalid credentials",
53
+ status_code=401,
54
+ detail="Invalid username or password",
55
+ headers={"WWW-Authenticate": "Bearer"},
38
56
  )
57
+ return create_token(user.username)
58
+
59
+
60
+ def get_current_user(
61
+ session_token: Annotated[str, Depends(session_auth)] = None,
62
+ oauth2_token: Annotated[str, Depends(oauth2_auth)] = None,
63
+ ) -> User:
64
+ session_user = get_user_from_token(session_token)
65
+ oauth2_user = get_user_from_token(oauth2_token)
66
+ user = session_user or oauth2_user
39
67
  return user
40
68
 
41
69
 
42
- def create_session(username: str) -> str:
43
- return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=SECRET)
70
+ # using | Request because oauth2_auth.__call__ expects is
71
+ async def get_user_from_request(connection: HTTPConnection | Request) -> User:
72
+ token = await session_auth(connection) or await oauth2_auth(connection)
73
+ return get_user_from_token(token)
44
74
 
45
75
 
46
- def authenticate_session(request: Request):
47
- session_id = request.cookies.get("session_id")
48
- if session_id is None:
49
- raise HTTPException(
50
- status_code=302,
51
- headers={"location": str(request.url_for("login"))},
52
- detail="Invalid session ID",
53
- )
54
- user = get_user_from_session(session_id)
76
+ def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
55
77
  if user is None:
56
78
  raise HTTPException(
57
79
  status_code=302,
58
- headers={"location": str(request.url_for("login"))},
80
+ headers={"location": str(connection.url_for("login_get"))},
59
81
  detail="Invalid username",
60
82
  )
61
- return user
62
83
 
63
84
 
64
- def authenticate_api_session(request: Request):
65
- session_id = request.cookies.get("session_id")
66
- user = get_user_from_session(session_id)
67
- if user is None:
68
- raise HTTPException(status_code=401, detail="Not logged in")
69
- return user
85
+ def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
86
+ if user is not None:
87
+ raise HTTPException(
88
+ status_code=302,
89
+ headers={"location": str(connection.url_for("ui_root"))},
90
+ detail="Already logged in",
91
+ )
70
92
 
71
93
 
72
- def authenticate_ws_session(websocket: WebSocket):
73
- session_id = websocket.cookies.get("session_id")
74
- user = get_user_from_session(session_id)
94
+ def validate_current_user(user: Annotated[User, Depends(get_current_user)]):
75
95
  if user is None:
76
- raise HTTPException(status_code=401, detail="Not logged in")
96
+ raise HTTPException(
97
+ status_code=401,
98
+ detail="Not authenticated",
99
+ headers={"WWW-Authenticate": "Bearer"},
100
+ )
77
101
  return user
78
102
 
79
103
 
80
- def auto_redirect(request: Request):
81
- session_id = request.cookies.get("session_id")
82
- if get_user_from_session(session_id) is None:
83
- return request
84
- raise HTTPException(
85
- status_code=302,
86
- headers={"location": str(request.url_for("ui_root"))},
87
- detail="Already logged in",
88
- )
89
-
90
-
91
- def get_user_from_session(session_id: str):
92
- if session_id is None:
93
- return
94
- try:
95
- session_data = jwt.decode(session_id, SECRET)
96
- return session_data.claims["username"]
97
- except (BadSignatureError, LookupError, ValueError):
98
- pass
99
-
100
-
101
- def get_current_user(request: Request):
102
- session_id = request.cookies.get("session_id")
103
- user = get_user_from_session(session_id)
104
- if user is None:
105
- return None
106
- return USERS[user]
107
-
108
-
109
104
  def validate_user_permissions(
110
- request: Request,
111
- security: SecurityScopes,
112
- username: str = Depends(authenticate_session),
113
- ) -> Request:
114
- user = USERS[username]
115
- if security.scopes is None:
116
- return request
117
- for scope in security.scopes:
118
- if scope not in user.permissions:
119
- logger.warning(f"User {username} does not have permission {scope}")
120
- raise HTTPException(
121
- status_code=403,
122
- detail="Not enough permissions",
123
- )
124
-
125
-
126
- def validate_ws_user_permissions(
127
- websocket: WebSocket,
105
+ connection: HTTPConnection,
128
106
  security: SecurityScopes,
129
- username: str = Depends(authenticate_ws_session),
130
- ) -> WebSocket:
131
- user = USERS[username]
132
- if security.scopes is None:
133
- return websocket
134
- for scope in security.scopes:
135
- if scope not in user.permissions:
136
- raise HTTPException(
137
- status_code=403,
138
- detail="Not enough permissions",
139
- )
107
+ user: User = Depends(get_current_user),
108
+ ) -> HTTPConnection:
109
+ if not check_permissions(security.scopes, user.permissions):
110
+ logger.warning(f"{user.username} does not have sufficient permissions")
111
+ raise HTTPException(
112
+ status_code=403,
113
+ detail="Not enough permissions",
114
+ headers={"WWW-Authenticate": "Bearer"},
115
+ )
116
+ return connection
117
+
118
+
119
+ def check_permissions(scopes: Iterable[str] | None, permissions: Iterable[str]) -> bool:
120
+ deny_permissions = [p.lstrip("!") for p in permissions if p.startswith("!")]
121
+ allow_permissions = [p for p in permissions if not p.startswith("!")]
122
+ if scopes is None:
123
+ return True
124
+ for scope in scopes:
125
+ if any([_check_permission(scope, permission) for permission in deny_permissions]):
126
+ return False
127
+ if not any([_check_permission(scope, permission) for permission in allow_permissions]):
128
+ return False
129
+ return True
130
+
131
+
132
+ def _check_permission(scope: str, permission: str) -> bool:
133
+ split_scope = scope.split(".")
134
+ for idx, permission in enumerate(permission.split(".")):
135
+ if permission == "*":
136
+ continue
137
+ if not split_scope[idx] == permission:
138
+ return False
139
+ return True
@@ -0,0 +1,11 @@
1
+ from tortoise import Tortoise
2
+
3
+ from goosebit.db.config import TORTOISE_CONF
4
+
5
+
6
+ async def init():
7
+ await Tortoise.init(config=TORTOISE_CONF)
8
+
9
+
10
+ async def close():
11
+ await Tortoise.close_connections()
goosebit/db/config.py ADDED
@@ -0,0 +1,10 @@
1
+ from goosebit.settings import config
2
+
3
+ TORTOISE_CONF = {
4
+ "connections": {"default": config.db_uri},
5
+ "apps": {
6
+ "models": {
7
+ "models": ["goosebit.db.models", "aerich.models"],
8
+ },
9
+ },
10
+ }
@@ -0,0 +1,136 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ dialect = db.schema_generator.DIALECT
6
+
7
+ if dialect == "postgres":
8
+ return """
9
+ CREATE TABLE IF NOT EXISTS "hardware" (
10
+ "id" SERIAL NOT NULL PRIMARY KEY,
11
+ "model" VARCHAR(255) NOT NULL,
12
+ "revision" VARCHAR(255) NOT NULL
13
+ );
14
+ CREATE TABLE IF NOT EXISTS "software" (
15
+ "id" SERIAL NOT NULL PRIMARY KEY,
16
+ "uri" VARCHAR(255) NOT NULL,
17
+ "size" BIGINT NOT NULL,
18
+ "hash" VARCHAR(255) NOT NULL,
19
+ "version" VARCHAR(255) NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS "device" (
22
+ "uuid" VARCHAR(255) NOT NULL PRIMARY KEY,
23
+ "name" VARCHAR(255),
24
+ "force_update" BOOL NOT NULL DEFAULT False,
25
+ "sw_version" VARCHAR(255),
26
+ "feed" VARCHAR(255) NOT NULL DEFAULT 'default',
27
+ "update_mode" SMALLINT NOT NULL DEFAULT 3,
28
+ "last_state" SMALLINT NOT NULL DEFAULT 1,
29
+ "progress" INT,
30
+ "log_complete" BOOL NOT NULL DEFAULT False,
31
+ "last_log" TEXT,
32
+ "last_seen" BIGINT,
33
+ "last_ip" VARCHAR(15),
34
+ "last_ipv6" VARCHAR(40),
35
+ "assigned_software_id" INT REFERENCES "software" ("id") ON DELETE SET NULL,
36
+ "hardware_id" INT NOT NULL REFERENCES "hardware" ("id") ON DELETE CASCADE
37
+ );
38
+ COMMENT ON COLUMN "device"."update_mode" IS 'NONE: 0\nLATEST: 1\nPINNED: 2\nROLLOUT: 3\nASSIGNED: 4';
39
+ COMMENT ON COLUMN "device"."last_state" IS 'NONE: 0\nUNKNOWN: 1\nREGISTERED: 2\nRUNNING: 3\nERROR: 4\nFINISHED: 5';
40
+ CREATE TABLE IF NOT EXISTS "rollout" (
41
+ "id" SERIAL NOT NULL PRIMARY KEY,
42
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
43
+ "name" VARCHAR(255),
44
+ "feed" VARCHAR(255) NOT NULL DEFAULT 'default',
45
+ "paused" BOOL NOT NULL DEFAULT False,
46
+ "success_count" INT NOT NULL DEFAULT 0,
47
+ "failure_count" INT NOT NULL DEFAULT 0,
48
+ "software_id" INT NOT NULL REFERENCES "software" ("id") ON DELETE CASCADE
49
+ );
50
+ CREATE TABLE IF NOT EXISTS "tag" (
51
+ "id" SERIAL NOT NULL PRIMARY KEY,
52
+ "name" VARCHAR(255) NOT NULL
53
+ );
54
+ CREATE TABLE IF NOT EXISTS "aerich" (
55
+ "id" SERIAL NOT NULL PRIMARY KEY,
56
+ "version" VARCHAR(255) NOT NULL,
57
+ "app" VARCHAR(100) NOT NULL,
58
+ "content" JSONB NOT NULL
59
+ );
60
+ CREATE TABLE IF NOT EXISTS "software_compatibility" (
61
+ "software_id" INT NOT NULL REFERENCES "software" ("id") ON DELETE CASCADE,
62
+ "hardware_id" INT NOT NULL REFERENCES "hardware" ("id") ON DELETE CASCADE
63
+ );
64
+ CREATE UNIQUE INDEX IF NOT EXISTS "uidx_software_co_softwar_2683cc" ON "software_compatibility" ("software_id", "hardware_id");
65
+ CREATE TABLE IF NOT EXISTS "device_tags" (
66
+ "device_id" VARCHAR(255) NOT NULL REFERENCES "device" ("uuid") ON DELETE CASCADE,
67
+ "tag_id" INT NOT NULL REFERENCES "tag" ("id") ON DELETE CASCADE
68
+ );
69
+ CREATE UNIQUE INDEX IF NOT EXISTS "uidx_device_tags_device__2ab77e" ON "device_tags" ("device_id", "tag_id");"""
70
+
71
+ else:
72
+ return """
73
+ CREATE TABLE IF NOT EXISTS "hardware" (
74
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
75
+ "model" VARCHAR(255) NOT NULL,
76
+ "revision" VARCHAR(255) NOT NULL
77
+ );
78
+ CREATE TABLE IF NOT EXISTS "software" (
79
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
80
+ "uri" VARCHAR(255) NOT NULL,
81
+ "size" BIGINT NOT NULL,
82
+ "hash" VARCHAR(255) NOT NULL,
83
+ "version" VARCHAR(255) NOT NULL
84
+ );
85
+ CREATE TABLE IF NOT EXISTS "device" (
86
+ "uuid" VARCHAR(255) NOT NULL PRIMARY KEY,
87
+ "name" VARCHAR(255),
88
+ "force_update" INT NOT NULL DEFAULT 0,
89
+ "sw_version" VARCHAR(255),
90
+ "feed" VARCHAR(255) NOT NULL DEFAULT 'default',
91
+ "update_mode" SMALLINT NOT NULL DEFAULT 3 /* NONE: 0\nLATEST: 1\nPINNED: 2\nROLLOUT: 3\nASSIGNED: 4 */,
92
+ "last_state" SMALLINT NOT NULL DEFAULT 1 /* NONE: 0\nUNKNOWN: 1\nREGISTERED: 2\nRUNNING: 3\nERROR: 4\nFINISHED: 5 */,
93
+ "progress" INT,
94
+ "log_complete" INT NOT NULL DEFAULT 0,
95
+ "last_log" TEXT,
96
+ "last_seen" BIGINT,
97
+ "last_ip" VARCHAR(15),
98
+ "last_ipv6" VARCHAR(40),
99
+ "assigned_software_id" INT REFERENCES "software" ("id") ON DELETE SET NULL,
100
+ "hardware_id" INT NOT NULL REFERENCES "hardware" ("id") ON DELETE CASCADE
101
+ );
102
+ CREATE TABLE IF NOT EXISTS "rollout" (
103
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
104
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
105
+ "name" VARCHAR(255),
106
+ "feed" VARCHAR(255) NOT NULL DEFAULT 'default',
107
+ "paused" INT NOT NULL DEFAULT 0,
108
+ "success_count" INT NOT NULL DEFAULT 0,
109
+ "failure_count" INT NOT NULL DEFAULT 0,
110
+ "software_id" INT NOT NULL REFERENCES "software" ("id") ON DELETE CASCADE
111
+ );
112
+ CREATE TABLE IF NOT EXISTS "tag" (
113
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
114
+ "name" VARCHAR(255) NOT NULL
115
+ );
116
+ CREATE TABLE IF NOT EXISTS "aerich" (
117
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
118
+ "version" VARCHAR(255) NOT NULL,
119
+ "app" VARCHAR(100) NOT NULL,
120
+ "content" JSON NOT NULL
121
+ );
122
+ CREATE TABLE IF NOT EXISTS "software_compatibility" (
123
+ "software_id" INT NOT NULL REFERENCES "software" ("id") ON DELETE CASCADE,
124
+ "hardware_id" INT NOT NULL REFERENCES "hardware" ("id") ON DELETE CASCADE
125
+ );
126
+ CREATE UNIQUE INDEX IF NOT EXISTS "uidx_software_co_softwar_2683cc" ON "software_compatibility" ("software_id", "hardware_id");
127
+ CREATE TABLE IF NOT EXISTS "device_tags" (
128
+ "device_id" VARCHAR(255) NOT NULL REFERENCES "device" ("uuid") ON DELETE CASCADE,
129
+ "tag_id" INT NOT NULL REFERENCES "tag" ("id") ON DELETE CASCADE
130
+ );
131
+ CREATE UNIQUE INDEX IF NOT EXISTS "uidx_device_tags_device__2ab77e" ON "device_tags" ("device_id", "tag_id");"""
132
+
133
+
134
+ async def downgrade(db: BaseDBAsyncClient) -> str:
135
+ return """
136
+ """
@@ -7,7 +7,7 @@ from urllib.request import url2pathname
7
7
  import semver
8
8
  from tortoise import Model, fields
9
9
 
10
- from goosebit.telemetry import devices_count
10
+ from goosebit.api.telemetry.metrics import devices_count
11
11
 
12
12
 
13
13
  class UpdateModeEnum(IntEnum):
@@ -55,12 +55,13 @@ class Tag(Model):
55
55
  class Device(Model):
56
56
  uuid = fields.CharField(max_length=255, primary_key=True)
57
57
  name = fields.CharField(max_length=255, null=True)
58
- assigned_firmware = fields.ForeignKeyField("models.Firmware", related_name="assigned_devices", null=True)
58
+ assigned_software = fields.ForeignKeyField(
59
+ "models.Software", related_name="assigned_devices", null=True, on_delete=fields.SET_NULL
60
+ )
59
61
  force_update = fields.BooleanField(default=False)
60
- fw_version = fields.CharField(max_length=255, null=True)
62
+ sw_version = fields.CharField(max_length=255, null=True)
61
63
  hardware = fields.ForeignKeyField("models.Hardware", related_name="devices")
62
64
  feed = fields.CharField(max_length=255, default="default")
63
- flavor = fields.CharField(max_length=255, default="default")
64
65
  update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
65
66
  last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
66
67
  progress = fields.IntField(null=True)
@@ -95,8 +96,7 @@ class Rollout(Model):
95
96
  created_at = fields.DatetimeField(auto_now_add=True)
96
97
  name = fields.CharField(max_length=255, null=True)
97
98
  feed = fields.CharField(max_length=255, default="default")
98
- flavor = fields.CharField(max_length=255, default="default")
99
- firmware = fields.ForeignKeyField("models.Firmware", related_name="rollouts")
99
+ software = fields.ForeignKeyField("models.Software", related_name="rollouts")
100
100
  paused = fields.BooleanField(default=False)
101
101
  success_count = fields.IntField(default=0)
102
102
  failure_count = fields.IntField(default=0)
@@ -108,7 +108,7 @@ class Hardware(Model):
108
108
  revision = fields.CharField(max_length=255)
109
109
 
110
110
 
111
- class Firmware(Model):
111
+ class Software(Model):
112
112
  id = fields.IntField(primary_key=True)
113
113
  uri = fields.CharField(max_length=255)
114
114
  size = fields.BigIntField()
@@ -116,8 +116,8 @@ class Firmware(Model):
116
116
  version = fields.CharField(max_length=255)
117
117
  compatibility = fields.ManyToManyField(
118
118
  "models.Hardware",
119
- related_name="firmwares",
120
- through="firmware_compatibility",
119
+ related_name="softwares",
120
+ through="software_compatibility",
121
121
  )
122
122
 
123
123
  @classmethod
@@ -136,5 +136,12 @@ class Firmware(Model):
136
136
  return Path(url2pathname(unquote(urlparse(self.uri).path)))
137
137
 
138
138
  @property
139
- def local(self):
139
+ def local(self) -> bool:
140
140
  return urlparse(self.uri).scheme == "file"
141
+
142
+ @property
143
+ def path_user(self) -> str:
144
+ if self.local:
145
+ return self.path.name
146
+ else:
147
+ return self.uri
goosebit/realtime/logs.py CHANGED
@@ -1,10 +1,11 @@
1
+ from __future__ import annotations
2
+
1
3
  from fastapi import APIRouter, Security
2
4
  from fastapi.websockets import WebSocket, WebSocketDisconnect
3
5
  from pydantic import BaseModel
4
6
  from websockets.exceptions import ConnectionClosed
5
7
 
6
- from goosebit.auth import validate_ws_user_permissions
7
- from goosebit.permissions import Permissions
8
+ from goosebit.auth import validate_user_permissions
8
9
  from goosebit.updater.manager import get_update_manager
9
10
 
10
11
  router = APIRouter(prefix="/logs")
@@ -18,7 +19,7 @@ class RealtimeLogModel(BaseModel):
18
19
 
19
20
  @router.websocket(
20
21
  "/{dev_id}",
21
- dependencies=[Security(validate_ws_user_permissions, scopes=[Permissions.HOME.READ])],
22
+ dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
22
23
  )
23
24
  async def device_logs(websocket: WebSocket, dev_id: str):
24
25
  await websocket.accept()
@@ -1,12 +1,12 @@
1
1
  from fastapi import APIRouter, Depends
2
2
 
3
- from goosebit.auth import authenticate_ws_session
3
+ from goosebit.auth import validate_current_user
4
4
 
5
5
  from . import logs
6
6
 
7
7
  router = APIRouter(
8
8
  prefix="/realtime",
9
- dependencies=[Depends(authenticate_ws_session)],
9
+ dependencies=[Depends(validate_current_user)],
10
10
  tags=["realtime"],
11
11
  )
12
12
 
File without changes
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from enum import Enum, IntEnum, StrEnum
5
+ from typing import Annotated
6
+
7
+ from pydantic import BaseModel, BeforeValidator, computed_field
8
+
9
+ from goosebit.db.models import Device, UpdateModeEnum, UpdateStateEnum
10
+ from goosebit.updater.manager import get_update_manager
11
+
12
+
13
+ class ConvertableEnum(StrEnum):
14
+ @classmethod
15
+ def convert(cls, value: IntEnum):
16
+ return cls(str(value))
17
+
18
+
19
+ def enum_factory(name: str, base: type[Enum]) -> type[ConvertableEnum]:
20
+ enum_dict = {item.name: str(item) for item in base}
21
+ return ConvertableEnum(name, enum_dict) # type: ignore
22
+
23
+
24
+ UpdateStateSchema = enum_factory("UpdateStateSchema", UpdateStateEnum)
25
+ UpdateModeSchema = enum_factory("UpdateModeSchema", UpdateModeEnum)
26
+
27
+
28
+ class DeviceSchema(BaseModel):
29
+ uuid: str
30
+ name: str | None
31
+ sw_version: str | None
32
+ sw_target_version: str | None
33
+ sw_assigned: int | None
34
+ hw_model: str
35
+ hw_revision: str
36
+ feed: str
37
+ progress: int | None
38
+ last_state: Annotated[UpdateStateSchema, BeforeValidator(UpdateStateSchema.convert)]
39
+ update_mode: Annotated[UpdateModeSchema, BeforeValidator(UpdateModeSchema.convert)]
40
+ force_update: bool
41
+ last_ip: str | None
42
+ last_seen: int | None
43
+ poll_seconds: int
44
+
45
+ @computed_field
46
+ def online(self) -> bool | None:
47
+ return self.last_seen < self.poll_seconds if self.last_seen is not None else None
48
+
49
+ @classmethod
50
+ async def convert(cls, device: Device):
51
+ manager = await get_update_manager(device.uuid)
52
+ _, target_software = await manager.get_update()
53
+ last_seen = device.last_seen
54
+ if last_seen is not None:
55
+ last_seen = round(time.time() - device.last_seen)
56
+
57
+ return cls(
58
+ uuid=device.uuid,
59
+ name=device.name,
60
+ sw_version=device.sw_version,
61
+ sw_target_version=(target_software.version if target_software is not None else None),
62
+ sw_assigned=(device.assigned_software.id if device.assigned_software is not None else None),
63
+ hw_model=device.hardware.model,
64
+ hw_revision=device.hardware.revision,
65
+ feed=device.feed,
66
+ progress=device.progress,
67
+ last_state=device.last_state,
68
+ update_mode=device.update_mode,
69
+ force_update=device.force_update,
70
+ last_ip=device.last_ip,
71
+ last_seen=last_seen,
72
+ poll_seconds=manager.poll_seconds,
73
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from goosebit.db.models import Rollout
6
+
7
+
8
+ class RolloutSchema(BaseModel):
9
+ id: int
10
+ created_at: int
11
+ name: str | None
12
+ feed: str
13
+ sw_file: str
14
+ sw_version: str
15
+ paused: bool
16
+ success_count: int
17
+ failure_count: int
18
+
19
+ @classmethod
20
+ async def convert(cls, rollout: Rollout):
21
+ return cls(
22
+ id=rollout.id,
23
+ created_at=int(rollout.created_at.timestamp() * 1000),
24
+ name=rollout.name,
25
+ feed=rollout.feed,
26
+ sw_file=rollout.software.path.name,
27
+ sw_version=rollout.software.version,
28
+ paused=rollout.paused,
29
+ success_count=rollout.success_count,
30
+ failure_count=rollout.failure_count,
31
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from goosebit.db.models import Hardware, Software
8
+
9
+
10
+ class HardwareSchema(BaseModel):
11
+ id: int
12
+ model: str
13
+ revision: str
14
+
15
+ @classmethod
16
+ async def convert(cls, hardware: Hardware):
17
+ return cls(id=hardware.id, model=hardware.model, revision=hardware.revision)
18
+
19
+
20
+ class SoftwareSchema(BaseModel):
21
+ id: int
22
+ name: str
23
+ size: int
24
+ hash: str
25
+ version: str
26
+ compatibility: list[HardwareSchema]
27
+
28
+ @classmethod
29
+ async def convert(cls, software: Software):
30
+ return cls(
31
+ id=software.id,
32
+ name=software.path_user,
33
+ size=software.size,
34
+ hash=software.hash,
35
+ version=software.version,
36
+ compatibility=await asyncio.gather(*[HardwareSchema.convert(h) for h in software.compatibility]),
37
+ )
@@ -0,0 +1,17 @@
1
+ import logging.config
2
+
3
+ from .const import PWD_CXT # noqa: F401
4
+ from .schema import GooseBitSettings
5
+
6
+ config = GooseBitSettings()
7
+
8
+
9
+ logging.config.dictConfig(config.logging)
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ if config.config_file is not None:
14
+ logger.info(f"Loading settings from: {config.config_file}")
15
+
16
+
17
+ USERS = {u.username: u for u in config.users}