goosebit 0.2.5__py3-none-any.whl → 0.2.6__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.6.dist-info/METADATA +280 -0
  79. goosebit-0.2.6.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.6.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.6.dist-info}/LICENSE +0 -0
  88. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/entry_points.txt +0 -0
goosebit/auth/__init__.py CHANGED
@@ -10,8 +10,9 @@ from fastapi.security import OAuth2PasswordBearer, SecurityScopes
10
10
  from joserfc import jwt
11
11
  from joserfc.errors import BadSignatureError
12
12
 
13
- from goosebit.settings import PWD_CXT, USERS, config
14
- from goosebit.settings.schema import User
13
+ from goosebit.db.models import User
14
+ from goosebit.settings import PWD_CXT, config
15
+ from goosebit.users import UserManager
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -31,25 +32,31 @@ def create_token(username: str) -> str:
31
32
  return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=config.secret_key)
32
33
 
33
34
 
34
- def get_user_from_token(token: str | None) -> User | None:
35
+ async def get_user_from_token(token: str | None) -> User | None:
35
36
  if token is None:
36
37
  return None
37
38
  try:
38
39
  token_data = jwt.decode(token, config.secret_key)
39
40
  username = token_data.claims["username"]
40
- return USERS.get(username)
41
+ return await UserManager.get_user(username)
41
42
  except (BadSignatureError, LookupError, ValueError):
42
43
  return None
43
44
 
44
45
 
45
- def login_user(username: str, password: str) -> str:
46
- user = USERS.get(username)
46
+ async def login_user(username: str, password: str) -> str:
47
+ user = await UserManager.get_user(username)
47
48
  if user is None:
48
49
  raise HTTPException(
49
50
  status_code=401,
50
51
  detail="Invalid username or password",
51
52
  headers={"WWW-Authenticate": "Bearer"},
52
53
  )
54
+ if not user.enabled:
55
+ raise HTTPException(
56
+ status_code=401,
57
+ detail="User has been disabled, please contact your administrator",
58
+ headers={"WWW-Authenticate": "Bearer"},
59
+ )
53
60
  try:
54
61
  PWD_CXT.verify(user.hashed_pwd, password)
55
62
  except VerifyMismatchError:
@@ -61,12 +68,12 @@ def login_user(username: str, password: str) -> str:
61
68
  return create_token(user.username)
62
69
 
63
70
 
64
- def get_current_user(
71
+ async def get_current_user(
65
72
  session_token: Annotated[str | None, Depends(session_auth)] = None,
66
73
  oauth2_token: Annotated[str | None, Depends(oauth2_auth)] = None,
67
74
  ) -> User | None:
68
- session_user = get_user_from_token(session_token)
69
- oauth2_user = get_user_from_token(oauth2_token)
75
+ session_user = await get_user_from_token(session_token)
76
+ oauth2_user = await get_user_from_token(oauth2_token)
70
77
  user = session_user or oauth2_user
71
78
  return user
72
79
 
@@ -74,34 +81,63 @@ def get_current_user(
74
81
  # using | Request because oauth2_auth.__call__ expects is
75
82
  async def get_user_from_request(connection: HTTPConnection | Request) -> User | None:
76
83
  token = await session_auth(connection) or await oauth2_auth(connection)
77
- return get_user_from_token(token)
84
+ return await get_user_from_token(token)
78
85
 
79
86
 
80
- def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
87
+ async def redirect_if_unauthenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
81
88
  if user is None:
82
89
  raise HTTPException(
83
90
  status_code=302,
84
91
  headers={"location": str(connection.url_for("login_get"))},
85
92
  detail="Invalid username",
86
93
  )
94
+ if not user.enabled:
95
+ raise HTTPException(
96
+ status_code=302,
97
+ headers={"location": str(connection.url_for("login_get"))},
98
+ detail="Disabled user",
99
+ )
87
100
 
88
101
 
89
- def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
102
+ async def redirect_if_authenticated(connection: HTTPConnection, user: Annotated[User, Depends(get_current_user)]):
90
103
  if user is not None:
104
+ if not user.enabled:
105
+ return
91
106
  raise HTTPException(
92
107
  status_code=302,
93
108
  headers={"location": str(connection.url_for("ui_root"))},
94
109
  detail="Already logged in",
95
110
  )
111
+ if await User.all().count() == 0:
112
+ raise HTTPException(
113
+ status_code=302,
114
+ headers={"location": str(connection.url_for("setup_get"))},
115
+ detail="No users set up",
116
+ )
96
117
 
97
118
 
98
- def validate_current_user(user: Annotated[User, Depends(get_current_user)]):
119
+ async def redirect_if_users_exist(connection: HTTPConnection):
120
+ if await User.all().count() > 0:
121
+ raise HTTPException(
122
+ status_code=302,
123
+ headers={"location": str(connection.url_for("login_get"))},
124
+ detail="An admin user already exists",
125
+ )
126
+
127
+
128
+ async def validate_current_user(user: Annotated[User, Depends(get_current_user)]):
99
129
  if user is None:
100
130
  raise HTTPException(
101
131
  status_code=401,
102
132
  detail="Not authenticated",
103
133
  headers={"WWW-Authenticate": "Bearer"},
104
134
  )
135
+ if not user.enabled:
136
+ raise HTTPException(
137
+ status_code=401,
138
+ detail="Disabled user",
139
+ headers={"WWW-Authenticate": "Bearer"},
140
+ )
105
141
  return user
106
142
 
107
143
 
@@ -0,0 +1,80 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel, Field, computed_field
4
+
5
+
6
+ class Permission(BaseModel):
7
+ def model_post_init(self, ctx):
8
+ if self.sub_permissions is None:
9
+ return
10
+ for permission in self.sub_permissions:
11
+ permission.parent_permission = self
12
+
13
+ def __call__(self, *args, **kwargs) -> str:
14
+ if self.parent_permission is None:
15
+ return self.name
16
+ return ".".join([self.parent_permission(), self.name])
17
+
18
+ def __getitem__(self, item):
19
+ return self.sub_permissions_by_name[item]
20
+
21
+ @property
22
+ def sub_permissions_by_name(self) -> dict[str, "Permission"]:
23
+ if self.sub_permissions is None:
24
+ return {}
25
+ return {item.name: item for item in self.sub_permissions}
26
+
27
+ @computed_field # type: ignore[misc]
28
+ @property
29
+ def value(self) -> str:
30
+ return self()
31
+
32
+ @computed_field # type: ignore[misc]
33
+ @property
34
+ def parent(self) -> str | None:
35
+ if self.parent_permission is not None:
36
+ return self.parent_permission()
37
+ return None
38
+
39
+ name: str
40
+ description: str
41
+
42
+ parent_permission: Optional["Permission"] = Field(exclude=True, default=None)
43
+ sub_permissions: list["Permission"] | None = None
44
+
45
+
46
+ READ_PERMISSION = Permission(name="read", description="Read access")
47
+ WRITE_PERMISSION = Permission(name="write", description="Write access")
48
+ DELETE_PERMISSION = Permission(name="delete", description="Delete access")
49
+
50
+ DEVICE_PERMISSIONS = Permission(
51
+ name="device",
52
+ description="Access to devices",
53
+ sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
54
+ )
55
+ SOFTWARE_PERMISSIONS = Permission(
56
+ name="software",
57
+ description="Access to software",
58
+ sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
59
+ )
60
+ ROLLOUT_PERMISSIONS = Permission(
61
+ name="rollout",
62
+ description="Access to rollouts",
63
+ sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
64
+ )
65
+ SETTINGS_USERS_PERMISSIONS = Permission(
66
+ name="users",
67
+ description="Access to user control",
68
+ sub_permissions=[READ_PERMISSION.model_copy(), WRITE_PERMISSION.model_copy(), DELETE_PERMISSION.model_copy()],
69
+ )
70
+ SETTING_PERMISSIONS = Permission(
71
+ name="settings",
72
+ description="Access to settings",
73
+ sub_permissions=[SETTINGS_USERS_PERMISSIONS],
74
+ )
75
+
76
+ GOOSEBIT_PERMISSIONS = Permission(
77
+ name="goosebit",
78
+ description="Full access to GooseBit",
79
+ sub_permissions=[DEVICE_PERMISSIONS, SOFTWARE_PERMISSIONS, ROLLOUT_PERMISSIONS, SETTING_PERMISSIONS],
80
+ )
goosebit/db/config.py CHANGED
@@ -1,10 +1,66 @@
1
+ import logging
2
+ from urllib.parse import parse_qs, urlparse
3
+
4
+ from goosebit.db.pg_ssl_context import PostgresSSLContext
1
5
  from goosebit.settings import config
2
6
 
7
+
8
+ def add_models(models_path: str):
9
+ models.append(models_path)
10
+
11
+
12
+ models = ["goosebit.db.models", "aerich.models"]
13
+
3
14
  TORTOISE_CONF = {
4
15
  "connections": {"default": config.db_uri},
5
16
  "apps": {
6
17
  "models": {
7
- "models": ["goosebit.db.models", "aerich.models"],
18
+ "models": models,
8
19
  },
9
20
  },
10
21
  }
22
+
23
+ if config.db_ssl_crt is not None:
24
+ # config.db_uri must have the following style:
25
+ # postgres://<user>:<password>@<host>:<port>/<database>?sslmode=<sslmode>
26
+ parsed = urlparse(config.db_uri)
27
+ if parsed.scheme != "postgres":
28
+ logging.error("database scheme must be postgres!")
29
+ exit(1)
30
+
31
+ # parse parameters
32
+ params = parse_qs(parsed.query)
33
+
34
+ # create ssl context for postgres
35
+ ssl_context = PostgresSSLContext()
36
+
37
+ # set certificate file
38
+ ssl_context.load_verify_locations(config.db_ssl_crt)
39
+
40
+ # parse and set verify-flags
41
+ if params.get("verifyflags") is not None:
42
+ ssl_context.parse_verify_flags(params["verifyflags"][0])
43
+
44
+ # parse and set ssl verify-mode
45
+ if params.get("sslmode") is not None:
46
+ ssl_context.parse_ssl_mode(params["sslmode"][0])
47
+
48
+ # update database configuration
49
+ TORTOISE_CONF = {
50
+ "connections": {
51
+ "default": {
52
+ "engine": "tortoise.backends.asyncpg",
53
+ "credentials": {
54
+ "host": parsed.hostname,
55
+ "port": parsed.port,
56
+ "user": parsed.username,
57
+ "password": parsed.password,
58
+ "database": parsed.path.lstrip("/"),
59
+ "ssl": ssl_context.context,
60
+ },
61
+ },
62
+ },
63
+ "apps": {
64
+ "models": {"models": ["goosebit.db.models", "aerich.models"], "default_connection": "default"},
65
+ },
66
+ }
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ CREATE INDEX "idx_rollout_software_3614e1" ON "rollout" ("software_id");"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ DROP INDEX "idx_rollout_software_3614e1";"""
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ ALTER TABLE "device" ADD "auth_token" VARCHAR(32);"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ ALTER TABLE "device" DROP COLUMN "auth_token";"""
@@ -0,0 +1,16 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ CREATE TABLE IF NOT EXISTS "user" (
7
+ "username" VARCHAR(255) NOT NULL PRIMARY KEY,
8
+ "hashed_pwd" VARCHAR(255) NOT NULL,
9
+ "permissions" JSON NOT NULL,
10
+ "enabled" INT NOT NULL DEFAULT 1
11
+ );"""
12
+
13
+
14
+ async def downgrade(db: BaseDBAsyncClient) -> str:
15
+ return """
16
+ DROP TABLE IF EXISTS "user";"""
@@ -0,0 +1,11 @@
1
+ from tortoise import BaseDBAsyncClient
2
+
3
+
4
+ async def upgrade(db: BaseDBAsyncClient) -> str:
5
+ return """
6
+ ALTER TABLE "device" RENAME COLUMN "uuid" TO "id";"""
7
+
8
+
9
+ async def downgrade(db: BaseDBAsyncClient) -> str:
10
+ return """
11
+ ALTER TABLE "device" RENAME COLUMN "id" TO "uuid";"""
@@ -0,0 +1,83 @@
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
+ ALTER TABLE "device" ALTER COLUMN "feed" DROP NOT NULL;"""
10
+
11
+ return """PRAGMA foreign_keys=off;
12
+
13
+ CREATE TABLE "device_new" (
14
+ "id" CHAR(255) NOT NULL PRIMARY KEY,
15
+ "name" CHAR(255),
16
+ "assigned_software_id" INT,
17
+ "force_update" INT NOT NULL DEFAULT 0,
18
+ "sw_version" CHAR(255),
19
+ "hardware_id" INT NOT NULL,
20
+ "feed" CHAR(255) DEFAULT 'default', -- NULL allowed here
21
+ "update_mode" INT NOT NULL DEFAULT 0,
22
+ "last_state" INT NOT NULL DEFAULT 0,
23
+ "progress" INT,
24
+ "last_log" TEXT,
25
+ "last_seen" BIGINT,
26
+ "last_ip" CHAR(15),
27
+ "last_ipv6" CHAR(40),
28
+ "auth_token" CHAR(32)
29
+ );
30
+
31
+ INSERT INTO "device_new" SELECT
32
+ id, name, assigned_software_id, force_update, sw_version,
33
+ hardware_id, feed, update_mode, last_state, progress,
34
+ last_log, last_seen, last_ip, last_ipv6, auth_token
35
+ FROM "device";
36
+
37
+ DROP TABLE "device";
38
+
39
+ ALTER TABLE "device_new" RENAME TO "device";
40
+
41
+ PRAGMA foreign_keys=on;
42
+ """
43
+
44
+
45
+ async def downgrade(db: BaseDBAsyncClient) -> str:
46
+ dialect = db.schema_generator.DIALECT
47
+
48
+ if dialect == "postgres":
49
+ return """
50
+ ALTER TABLE "device" ALTER COLUMN "feed" SET NOT NULL;"""
51
+
52
+ return """PRAGMA foreign_keys=off;
53
+
54
+ CREATE TABLE "device_old" (
55
+ "id" CHAR(255) NOT NULL PRIMARY KEY,
56
+ "name" CHAR(255),
57
+ "assigned_software_id" INT,
58
+ "force_update" INT NOT NULL DEFAULT 0,
59
+ "sw_version" CHAR(255),
60
+ "hardware_id" INT NOT NULL,
61
+ "feed" CHAR(255) NOT NULL DEFAULT 'default', -- NOT NULL again
62
+ "update_mode" INT NOT NULL DEFAULT 0,
63
+ "last_state" INT NOT NULL DEFAULT 0,
64
+ "progress" INT,
65
+ "last_log" TEXT,
66
+ "last_seen" BIGINT,
67
+ "last_ip" CHAR(15),
68
+ "last_ipv6" CHAR(40),
69
+ "auth_token" CHAR(32)
70
+ );
71
+
72
+ INSERT INTO "device_old" SELECT
73
+ id, name, assigned_software_id, force_update, sw_version,
74
+ hardware_id, feed, update_mode, last_state, progress,
75
+ last_log, last_seen, last_ip, last_ipv6, auth_token
76
+ FROM "device";
77
+
78
+ DROP TABLE "device";
79
+
80
+ ALTER TABLE "device_old" RENAME TO "device";
81
+
82
+ PRAGMA foreign_keys=on;
83
+ """
goosebit/db/models.py CHANGED
@@ -5,13 +5,12 @@ from typing import Self
5
5
  from urllib.parse import unquote, urlparse
6
6
  from urllib.request import url2pathname
7
7
 
8
- import semver
9
8
  from anyio import Path
10
- from semver import Version
11
9
  from tortoise import Model, fields
12
10
  from tortoise.exceptions import ValidationError
13
11
 
14
12
  from goosebit.api.telemetry.metrics import devices_count
13
+ from goosebit.util.version import Version
15
14
 
16
15
 
17
16
  class UpdateModeEnum(IntEnum):
@@ -57,7 +56,7 @@ class Tag(Model):
57
56
 
58
57
 
59
58
  class Device(Model):
60
- uuid = fields.CharField(max_length=255, primary_key=True)
59
+ id = fields.CharField(max_length=255, primary_key=True)
61
60
  name = fields.CharField(max_length=255, null=True)
62
61
  assigned_software = fields.ForeignKeyField(
63
62
  "models.Software", related_name="assigned_devices", null=True, on_delete=fields.SET_NULL
@@ -65,7 +64,7 @@ class Device(Model):
65
64
  force_update = fields.BooleanField(default=False)
66
65
  sw_version = fields.CharField(max_length=255, null=True)
67
66
  hardware = fields.ForeignKeyField("models.Hardware", related_name="devices")
68
- feed = fields.CharField(max_length=255, default="default")
67
+ feed = fields.CharField(max_length=255, default="default", null=True)
69
68
  update_mode = fields.IntEnumField(UpdateModeEnum, default=UpdateModeEnum.ROLLOUT)
70
69
  last_state = fields.IntEnumField(UpdateStateEnum, default=UpdateStateEnum.UNKNOWN)
71
70
  progress = fields.IntField(null=True)
@@ -73,9 +72,14 @@ class Device(Model):
73
72
  last_seen = fields.BigIntField(null=True)
74
73
  last_ip = fields.CharField(max_length=15, null=True)
75
74
  last_ipv6 = fields.CharField(max_length=40, null=True)
75
+ auth_token = fields.CharField(max_length=32, null=True)
76
76
  tags = fields.ManyToManyField("models.Tag", related_name="devices", through="device_tags")
77
77
 
78
78
  async def save(self, *args, **kwargs):
79
+ # ensure if using rollout that feed is set
80
+ if self.update_mode == UpdateModeEnum.ROLLOUT:
81
+ if self.feed is None:
82
+ raise ValidationError("Feed must be set in order to use rollout.")
79
83
  # Check if the software is compatible with the hardware before saving
80
84
  if self.assigned_software and self.hardware:
81
85
  # Check if the assigned software is compatible with the hardware
@@ -107,7 +111,7 @@ class Rollout(Model):
107
111
  created_at = fields.DatetimeField(auto_now_add=True)
108
112
  name = fields.CharField(max_length=255, null=True)
109
113
  feed = fields.CharField(max_length=255, default="default")
110
- software = fields.ForeignKeyField("models.Software", related_name="rollouts")
114
+ software = fields.ForeignKeyField("models.Software", related_name="rollouts", db_index=True)
111
115
  paused = fields.BooleanField(default=False)
112
116
  success_count = fields.IntField(default=0)
113
117
  failure_count = fields.IntField(default=0)
@@ -133,12 +137,12 @@ class Software(Model):
133
137
 
134
138
  @classmethod
135
139
  async def latest(cls, device: Device) -> Self | None:
136
- updates = await cls.filter(compatibility__devices__uuid=device.uuid)
140
+ updates = await cls.filter(compatibility__devices__id=device.id)
137
141
  if len(updates) == 0:
138
142
  return None
139
143
  return sorted(
140
144
  updates,
141
- key=lambda x: semver.Version.parse(x.version, optional_minor_and_patch=True),
145
+ key=lambda x: Version.parse(x.version),
142
146
  reverse=True,
143
147
  )[0]
144
148
 
@@ -159,4 +163,11 @@ class Software(Model):
159
163
 
160
164
  @property
161
165
  def parsed_version(self) -> Version:
162
- return semver.Version.parse(self.version, optional_minor_and_patch=True)
166
+ return Version.parse(self.version)
167
+
168
+
169
+ class User(Model):
170
+ username = fields.CharField(max_length=255, primary_key=True, null=False)
171
+ hashed_pwd = fields.CharField(max_length=255, null=False)
172
+ permissions: fields.JSONField[list[str]] = fields.JSONField(default=[])
173
+ enabled = fields.BooleanField(default=True)
@@ -0,0 +1,51 @@
1
+ import logging
2
+ import ssl
3
+
4
+
5
+ class PostgresSSLContext:
6
+ context: ssl.SSLContext
7
+
8
+ def __init__(self):
9
+ # create ssl context in server-auth mode: this sets verify_mode = required and check_hostname = True
10
+ self.context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
11
+
12
+ def parse_ssl_mode(self, sslmode: str):
13
+ match sslmode:
14
+ case "none":
15
+ self.context.check_hostname = False
16
+ self.context.verify_mode = ssl.CERT_NONE
17
+ case "optional":
18
+ self.context.verify_mode = ssl.CERT_OPTIONAL
19
+ case "require":
20
+ self.context.verify_mode = ssl.CERT_REQUIRED
21
+ case _:
22
+ logging.error("sslmode must be either: none, optional or required!")
23
+ exit(1)
24
+
25
+ # parse and set verify-flags according to postgres string attributes
26
+ # default as defined in python3.13 lib/ssl.py: https://github.com/python/cpython/blob/3.13/Lib/ssl.py#L713)
27
+ # is the following: (ssl.VERIFY_X509_PARTIAL_CHAIN | ssl.VERIFY_X509_STRICT)
28
+ def parse_verify_flags(self, verifyflags: str):
29
+ self.context.verify_flags = ssl.VerifyFlags(0)
30
+ for f in verifyflags.split("|"):
31
+ match f:
32
+ case "default":
33
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_DEFAULT
34
+ case "crl_check_leaf":
35
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_CRL_CHECK_LEAF
36
+ case "crl_check_chain":
37
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN
38
+ case "x509_strict":
39
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_STRICT
40
+ case "allow_proxy_certs":
41
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_ALLOW_PROXY_CERTS
42
+ case "x509_trusted_first":
43
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_TRUSTED_FIRST
44
+ case "x509_partial_chain":
45
+ self.context.verify_flags |= ssl.VerifyFlags.VERIFY_X509_PARTIAL_CHAIN
46
+ case _:
47
+ logging.error(f"verify-flag is undefined: {f}")
48
+ exit(1)
49
+
50
+ def load_verify_locations(self, file):
51
+ self.context.load_verify_locations(file)