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.
- goosebit/__init__.py +50 -19
- goosebit/__main__.py +7 -0
- goosebit/api/responses.py +5 -0
- goosebit/api/routes.py +5 -15
- goosebit/api/telemetry/__init__.py +1 -0
- goosebit/{telemetry/__init__.py → api/telemetry/metrics.py} +9 -3
- goosebit/api/telemetry/prometheus/__init__.py +2 -0
- goosebit/api/telemetry/prometheus/readers.py +3 -0
- goosebit/api/telemetry/prometheus/routes.py +18 -0
- goosebit/api/telemetry/routes.py +9 -0
- goosebit/api/v1/__init__.py +1 -0
- goosebit/api/v1/devices/__init__.py +1 -0
- goosebit/api/v1/devices/device/__init__.py +1 -0
- goosebit/api/v1/devices/device/responses.py +13 -0
- goosebit/api/v1/devices/device/routes.py +27 -0
- goosebit/api/v1/devices/requests.py +7 -0
- goosebit/api/v1/devices/responses.py +16 -0
- goosebit/api/v1/devices/routes.py +35 -0
- goosebit/api/v1/download/__init__.py +1 -0
- goosebit/api/v1/download/routes.py +22 -0
- goosebit/api/v1/rollouts/__init__.py +1 -0
- goosebit/api/v1/rollouts/requests.py +16 -0
- goosebit/api/v1/rollouts/responses.py +19 -0
- goosebit/api/v1/rollouts/routes.py +50 -0
- goosebit/api/v1/routes.py +9 -0
- goosebit/api/v1/software/__init__.py +1 -0
- goosebit/api/v1/software/requests.py +5 -0
- goosebit/api/v1/software/responses.py +16 -0
- goosebit/api/v1/software/routes.py +77 -0
- goosebit/auth/__init__.py +101 -101
- goosebit/db/__init__.py +11 -0
- goosebit/db/config.py +10 -0
- goosebit/db/migrations/models/0_20240830054046_init.py +136 -0
- goosebit/{models.py → db/models.py} +17 -10
- goosebit/realtime/logs.py +4 -3
- goosebit/realtime/routes.py +2 -2
- goosebit/schema/__init__.py +0 -0
- goosebit/schema/devices.py +73 -0
- goosebit/schema/rollouts.py +31 -0
- goosebit/schema/software.py +37 -0
- goosebit/settings/__init__.py +17 -0
- goosebit/settings/const.py +21 -0
- goosebit/settings/schema.py +86 -0
- goosebit/ui/bff/__init__.py +1 -0
- goosebit/ui/bff/devices/__init__.py +1 -0
- goosebit/ui/bff/devices/requests.py +12 -0
- goosebit/ui/bff/devices/responses.py +39 -0
- goosebit/ui/bff/devices/routes.py +72 -0
- goosebit/ui/bff/download/__init__.py +1 -0
- goosebit/ui/bff/download/routes.py +22 -0
- goosebit/ui/bff/rollouts/__init__.py +1 -0
- goosebit/ui/bff/rollouts/responses.py +37 -0
- goosebit/ui/bff/rollouts/routes.py +52 -0
- goosebit/ui/bff/routes.py +11 -0
- goosebit/ui/bff/software/__init__.py +1 -0
- goosebit/ui/bff/software/responses.py +37 -0
- goosebit/ui/bff/software/routes.py +83 -0
- goosebit/ui/nav.py +16 -0
- goosebit/ui/routes.py +29 -66
- goosebit/ui/static/favicon.ico +0 -0
- goosebit/ui/static/favicon.svg +1 -1
- goosebit/ui/static/js/devices.js +47 -71
- goosebit/ui/static/js/index.js +4 -9
- goosebit/ui/static/js/login.js +23 -0
- goosebit/ui/static/js/logs.js +1 -1
- goosebit/ui/static/js/rollouts.js +33 -19
- goosebit/ui/static/js/{firmware.js → software.js} +87 -86
- goosebit/ui/static/js/util.js +60 -6
- goosebit/ui/static/svg/goosebit-logo.svg +1 -1
- goosebit/ui/templates/__init__.py +9 -1
- goosebit/ui/templates/devices.html.jinja +75 -0
- goosebit/ui/templates/index.html.jinja +25 -0
- goosebit/ui/templates/login.html.jinja +57 -0
- goosebit/ui/templates/logs.html.jinja +31 -0
- goosebit/ui/templates/nav.html.jinja +84 -0
- goosebit/ui/templates/rollouts.html.jinja +93 -0
- goosebit/ui/templates/software.html.jinja +139 -0
- goosebit/updater/controller/v1/routes.py +101 -96
- goosebit/updater/controller/v1/schema.py +56 -0
- goosebit/updater/manager.py +65 -65
- goosebit/updater/routes.py +3 -11
- goosebit/updates/__init__.py +91 -32
- goosebit/updates/swdesc.py +2 -7
- goosebit-0.2.1.dist-info/METADATA +173 -0
- goosebit-0.2.1.dist-info/RECORD +95 -0
- goosebit/api/devices.py +0 -136
- goosebit/api/download.py +0 -34
- goosebit/api/firmware.py +0 -57
- goosebit/api/helper.py +0 -30
- goosebit/api/rollouts.py +0 -87
- goosebit/db.py +0 -37
- goosebit/permissions.py +0 -75
- goosebit/settings.py +0 -64
- goosebit/telemetry/prometheus.py +0 -10
- goosebit/ui/templates/devices.html +0 -115
- goosebit/ui/templates/firmware.html +0 -163
- goosebit/ui/templates/index.html +0 -23
- goosebit/ui/templates/login.html +0 -65
- goosebit/ui/templates/logs.html +0 -36
- goosebit/ui/templates/nav.html +0 -117
- goosebit/ui/templates/rollouts.html +0 -76
- goosebit-0.1.2.dist-info/METADATA +0 -123
- goosebit-0.1.2.dist-info/RECORD +0 -51
- {goosebit-0.1.2.dist-info → goosebit-0.2.1.dist-info}/LICENSE +0 -0
- {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,
|
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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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=
|
23
|
-
|
24
|
-
|
45
|
+
status_code=401,
|
46
|
+
detail="Invalid username or password",
|
47
|
+
headers={"WWW-Authenticate": "Bearer"},
|
25
48
|
)
|
26
49
|
try:
|
27
|
-
|
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=
|
36
|
-
|
37
|
-
|
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
|
-
|
43
|
-
|
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
|
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(
|
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
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
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(
|
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
|
-
|
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
|
-
|
130
|
-
) ->
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
goosebit/db/__init__.py
ADDED
goosebit/db/config.py
ADDED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
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="
|
120
|
-
through="
|
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
|
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(
|
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()
|
goosebit/realtime/routes.py
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
from fastapi import APIRouter, Depends
|
2
2
|
|
3
|
-
from goosebit.auth import
|
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(
|
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}
|